diff --git a/.gitignore b/.gitignore index 46ce1ec5..3cc8b82e 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,7 @@ mateclaw-server/src/main/resources/static/ # mateclaw local runtime data (H2 DB, logs, etc. - do not commit) mateclaw-server/data/ +.sessions/ /data/ # VitePress build output and cache (do not commit) diff --git a/mateclaw-desktop/package.json b/mateclaw-desktop/package.json index 336d6171..e1fef6e5 100644 --- a/mateclaw-desktop/package.json +++ b/mateclaw-desktop/package.json @@ -1,6 +1,6 @@ { "name": "mateclaw-desktop", - "version": "2.1.0", + "version": "2.2.0", "description": "MateClaw Desktop - AI Assistant powered by Spring AI Alibaba", "author": "MateClaw Team", "license": "Apache-2.0", diff --git a/mateclaw-server/src/main/java/vip/mate/acp/model/AcpEndpointEntity.java b/mateclaw-server/src/main/java/vip/mate/acp/model/AcpEndpointEntity.java index 88befe46..5effe8c2 100644 --- a/mateclaw-server/src/main/java/vip/mate/acp/model/AcpEndpointEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/acp/model/AcpEndpointEntity.java @@ -60,6 +60,9 @@ public class AcpEndpointEntity { /** Stdio buffer ceiling in bytes; defaults to 50 MiB. */ private Long stdioBufferLimitBytes; + /** Max wait for session/prompt, in seconds. Defaults to 300, capped at 3600. */ + private Integer promptTimeoutSeconds; + /** UNKNOWN / OK / ERROR — last test result. */ private String lastStatus; diff --git a/mateclaw-server/src/main/java/vip/mate/acp/service/AcpDelegationService.java b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpDelegationService.java index 59b4eab2..dcc910e0 100644 --- a/mateclaw-server/src/main/java/vip/mate/acp/service/AcpDelegationService.java +++ b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpDelegationService.java @@ -11,7 +11,6 @@ import vip.mate.acp.model.AcpEndpointEntity; import vip.mate.exception.MateClawException; import java.io.IOException; -import java.time.Duration; import java.util.List; import java.util.Map; @@ -47,11 +46,6 @@ import java.util.Map; @RequiredArgsConstructor public class AcpDelegationService { - /** Hard ceiling on a single ACP delegation. Long enough for a - * multi-turn coding session, short enough that a hung agent can't - * permanently block an LLM tool call. */ - private static final Duration PROMPT_TIMEOUT = Duration.ofMinutes(5); - private static final long INITIALIZE_TIMEOUT_MS = 15_000L; private static final long SESSION_NEW_TIMEOUT_MS = 10_000L; @@ -89,6 +83,7 @@ public class AcpDelegationService { List args = endpointService.parseArgs(endpoint); Map env = endpointService.parseEnv(endpoint); boolean trusted = !Boolean.FALSE.equals(endpoint.getTrusted()); + long promptTimeoutMillis = resolvePromptTimeoutMillis(endpoint); // Always resolve cwd to a real directory: Zed's ACP Zod schema // marks cwd as a required string and rejects {@code undefined} // with -32602. See {@link AcpRuntimeSupport#resolveCwd}. @@ -124,7 +119,7 @@ public class AcpDelegationService { ObjectNode promptParams = objectMapper.createObjectNode(); promptParams.put("sessionId", sessionId); promptParams.set("prompt", buildPromptArray(userPrompt)); - autoClose.sendRequest("session/prompt", promptParams, PROMPT_TIMEOUT.toMillis()); + autoClose.sendRequest("session/prompt", promptParams, promptTimeoutMillis); } catch (IOException | InterruptedException e) { if (e instanceof InterruptedException) Thread.currentThread().interrupt(); log.warn("ACP delegation failed for endpoint '{}': {}", endpointName, e.getMessage()); @@ -144,6 +139,12 @@ public class AcpDelegationService { return accumulator.toString().trim(); } + static long resolvePromptTimeoutMillis(AcpEndpointEntity endpoint) { + int seconds = AcpEndpointService.normalizePromptTimeoutSeconds( + endpoint != null ? endpoint.getPromptTimeoutSeconds() : null); + return seconds * 1000L; + } + private void wireHandlers(AcpStdioClient client, StringBuilder buf, boolean trusted, String endpointName) { // Notifications carry session/update messages; agent_message_chunk diff --git a/mateclaw-server/src/main/java/vip/mate/acp/service/AcpEndpointService.java b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpEndpointService.java index fd2e7f60..5f660497 100644 --- a/mateclaw-server/src/main/java/vip/mate/acp/service/AcpEndpointService.java +++ b/mateclaw-server/src/main/java/vip/mate/acp/service/AcpEndpointService.java @@ -36,6 +36,9 @@ import java.util.Map; @RequiredArgsConstructor public class AcpEndpointService { + public static final int DEFAULT_PROMPT_TIMEOUT_SECONDS = 300; + public static final int MAX_PROMPT_TIMEOUT_SECONDS = 3600; + private final AcpEndpointMapper mapper; private final ObjectMapper objectMapper; private final ApplicationEventPublisher eventPublisher; @@ -91,6 +94,7 @@ public class AcpEndpointService { if (input.getStdioBufferLimitBytes() == null || input.getStdioBufferLimitBytes() <= 0) { input.setStdioBufferLimitBytes(50L * 1024L * 1024L); } + input.setPromptTimeoutSeconds(normalizePromptTimeoutSeconds(input.getPromptTimeoutSeconds())); if (input.getWorkspaceId() == null) input.setWorkspaceId(1L); mapper.insert(input); log.info("Created ACP endpoint: {}", input.getName()); @@ -118,6 +122,9 @@ public class AcpEndpointService { if (patch.getStdioBufferLimitBytes() != null && patch.getStdioBufferLimitBytes() > 0) { existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes()); } + if (patch.getPromptTimeoutSeconds() != null) { + existing.setPromptTimeoutSeconds(normalizePromptTimeoutSeconds(patch.getPromptTimeoutSeconds())); + } mapper.updateById(existing); publish(existing, AcpEndpointChangedEvent.Type.UPDATED); return existing; @@ -180,6 +187,13 @@ public class AcpEndpointService { } } + public static int normalizePromptTimeoutSeconds(Integer seconds) { + if (seconds == null || seconds <= 0) { + return DEFAULT_PROMPT_TIMEOUT_SECONDS; + } + return Math.min(seconds, MAX_PROMPT_TIMEOUT_SECONDS); + } + private List parseStringList(String json) { if (json == null || json.isBlank()) return Collections.emptyList(); try { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index e50e7f3a..c4fe1b07 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -1069,6 +1069,7 @@ public class AgentGraphBuilder { // Summarizing .addStrategy(MateClawStateKeys.SUMMARIZED_CONTEXT, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.FINAL_ANSWER_DRAFT, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.LONG_FORM_DRAFT, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.SHOULD_SUMMARIZE, KeyStrategy.REPLACE) // 终止控制 .addStrategy(MateClawStateKeys.FINISH_REASON, KeyStrategy.REPLACE) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java index 08c4327c..0d83c1a6 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -27,8 +27,10 @@ import vip.mate.workspace.conversation.model.ConversationEntity; import vip.mate.workspace.conversation.repository.ConversationMapper; import java.util.List; +import java.util.Locale; import java.time.Duration; import java.util.Map; +import java.nio.file.Path; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import java.util.function.Supplier; @@ -70,6 +72,9 @@ public class AgentService { @Autowired(required = false) private vip.mate.agent.runtime.RunningConversationRegistry runningConversationRegistry; + @Autowired + private vip.mate.agent.runtime.ConversationTurnGate turnGate = new vip.mate.agent.runtime.ConversationTurnGate(); + /** * Optional — clears leftover auto-recorded ledger entries when a new * user turn starts. Field-injected so existing test constructors of @@ -78,6 +83,13 @@ public class AgentService { @Autowired(required = false) private ProgressLedgerService progressLedgerService; + /** Runtime SPI coordinator. Native agents remain the default. */ + @Autowired(required = false) + private vip.mate.agent.runtime.contract.AgentRuntimeCoordinator runtimeCoordinator; + + @Autowired(required = false) + private vip.mate.agent.runtime.dsh.DshRuntimeService dshRuntimeService; + /** * Runtime Agent instance cache. Keyed first by agentId, then by a model * key, so a conversation that pins a non-default model gets its own graph @@ -132,6 +144,16 @@ public class AgentService { if (agent.getAgentType() == null) { agent.setAgentType("react"); } + if (!StringUtils.hasText(agent.getRuntimeType())) { + agent.setRuntimeType("native"); + } else { + agent.setRuntimeType(agent.getRuntimeType().trim().toLowerCase(Locale.ROOT)); + } + if (!"native".equals(agent.getRuntimeType()) && !"dsh".equals(agent.getRuntimeType())) { + throw new MateClawException("err.agent.runtime_unsupported", 400, + "Unsupported runtime provider: " + agent.getRuntimeType()); + } + validateDshConfiguration(agent); requireUniqueName(agent, null); agentMapper.insert(agent); publishLifecycle(agent, "spawned"); @@ -156,6 +178,9 @@ public class AgentService { } requireUniqueName(agent, agent.getId()); } + if ("dsh".equalsIgnoreCase(agent.getRuntimeType())) { + validateDshConfiguration(agent); + } agentMapper.updateById(agent); agentInstances.remove(agent.getId()); if (prior != null && prior.getEnabled() != null @@ -275,6 +300,8 @@ public class AgentService { * work already done before the pause. */ private void clearAutoRecordedForNewTurn(String conversationId) { + // Autonomous segments resume the same objective; retain authoritative tool progress. + if (vip.mate.agent.context.GoalContinuationContext.active()) return; if (progressLedgerService == null || conversationId == null || conversationId.isBlank()) { return; } @@ -299,6 +326,10 @@ public class AgentService { public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) { clearAutoRecordedForNewTurn(conversationId); memoryRecallTracker.trackRecalls(agentId, message); + if (isDshAgent(agentId)) { + return collectChatResult(chatStructuredStream(agentId, message, conversationId, + "", null, origin != null ? origin : ChatOrigin.EMPTY)).content(); + } BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY); try { @@ -336,6 +367,12 @@ public class AgentService { public Flux chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) { clearAutoRecordedForNewTurn(conversationId); memoryRecallTracker.trackRecalls(agentId, message); + if (isDshAgent(agentId)) { + return chatStructuredStream(agentId, message, conversationId, "", null, + origin != null ? origin : ChatOrigin.EMPTY) + .filter(delta -> delta.content() != null) + .map(StreamDelta::content); + } BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); // Capture the origin into a request-scoped holder; cleared on Flux // termination so the next reactive subscriber doesn't inherit stale state. @@ -373,6 +410,19 @@ public class AgentService { ChatOrigin origin) { clearAutoRecordedForNewTurn(conversationId); memoryRecallTracker.trackRecalls(agentId, message); + if (isDshAgent(agentId)) { + AgentEntity dshAgent = getAgent(agentId); + return withLifecycleFlux(agentId, message, conversationId, + (msg, convId) -> Flux.using( + () -> runtimeCoordinator.start(dshAgent, convId, convId, + dshAgent.getModelName(), dshWorkingDirectory(dshAgent), + dshWorkingDirectory(dshAgent)), + connection -> vip.mate.agent.runtime.RuntimeEventStreamAdapter.adapt( + connection.prompt(msg)), + connection -> connection.close()), + StreamDelta::content) + .doFinally(signal -> ThinkingLevelHolder.clear()); + } BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); // 设置请求级思考深度(通过 ThreadLocal 传递到 StateGraph 执行) @@ -618,6 +668,13 @@ public class AgentService { */ private String withLifecycleSync(Long agentId, String message, String conversationId, java.util.function.BiFunction invoke) { + try (var permit = acquireTurn(conversationId)) { + return invokeWithLifecycleSync(agentId,message,conversationId,invoke); + } + } + + private String invokeWithLifecycleSync(Long agentId, String message, String conversationId, + java.util.function.BiFunction invoke) { safeRegister(conversationId, agentId); try { if (!memoryProperties.isLifecycleMediatorEnabled()) { @@ -646,11 +703,26 @@ public class AgentService { private Flux withLifecycleFlux(Long agentId, String message, String conversationId, java.util.function.BiFunction> invoke, Function contentExtractor) { + return Flux.using(() -> acquireTurn(conversationId), + permit -> invokeWithLifecycleFlux(agentId,message,conversationId,invoke,contentExtractor), + vip.mate.agent.runtime.ConversationTurnGate.Permit::close); + } + + private vip.mate.agent.runtime.ConversationTurnGate.Permit acquireTurn(String conversationId) { + var permit = turnGate.tryAcquire(conversationId); + if (permit == null) throw new MateClawException("err.agent.conversation_busy",409,"Conversation is already running"); + return permit; + } + + private Flux invokeWithLifecycleFlux(Long agentId, String message, String conversationId, + java.util.function.BiFunction> invoke, + Function contentExtractor) { + boolean goalContinuation = vip.mate.agent.context.GoalContinuationContext.active(); safeRegister(conversationId, agentId); try { if (!memoryProperties.isLifecycleMediatorEnabled()) { return invoke.apply(message, conversationId) - .doFinally(s -> safeUnregister(conversationId)); + .doFinally(s -> safeUnregister(conversationId, goalContinuation)); } String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get()); TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey); @@ -666,11 +738,11 @@ public class AgentService { }) .doOnComplete(() -> lifecycleMediator.afterLlmCall(ctx, reply.toString())) .doOnError(e -> log.debug("[Memory] Stream error, skipping afterLlmCall: {}", e.getMessage())) - .doFinally(s -> safeUnregister(conversationId)); + .doFinally(s -> safeUnregister(conversationId, goalContinuation)); } catch (Exception e) { // If invoke.apply() throws before the Flux is constructed, the // doFinally above never runs — clean up here. - safeUnregister(conversationId); + safeUnregister(conversationId, goalContinuation); throw e; } } @@ -684,9 +756,41 @@ public class AgentService { /** C5 helper — null-safe unregister so tests without the registry don't NPE. */ private void safeUnregister(String conversationId) { + safeUnregister(conversationId, vip.mate.agent.context.GoalContinuationContext.active()); + } + + private void safeUnregister(String conversationId, boolean goalContinuation) { if (runningConversationRegistry != null) { runningConversationRegistry.unregister(conversationId); } + if (events != null && !goalContinuation) events.publishEvent(new vip.mate.goal.service.GoalExecutionSignal.TurnFinished(conversationId)); + } + + private boolean isDshAgent(Long agentId) { + if (runtimeCoordinator == null || agentId == null) return false; + AgentEntity entity = getAgent(agentId); + return "dsh".equalsIgnoreCase(entity.getRuntimeType()); + } + + private void validateDshConfiguration(AgentEntity agent) { + if (!"dsh".equalsIgnoreCase(agent.getRuntimeType())) return; + if (dshRuntimeService == null) { + throw new MateClawException("err.agent.runtime_unavailable", 503, + "DSH runtime provider is unavailable"); + } + try { + dshRuntimeService.validateAgentConfiguration(agent); + } catch (IllegalArgumentException error) { + throw new MateClawException("err.agent.runtime_invalid", 400, error.getMessage()); + } + } + + private Path dshWorkingDirectory(AgentEntity agent) { + String configured = System.getenv().getOrDefault("DSH_CWD", System.getProperty("user.dir")); + if (agent.getWorkspaceBasePath() != null && !agent.getWorkspaceBasePath().isBlank()) { + configured = agent.getWorkspaceBasePath().trim(); + } + return Path.of(configured).toAbsolutePath().normalize(); } /** diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java index 0c264d44..cd9b0065 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -13,6 +13,7 @@ import org.springframework.util.MimeType; import reactor.core.publisher.Flux; import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ChatOriginHolder; +import vip.mate.agent.context.GoalContinuationContext; import vip.mate.approval.ApprovalPlaceholderUtil; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.routing.MediaCaptionService; @@ -1264,6 +1265,12 @@ public abstract class BaseAgent { * the primary model can't already handle. */ protected CurrentTurnUserMessage buildCurrentUserMessageWithRouting(String conversationId, String userMessageText) { + // Autonomous segments have no new persisted user row. Reconstructing + // from the last user would replace the continuation/recovery instruction. + // History is still loaded normally; queued user turns retain attachment routing. + if (GoalContinuationContext.explicitPrompt()) { + return new CurrentTurnUserMessage(new UserMessage(userMessageText), null); + } // Scheduled-job run (issue #142): the task text is the explicit // userMessageText argument. Never reconstruct it from the conversation // — a shared cron conversation under concurrent runs has no reliable diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java index 0abe5bb2..2596faec 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -730,6 +730,7 @@ public class AgentBindingService implements AgentBindingResolver { "addGoalCriterion", "completeGoal", "getGoalStatus", + "waitForGoalInput", // Conversation-scoped progress ledger — same rationale as the // goal primitives above. Long multi-step research / drafting // tasks need it on every business agent, not just the planner, diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/GoalContinuationContext.java b/mateclaw-server/src/main/java/vip/mate/agent/context/GoalContinuationContext.java new file mode 100644 index 00000000..970469e4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/GoalContinuationContext.java @@ -0,0 +1,20 @@ +package vip.mate.agent.context; + +import java.util.function.Supplier; + +/** Subscription-time marker; callers capture it before asynchronous lifecycle callbacks. */ +public final class GoalContinuationContext { + private static final ThreadLocal EXPLICIT_PROMPT = new ThreadLocal<>(); + private GoalContinuationContext() {} + public static boolean active() { return EXPLICIT_PROMPT.get() != null; } + public static boolean explicitPrompt() { return Boolean.TRUE.equals(EXPLICIT_PROMPT.get()); } + public static T call(Supplier action) { return call(true, action); } + + /** Queued user input keeps normal attachment reconstruction within the same worker. */ + public static T call(boolean explicitPrompt, Supplier action) { + Boolean previous=EXPLICIT_PROMPT.get(); + EXPLICIT_PROMPT.set(explicitPrompt); + try { return action.get(); } + finally { if(previous==null) EXPLICIT_PROMPT.remove(); else EXPLICIT_PROMPT.set(previous); } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java index d6b23486..8b6a98ef 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java @@ -310,10 +310,17 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) { lastEmittedStreamedContent.set(streamed); boolean completionRetry = output.state().value(CONTINUE_REASONING, false); - addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn, - completionRetry || output.state().value(NEEDS_TOOL_CALL, false), - completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0), - streamed)); + boolean longFormAccumulation = !output.state() + .value(LONG_FORM_DRAFT, "").isEmpty(); + String resolvedFinalAnswer = isFinalAnswerTurn + ? extractFinalAnswer(output) : ""; + if (shouldEmitStreamedContent(isFinalAnswerTurn, longFormAccumulation, + streamed, resolvedFinalAnswer)) { + addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn, + completionRetry || output.state().value(NEEDS_TOOL_CALL, false), + completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0), + streamed)); + } } if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) { @@ -503,10 +510,17 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) { lastEmittedStreamedContent.set(streamed); boolean completionRetry = output.state().value(CONTINUE_REASONING, false); - addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn, - completionRetry || output.state().value(NEEDS_TOOL_CALL, false), - completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0), - streamed)); + boolean longFormAccumulation = !output.state() + .value(LONG_FORM_DRAFT, "").isEmpty(); + String resolvedFinalAnswer = isFinalAnswerTurn + ? extractFinalAnswer(output) : ""; + if (shouldEmitStreamedContent(isFinalAnswerTurn, longFormAccumulation, + streamed, resolvedFinalAnswer)) { + addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn, + completionRetry || output.state().value(NEEDS_TOOL_CALL, false), + completionRetry ? 0 : output.state().value(TOOL_CALL_COUNT, 0), + streamed)); + } } if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) { @@ -633,6 +647,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC inputs.put(TOOL_CALL_COUNT, 0); inputs.put(ERROR_COUNT, 0); inputs.put(SHOULD_SUMMARIZE, false); + inputs.put(LONG_FORM_DRAFT, ""); inputs.put(LIMIT_EXCEEDED, false); inputs.put(CONTENT_STREAMED, false); inputs.put(THINKING_STREAMED, false); @@ -774,6 +789,17 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC return AgentService.StreamDelta.segmentOnly(streamed, null, kind); } + static boolean shouldEmitStreamedContent(boolean isFinalAnswerTurn, + boolean longFormAccumulation, + String streamed, + String finalAnswer) { + if (longFormAccumulation) { + return false; + } + return !isFinalAnswerTurn || finalAnswer == null || streamed == null + || !finalAnswer.contains(streamed); + } + private boolean hasFinalAnswer(NodeOutput output) { if (output == null || output.state() == null) { return false; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java index 4500618c..9e0f0663 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java @@ -23,6 +23,7 @@ import vip.mate.approval.grant.AutoApproveResult; import vip.mate.approval.grant.WorkspaceLookupCache; import vip.mate.approval.grant.service.ApprovalGrantResolver; import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.tool.ToolInputValidationException; import vip.mate.tool.guard.ToolExecutionGuardHelper; import vip.mate.tool.guard.ToolGuard; import vip.mate.tool.guard.ToolGuardResult; @@ -465,7 +466,29 @@ public class ToolExecutionExecutor { boolean isReplay, String requesterId, String workspaceBasePath, ChatOrigin origin) { + return execute(toolCalls, conversationId, agentId, isReplay, requesterId, + workspaceBasePath, origin, Set.of()); + } + + /** + * Preferred graph overload. {@code loadedSkills} is the conversation/run + * state captured before this batch, allowing the shared executor to reject + * both cross-iteration and same-batch duplicate {@code load_skill} calls + * before parallel execution starts. + */ + public ToolExecutionResult execute(List toolCalls, + String conversationId, String agentId, + boolean isReplay, String requesterId, + String workspaceBasePath, + ChatOrigin origin, + Set loadedSkills) { ChatOrigin safeOrigin = origin != null ? origin : ChatOrigin.EMPTY; + if (isBlank(safeOrigin.conversationId()) && !isBlank(conversationId)) { + safeOrigin = safeOrigin.withConversationId(conversationId); + } + if (isBlank(safeOrigin.workspaceBasePath()) && !isBlank(workspaceBasePath)) { + safeOrigin = safeOrigin.withWorkspace(safeOrigin.workspaceId(), workspaceBasePath); + } // Reset per-turn audit dedupe state. A retried denied tool inside the // same turn writes a single audit row; the set is repopulated by the // denial branch below. @@ -511,6 +534,15 @@ public class ToolExecutionExecutor { // ═══ Phase 1: 顺序 Guard + 分段 ═══ List preparedCalls = new ArrayList<>(); ApprovalBarrier barrier = null; + Set seenSkillLoads = new LinkedHashSet<>(); + if (loadedSkills != null) { + loadedSkills.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(name -> !name.isEmpty()) + .map(name -> name.toLowerCase(Locale.ROOT)) + .forEach(seenSkillLoads::add); + } for (int i = 0; i < effectiveCalls.size(); i++) { AssistantMessage.ToolCall toolCall = effectiveCalls.get(i); @@ -590,6 +622,26 @@ public class ToolExecutionExecutor { } } + // load_skill is retrieval-only and concurrency-safe, so identical + // calls in one model response would otherwise race through the + // parallel phase and read/record the same skill twice. Keep this in + // the shared executor so both ActionNode and plan execution receive + // identical protection while preserving one response per call id. + if ("load_skill".equals(toolName)) { + String requestedSkill = requestedSkillName(arguments); + if (requestedSkill != null + && !seenSkillLoads.add(requestedSkill.toLowerCase(Locale.ROOT))) { + String message = "Skill '" + requestedSkill + "' was already loaded earlier in this run. " + + "Reuse the SKILL.md content already present in the conversation; " + + "do not call load_skill for this skill again."; + log.debug("[ToolExecutor] Skipping duplicate load_skill({})", requestedSkill); + events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, message, true)); + allResponses.add(new ToolResponseMessage.ToolResponse( + toolCall.id(), responseName, message)); + continue; + } + } + // 2. ToolGuard 安全检查(replay 模式跳过) if (!isReplay) { GuardDecision decision = evaluateGuard(toolCall, toolName, arguments, @@ -678,6 +730,23 @@ public class ToolExecutionExecutor { rawEvidenceRef.get()); } + private static String requestedSkillName(String arguments) { + if (arguments == null || arguments.isBlank()) { + return null; + } + try { + var node = OBJECT_MAPPER.readTree(arguments); + var value = node.get("skillName"); + if (value == null || value.isNull() || value.asText().isBlank()) { + value = node.get("name"); + } + return value == null || value.isNull() || value.asText().isBlank() + ? null : value.asText().trim(); + } catch (Exception ignored) { + return null; + } + } + /** * Execute a pre-approved tool call (used by StepExecutionNode's replay path * after a user approves a previously-blocked invocation). @@ -767,6 +836,13 @@ public class ToolExecutionExecutor { } events.add(GraphEventPublisher.toolDirectResult( toolCall.id(), toolName, fullResult)); + // A direct result replaces the tool card's body, but the + // started event still needs a terminal pair so live clients + // do not leave the card spinning forever. The placeholder is + // deliberately used here: the full result remains confined to + // tool_direct_result / DIRECT_TOOL_OUTPUTS. + events.add(GraphEventPublisher.toolComplete( + toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER, true)); return new ToolResponseMessage.ToolResponse( toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER); } @@ -789,9 +865,12 @@ public class ToolExecutionExecutor { throw e; } catch (Exception e) { log.error("[ToolExecutor] Pre-approved tool {} failed: {}", toolName, e.getMessage()); - String safeError = isReturnDirect(callback) - ? "Tool execution failed (details withheld per returnDirect policy)" - : "Tool execution failed: " + e.getMessage(); + String validationError = safeInputValidationMessage(e); + String safeError = validationError != null + ? validationError + : isReturnDirect(callback) + ? "Tool execution failed (details withheld per returnDirect policy)" + : "Tool execution failed: " + e.getMessage(); events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, safeError, false)); return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, safeError); } finally { @@ -1019,8 +1098,14 @@ public class ToolExecutionExecutor { if (streamTracker != null) { streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_DIRECT_RESULT, directEvent.data()); + streamTracker.broadcastObject(pc.conversationId, + GraphEventPublisher.EVENT_TOOL_COMPLETE, + GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, + DIRECT_TOOL_PLACEHOLDER, true).data()); streamTracker.updateRunningTool(pc.conversationId, null); } + events.add(GraphEventPublisher.toolComplete( + pc.toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER, true)); // Placeholder keeps the tool_call_id ↔ tool_response pairing valid // for OpenAI-compatible providers, while withholding the data from // any subsequent LLM round (the graph won't take a next round — @@ -1080,9 +1165,12 @@ public class ToolExecutionExecutor { // or other sensitive substrings that should not enter LLM context. // Emit a generic placeholder instead. Full error still goes to logs // for operator diagnosis. - String reportedError = isReturnDirect(pc.callback) - ? "Tool execution failed (details withheld per returnDirect policy)" - : normalizeToolExecutionError(e); + String validationError = safeInputValidationMessage(e); + String reportedError = validationError != null + ? validationError + : isReturnDirect(pc.callback) + ? "Tool execution failed (details withheld per returnDirect policy)" + : normalizeToolExecutionError(e); events.add(GraphEventPublisher.toolComplete(pc.toolCall.id(), toolName, reportedError, false)); if (streamTracker != null) { streamTracker.broadcastObject(pc.conversationId, GraphEventPublisher.EVENT_TOOL_COMPLETE, @@ -1215,6 +1303,10 @@ public class ToolExecutionExecutor { return GuardDecision.allowed(); } + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + /** * Deny an approval-required tool when the run is non-interactive (no human can * approve), returning an actionable message so the agent falls back to a @@ -1304,6 +1396,17 @@ public class ToolExecutionExecutor { return "Tool execution failed: " + message; } + private String safeInputValidationMessage(Throwable error) { + Throwable current = error; + while (current != null) { + if (current instanceof ToolInputValidationException validation) { + return "Tool input validation failed: " + validation.getMessage(); + } + current = current.getCause(); + } + return null; + } + /** * Issue #46 — when a tool callback miss happens, check whether the * unrecognized name actually matches an active skill. If it does, return diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java index 66949b6a..8423ae04 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java @@ -144,7 +144,8 @@ public class ActionNode implements NodeAction { // 委托 ToolExecutionExecutor 执行(两阶段:顺序 Guard + 分段并发执行) ToolExecutionExecutor.ToolExecutionResult result = executor.execute( - toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath, origin); + toolCalls, conversationId, agentId, isReplay, requesterId, workspaceBasePath, origin, + accessor.loadedSkills()); ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder() .responses(result.responses()) @@ -406,7 +407,7 @@ public class ActionNode implements NodeAction { return names; } - static Set extractLoadedSkillNames(List toolCalls) { + public static Set extractLoadedSkillNames(List toolCalls) { if (toolCalls == null || toolCalls.isEmpty()) { return Set.of(); } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java index 042b35fc..841f1816 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java @@ -12,12 +12,16 @@ import vip.mate.agent.graph.state.MateClawStateAccessor; import vip.mate.goal.config.GoalProperties; import vip.mate.goal.model.GoalEntity; import vip.mate.goal.model.GoalEvaluationResult; +import vip.mate.goal.model.GoalResponse; import vip.mate.goal.service.GoalEvaluationService; import vip.mate.goal.service.GoalFollowupService; import vip.mate.goal.service.GoalService; import vip.mate.goal.service.GraphFlavor; import vip.mate.workspace.conversation.ConversationService; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -184,6 +188,12 @@ public class GoalEvaluationNode implements NodeAction { .build(); } + if (Boolean.TRUE.equals(refreshed.getPersistentExecution()) + && refreshed.getStatus()!=vip.mate.goal.model.GoalStatus.ACTIVE) { + return MateClawStateAccessor.output().goalEvaluatedThisRun(true) + .events(List.of(skippedEvent(refreshed.getId(), "goal_no_longer_active"))).build(); + } + // Decision branches. Each terminal write is wrapped so a DB hiccup // (e.g. optimistic-lock conflict exceeding retries, memory sync // failure on completion) does not propagate into the chat graph @@ -199,7 +209,7 @@ public class GoalEvaluationNode implements NodeAction { .events(List.of(goalEvent("goal_completed", Map.of( "goalId", String.valueOf(completed.getId()), "score", result.score(), - "goal", goalService.toResponse(completed))))) + "goal", stateSafeGoal(goalService.toResponse(completed)))))) .build(); } @@ -216,7 +226,7 @@ public class GoalEvaluationNode implements NodeAction { "evalLlmCallsUsed", exhausted.getEvalLlmCallsUsed(), "totalLlmCallsUsed", exhausted.totalLlmCallsUsed(), "reason", reason, - "goal", goalService.toResponse(exhausted))))) + "goal", stateSafeGoal(goalService.toResponse(exhausted)))))) .build(); } } catch (Throwable t) { @@ -229,6 +239,21 @@ public class GoalEvaluationNode implements NodeAction { .build(); } + // A persistent goal yields a finite segment. Its durable supervisor owns + // the next turn, cooldown and recovery; never consume graph recursion here. + if (Boolean.TRUE.equals(refreshed.getPersistentExecution())) { + return MateClawStateAccessor.output() + .goalEvaluationResult(result.toMap()) + .goalEvaluatedThisRun(true) + .events(List.of(goalEvent("goal_evaluated", Map.of( + "goalId", String.valueOf(refreshed.getId()), + "score", result.score(), + "decision", result.decision(), + "gap", result.gap() == null ? "" : result.gap(), + "goal", stateSafeGoal(goalService.toResponse(refreshed)))))) + .build(); + } + int followupCountThisRun = accessor.goalFollowupCount(); int hardContinuationCount = accessor.goalHardContinuationCount(); int hardCap = Math.min(properties.getMaxHardContinuationsPerRun(), @@ -289,7 +314,7 @@ public class GoalEvaluationNode implements NodeAction { .events(List.of(goalEvent("goal_followup", Map.of( "goalId", String.valueOf(refreshed.getId()), "prompt", followup.get(), - "goal", goalService.toResponse(refreshed))))); + "goal", stateSafeGoal(goalService.toResponse(refreshed)))))); if (flavor == GraphFlavor.REACT) { // ReAct: append the followup as a fresh user message via the @@ -343,10 +368,72 @@ public class GoalEvaluationNode implements NodeAction { "goalId", String.valueOf(refreshed.getId()), "score", result.score(), "gap", result.gap() == null ? "" : result.gap(), - "goal", goalService.toResponse(refreshed))))) + "goal", stateSafeGoal(goalService.toResponse(refreshed)))))) .build(); } + /** + * Graph state may be checkpointed and restored through a generic map + * serializer. Keep event payloads limited to JSON primitives, maps and + * lists so a restored checklist cannot contain raw maps inside a typed + * {@link GoalResponse} bean and fail during SSE serialization. + */ + private static Map stateSafeGoal(GoalResponse goal) { + if (goal == null) { + return Map.of(); + } + Map snapshot = new LinkedHashMap<>(); + snapshot.put("id", stringId(goal.getId())); + snapshot.put("conversationId", goal.getConversationId()); + snapshot.put("agentId", stringId(goal.getAgentId())); + snapshot.put("workspaceId", stringId(goal.getWorkspaceId())); + snapshot.put("createdBy", goal.getCreatedBy()); + snapshot.put("title", goal.getTitle()); + snapshot.put("description", goal.getDescription()); + snapshot.put("exitCriteria", goal.getExitCriteria()); + snapshot.put("successCheckPrompt", goal.getSuccessCheckPrompt()); + snapshot.put("status", goal.getStatus() == null ? null : goal.getStatus().getValue()); + snapshot.put("persistentExecution", goal.getPersistentExecution()); + snapshot.put("turnBudget", goal.getTurnBudget()); + snapshot.put("turnsUsed", goal.getTurnsUsed()); + snapshot.put("llmCallBudget", goal.getLlmCallBudget()); + snapshot.put("agentLlmCallsUsed", goal.getAgentLlmCallsUsed()); + snapshot.put("evalLlmCallsUsed", goal.getEvalLlmCallsUsed()); + snapshot.put("totalLlmCallsUsed", goal.getTotalLlmCallsUsed()); + snapshot.put("progressSummary", goal.getProgressSummary()); + snapshot.put("completionScore", goal.getCompletionScore()); + snapshot.put("lastEvaluationAt", stringTime(goal.getLastEvaluationAt())); + snapshot.put("autoFollowupEnabled", goal.getAutoFollowupEnabled()); + snapshot.put("followupCooldownSeconds", goal.getFollowupCooldownSeconds()); + snapshot.put("lastFollowupAt", stringTime(goal.getLastFollowupAt())); + snapshot.put("version", goal.getVersion()); + snapshot.put("createTime", stringTime(goal.getCreateTime())); + snapshot.put("updateTime", stringTime(goal.getUpdateTime())); + + List> criteria = new ArrayList<>(); + if (goal.getCriteria() != null) { + goal.getCriteria().forEach(criterion -> { + if (criterion == null) return; + Map item = new LinkedHashMap<>(); + item.put("id", criterion.id() == null ? "" : criterion.id()); + item.put("text", criterion.text() == null ? "" : criterion.text()); + item.put("passed", criterion.passed()); + item.put("evidence", criterion.evidence() == null ? "" : criterion.evidence()); + criteria.add(Collections.unmodifiableMap(item)); + }); + } + snapshot.put("criteria", List.copyOf(criteria)); + return Collections.unmodifiableMap(snapshot); + } + + private static String stringId(Long value) { + return value == null ? null : value.toString(); + } + + private static String stringTime(java.time.LocalDateTime value) { + return value == null ? null : value.toString(); + } + /** * Resolve the active goal for this run: prefer the turn-start * {@code ACTIVE_GOAL} snapshot; if absent, fall back to a conversation diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java index bd7ffd1b..24147384 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java @@ -37,6 +37,8 @@ import vip.mate.team.service.TeamContextBuilder; import java.util.*; import java.util.concurrent.CancellationException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import static vip.mate.agent.graph.state.MateClawStateKeys.*; @@ -137,6 +139,17 @@ public class ReasoningNode implements NodeAction { */ private static final int KEEP_RECENT_TOOL_RESPONSES = 3; + private static final int LONG_FORM_MIN_REQUEST_CHARS = 3_000; + private static final Pattern ARABIC_CHAR_COUNT_PATTERN = Pattern.compile( + "(\\d{1,3}(?:[,,]\\d{3})+|\\d+(?:\\.\\d+)?)\\s*(万|千|k|K)?\\s*(字|字符|中文字|汉字|word|words)"); + private static final Pattern CHINESE_TEN_THOUSAND_CHARS_PATTERN = Pattern.compile( + "(一万|1万|十千)\\s*(字|字符|中文字|汉字)"); + private static final Pattern EXPLICIT_ARTIFACT_REQUEST_PATTERN = Pattern.compile( + "(?i)(word|docx|pdf|pptx|xlsx|markdown|\\bmd\\b|下载|附件|文档|文件|保存|落盘|导出)"); + private static final List ARTIFACT_DELIVERY_TOOL_PREFIXES = List.of( + "renderDocx", "renderPdf", "renderPptx", "renderXlsx", "send_file", "sendFile", + "write_file", "local_write_file", "edit_file", "local_edit_file"); + /** Continuation nudge appended to the prompt when the model returns an empty turn. */ private static final String EMPTY_COMPLETION_NUDGE = "上一轮回复为空。如果任务尚未完成,请现在继续执行下一个具体步骤:" @@ -235,6 +248,100 @@ public class ReasoningNode implements NodeAction { return false; } + static OptionalInt requestedLongFormChars(String userMessage) { + if (userMessage == null || userMessage.isBlank()) { + return OptionalInt.empty(); + } + Matcher tenThousand = CHINESE_TEN_THOUSAND_CHARS_PATTERN.matcher(userMessage); + if (tenThousand.find()) { + return OptionalInt.of(10_000); + } + Matcher matcher = ARABIC_CHAR_COUNT_PATTERN.matcher(userMessage); + int best = 0; + while (matcher.find()) { + String rawNumber = matcher.group(1).replace(",", "").replace(",", ""); + double value; + try { + value = Double.parseDouble(rawNumber); + } catch (NumberFormatException ignored) { + continue; + } + String unit = matcher.group(2); + if ("万".equals(unit)) { + value *= 10_000; + } else if ("千".equals(unit) || "k".equals(unit) || "K".equals(unit)) { + value *= 1_000; + } + best = Math.max(best, (int) Math.round(value)); + } + return best >= LONG_FORM_MIN_REQUEST_CHARS ? OptionalInt.of(best) : OptionalInt.empty(); + } + + static List filterLongFormArtifactTools(String userMessage, + List callbacks) { + String currentRequest = currentUserRequest(userMessage); + if (callbacks == null || callbacks.isEmpty() + || requestedLongFormChars(currentRequest).isEmpty() + || EXPLICIT_ARTIFACT_REQUEST_PATTERN.matcher(currentRequest).find()) { + return callbacks; + } + return callbacks.stream() + .filter(callback -> { + String name = callback.getToolDefinition().name(); + return ARTIFACT_DELIVERY_TOOL_PREFIXES.stream().noneMatch(name::startsWith); + }) + .toList(); + } + + static boolean hasDisallowedLongFormArtifactCall(String userMessage, + List toolCalls) { + String currentRequest = currentUserRequest(userMessage); + if (toolCalls == null || toolCalls.isEmpty() + || requestedLongFormChars(currentRequest).isEmpty() + || EXPLICIT_ARTIFACT_REQUEST_PATTERN.matcher(currentRequest).find()) { + return false; + } + return toolCalls.stream().anyMatch(call -> ARTIFACT_DELIVERY_TOOL_PREFIXES.stream() + .anyMatch(prefix -> call.name().startsWith(prefix))); + } + + private static String currentUserRequest(String userMessage) { + if (userMessage == null) { + return ""; + } + int memoryEnd = userMessage.lastIndexOf(""); + return memoryEnd >= 0 + ? userMessage.substring(memoryEnd + "".length()).trim() + : userMessage; + } + + private static String appendLongFormChunk(String draft, String currentContent) { + return (draft != null ? draft : "") + (currentContent != null ? currentContent : ""); + } + + private static boolean shouldContinueLongForm(String userMessage, String longFormDraft, + String currentContent, int iteration, int maxIterations) { + OptionalInt requested = requestedLongFormChars(userMessage); + if (requested.isEmpty()) { + return false; + } + if (maxIterations > 0 && iteration + 1 >= maxIterations) { + return false; + } + return appendLongFormChunk(longFormDraft, currentContent).length() < requested.getAsInt(); + } + + private static UserMessage longFormContinuationPrompt(String userMessage, String longFormDraft, + String currentContent) { + int written = appendLongFormChunk(longFormDraft, currentContent).length(); + int requested = requestedLongFormChars(userMessage).orElse(0); + return new UserMessage(""" + [Runtime long-form continuation] + 用户明确要求长篇输出,目标约 %d 字;目前累计约 %d 字,尚未达到目标。 + 请从上一段结尾自然继续写,不要重写开头,不要总结,不要说明原因,直接续写正文。 + """.formatted(requested, written)); + } + /** * Tool-use enforcement clause appended to every ReasoningNode * system prompt. Treats narration ("I will now …") as a protocol violation @@ -857,6 +964,7 @@ public class ReasoningNode implements NodeAction { ? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools(), autoDemotedTools) .activeCallbacks() : toolCallbacks; + activeCallbacks = filterLongFormArtifactTools(accessor.userMessage(), activeCallbacks); ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks); @@ -1142,6 +1250,34 @@ public class ReasoningNode implements NodeAction { } if (result.hasToolCalls()) { + if (hasDisallowedLongFormArtifactCall(accessor.userMessage(), result.toolCalls())) { + log.warn("[ReasoningNode] Rejecting artifact tool call for plain long-form response: {}", + result.toolCalls().stream().map(AssistantMessage.ToolCall::name).toList()); + UserMessage continuation = new UserMessage(""" + [Runtime long-form delivery gate] + The user requested the long-form text directly in chat and did not request a file, + document, attachment, export, or download. Do not call rendering or file-writing tools. + Continue writing the requested text directly in the response. + """); + return reasonOutput() + .continueReasoning(true) + .iterationCount(accessor.iterationCount() + 1) + .needsToolCall(false) + .shouldSummarize(false) + .toolCalls(List.of()) + .finalAnswer("") + .clearFinishReason() + .messages(List.of((Message) continuation)) + .currentPhase("reasoning") + .streamedContent("") + .streamedThinking(result.thinking()) + .contentStreamed(true) + .thinkingStreamed(!result.thinking().isEmpty()) + .llmCallCount(nextLlmCallCount) + .mergeUsage(state, result) + .events(buildEvents(phaseEvent, iterStartEvent)) + .build(); + } log.info("[ReasoningNode] LLM requested {} tool call(s): {}", result.toolCalls().size(), result.toolCalls().stream().map(AssistantMessage.ToolCall::name).toList()); @@ -1219,12 +1355,43 @@ public class ReasoningNode implements NodeAction { .build(); } log.info("[ReasoningNode] LLM produced final answer ({} chars)", content != null ? content.length() : 0); + if (shouldContinueLongForm(accessor.userMessage(), accessor.longFormDraft(), content, + accessor.iterationCount(), accessor.maxIterations())) { + String accumulatedDraft = appendLongFormChunk(accessor.longFormDraft(), content); + int written = accumulatedDraft.length(); + int requested = requestedLongFormChars(accessor.userMessage()).orElse(0); + log.info("[ReasoningNode] Long-form answer below requested length ({} / {} chars), continuing", + written, requested); + return reasonOutput() + .continueReasoning(true) + .iterationCount(accessor.iterationCount() + 1) + .needsToolCall(false) + .shouldSummarize(false) + .finalAnswer("") + .longFormDraft(accumulatedDraft) + .clearFinishReason() + .messages(List.of((Message) result.assistantMessage(), + longFormContinuationPrompt(accessor.userMessage(), accessor.longFormDraft(), content))) + .currentPhase("reasoning") + .streamedContent(content != null ? content : "") + .streamedThinking(result.thinking()) + .contentStreamed(true) + .thinkingStreamed(!result.thinking().isEmpty()) + .llmCallCount(nextLlmCallCount) + .mergeUsage(state, result) + .events(buildEvents(phaseEvent, iterStartEvent)) + .build(); + } pushPhase(conversationId, "drafting_answer", Map.of( "iteration", accessor.iterationCount(), "answerChars", content != null ? content.length() : 0 )); + boolean longFormRequest = requestedLongFormChars(accessor.userMessage()).isPresent(); + String accumulatedContent = longFormRequest + ? appendLongFormChunk(accessor.longFormDraft(), content) + : (content != null ? content : ""); String answerWithSources = accessor.sourceEvidenceLedger() - .appendWikiSourceTable(content != null ? content : ""); + .appendWikiSourceTable(accumulatedContent); SourceEvidenceLedger.Validation validation = accessor.sourceEvidenceLedger().validateAnswer(answerWithSources); boolean evidenceInsufficient = !validation.valid(); @@ -1251,9 +1418,9 @@ public class ReasoningNode implements NodeAction { .finalThinking(result.thinking()) .messages(List.of((Message) result.assistantMessage())) .currentPhase("reasoning") - .streamedContent(evidenceInsufficient ? (content != null ? content : "") : "") + .streamedContent(evidenceInsufficient ? accumulatedContent : "") .finishReason(evidenceInsufficient ? FinishReason.EVIDENCE_INSUFFICIENT : FinishReason.NORMAL) - .contentStreamed(!evidenceInsufficient && Objects.equals(answerWithSources, content != null ? content : "")) + .contentStreamed(!evidenceInsufficient && Objects.equals(answerWithSources, accumulatedContent)) .thinkingStreamed(!result.thinking().isEmpty()) .llmCallCount(nextLlmCallCount) .mergeUsage(state, result) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java index 44bd5a3d..26b9f1d3 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java @@ -623,7 +623,9 @@ public class PlanGenerationNode implements NodeAction { + "相互独立的步骤请不要标注前置,以便并行执行。\n" + "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。\n" + "4. 若用户要求编号轮次、检查点区间或连续跟踪,必须包含一个专门的共享跟踪步骤," - + "明确区间、证据格式和完成条件;不要只把轮次要求埋在普通交付步骤中。")); + + "明确区间、证据格式和完成条件;不要只把轮次要求埋在普通交付步骤中。\n" + + "5. 不要创建专门的‘最终汇总/总结/验收’成员步骤;系统会在所有任务结束后自动汇总。" + + "把必要的自检和验收标准写进实际产出步骤,避免为了复述结果增加串行任务。")); } else { List delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId); if (!delegatable.isEmpty()) { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java index 85271f59..2fc267b4 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import vip.mate.agent.AgentToolSet; import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.graph.NodeStreamingChatHelper; +import vip.mate.agent.graph.node.ActionNode; import vip.mate.agent.graph.plan.state.PlanStateAccessor; import vip.mate.agent.graph.plan.state.PlanStateKeys; import vip.mate.agent.graph.state.DirectToolOutput; @@ -35,6 +36,7 @@ import vip.mate.tool.builtin.DelegationContext; import vip.mate.tool.builtin.ToolExecutionContext; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -195,6 +197,7 @@ public class StepExecutionNode implements NodeAction { .orElse(vip.mate.agent.context.ChatOrigin.EMPTY); String runtimeModelName = state.value(MateClawStateKeys.RUNTIME_MODEL_NAME, ""); String runtimeProviderId = state.value(MateClawStateKeys.RUNTIME_PROVIDER_ID, ""); + Set loadedSkills = new LinkedHashSet<>(accessor.loadedSkills()); if (stepIndex >= steps.size()) { log.warn("[StepExecution] stepIndex {} >= steps.size() {}, skipping", stepIndex, steps.size()); @@ -202,6 +205,7 @@ public class StepExecutionNode implements NodeAction { .currentStepResult("步骤索引越界") .completedResults(formatStepResult(stepIndex, "步骤索引越界")) .currentStepIndex(stepIndex + 1) + .loadedSkills(Set.copyOf(loadedSkills)) .build(); } @@ -364,7 +368,8 @@ public class StepExecutionNode implements NodeAction { } else { // 非预批准工具走正常执行器 ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute( - List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath, chatOrigin); + List.of(toolCall), conversationId, agentId, false, "", workspaceBasePath, + chatOrigin, loadedSkills); toolResponses.addAll(execResult.responses()); events.addAll(execResult.events()); if (execResult.hasDirectOutputs()) { @@ -379,20 +384,28 @@ public class StepExecutionNode implements NodeAction { } } else { // 正常路径:委托 ToolExecutionExecutor(支持并发执行 + 审批 barrier) - ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute( - allToolCalls, conversationId, agentId, false, "", workspaceBasePath, chatOrigin); - toolResponses.addAll(execResult.responses()); - events.addAll(execResult.events()); - if (execResult.hasDirectOutputs()) { - stepDirectOutputs.addAll(execResult.directOutputs()); - } - if (execResult.awaitingApproval()) { - approvalTriggered = true; - approvalToolName = execResult.barrierToolName() != null - ? execResult.barrierToolName() : "unknown"; + if (!allToolCalls.isEmpty()) { + ToolExecutionExecutor.ToolExecutionResult execResult = executor.execute( + allToolCalls, conversationId, agentId, false, "", workspaceBasePath, + chatOrigin, loadedSkills); + toolResponses.addAll(execResult.responses()); + events.addAll(execResult.events()); + if (execResult.hasDirectOutputs()) { + stepDirectOutputs.addAll(execResult.directOutputs()); + } + if (execResult.awaitingApproval()) { + approvalTriggered = true; + approvalToolName = execResult.barrierToolName() != null + ? execResult.barrierToolName() : "unknown"; + } } } + Set requestedSkills = ActionNode.extractLoadedSkillNames(allToolCalls); + if (!requestedSkills.isEmpty() && loadedSkills.addAll(requestedSkills)) { + log.debug("[StepExecution] pinned loaded skills in plan state: {}", requestedSkills); + } + // 将工具响应追加到消息 ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder() .responses(toolResponses) @@ -450,6 +463,7 @@ public class StepExecutionNode implements NodeAction { .currentPhase("awaiting_approval") .contentStreamed(true) .thinkingStreamed(!stepThinking.isEmpty()) + .loadedSkills(Set.copyOf(loadedSkills)) .addStepUsage(state, stepPromptTokens, stepCompletionTokens, stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) .events(events) @@ -486,6 +500,7 @@ public class StepExecutionNode implements NodeAction { .contentStreamed(false) // 由 StateGraphPlanExecuteAgent 经 finalSummary 推送 .put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true) .put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs)) + .loadedSkills(Set.copyOf(loadedSkills)) .addStepUsage(state, stepPromptTokens, stepCompletionTokens, stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) .events(events) @@ -536,6 +551,7 @@ public class StepExecutionNode implements NodeAction { .currentStepTitle("") .currentStepResult("") .contentStreamed(false) + .loadedSkills(Set.copyOf(loadedSkills)) .addStepUsage(state, stepPromptTokens, stepCompletionTokens, stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) .events(events) @@ -594,6 +610,7 @@ public class StepExecutionNode implements NodeAction { .currentStepTitle("") .currentStepResult("") .contentStreamed(false) + .loadedSkills(Set.copyOf(loadedSkills)) .addStepUsage(state, stepPromptTokens, stepCompletionTokens, stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) .events(events) @@ -610,6 +627,7 @@ public class StepExecutionNode implements NodeAction { // FINAL_SUMMARY is the single persistence/broadcast channel. .finalSummary(shortError) .contentStreamed(false) + .loadedSkills(Set.copyOf(loadedSkills)) .addStepUsage(state, stepPromptTokens, stepCompletionTokens, stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) .events(events) @@ -654,6 +672,7 @@ public class StepExecutionNode implements NodeAction { .currentPhase("step_completed") .contentStreamed(true) .thinkingStreamed(!stepThinking.isEmpty()) + .loadedSkills(Set.copyOf(loadedSkills)) .addStepUsage(state, stepPromptTokens, stepCompletionTokens, stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens) .events(events) @@ -803,10 +822,9 @@ public class StepExecutionNode implements NodeAction { """; messages.add(new SystemMessage(enhancedSystemPrompt)); // Runtime skill catalog (rendered here instead of baked into the system - // prompt). The Plan path never pins per-run loads, so render with an - // empty loaded set — this reproduces the pre-disclosure DB ordering. + // prompt), ranked with skills already loaded during this graph run. if (skillCatalogRenderer != null) { - String skillCatalog = skillCatalogRenderer.render(java.util.Set.of()); + String skillCatalog = skillCatalogRenderer.render(accessor.loadedSkills()); if (skillCatalog != null && !skillCatalog.isBlank()) { messages.add(new SystemMessage(skillCatalog)); } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java index 7522377e..5615148b 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java @@ -140,6 +140,11 @@ public final class PlanStateAccessor { return state.value(WORKING_CONTEXT, ""); } + @SuppressWarnings("unchecked") + public Set loadedSkills() { + return state.>value(MateClawStateKeys.LOADED_SKILLS).orElse(Set.of()); + } + // ===== 输出构建器 ===== public static OutputBuilder output() { @@ -251,6 +256,10 @@ public final class PlanStateAccessor { return put(MateClawStateKeys.PENDING_EVENTS, events); } + public OutputBuilder loadedSkills(Set names) { + return put(MateClawStateKeys.LOADED_SKILLS, names); + } + // ---- 阶段标记(写入共享键 MateClawStateKeys.CURRENT_PHASE)---- public OutputBuilder currentPhase(String phase) { return put(MateClawStateKeys.CURRENT_PHASE, phase); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java index bb8c2082..7ff1e43a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java @@ -119,6 +119,10 @@ public final class MateClawStateAccessor { return state.value(FINAL_ANSWER_DRAFT, ""); } + public String longFormDraft() { + return state.value(LONG_FORM_DRAFT, ""); + } + public boolean limitExceeded() { return state.value(LIMIT_EXCEEDED, false); } @@ -453,6 +457,10 @@ public final class MateClawStateAccessor { return put(FINAL_ANSWER_DRAFT, draft); } + public OutputBuilder longFormDraft(String draft) { + return put(LONG_FORM_DRAFT, draft); + } + // ---- 终止 ---- public OutputBuilder finalAnswer(String answer) { return put(FINAL_ANSWER, answer); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java index 6c5e9743..47b60eea 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java @@ -64,6 +64,8 @@ public final class MateClawStateKeys { /** 最终回答草稿(由 summarizing 或 limitExceeded 节点生成) */ public static final String FINAL_ANSWER_DRAFT = "final_answer_draft"; + /** Accumulated visible body for an explicit long-form generation request. */ + public static final String LONG_FORM_DRAFT = "long_form_draft"; /** 是否需要进入 summarizing 阶段 */ public static final String SHOULD_SUMMARIZE = "should_summarize"; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java b/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java index 3a49340c..a00cb8d7 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java @@ -26,6 +26,14 @@ public class AgentEntity { /** Agent 类型:react / plan_execute */ private String agentType; + /** Runtime provider type: native / dsh / other registered providers. */ + @TableField(value = "runtime_type") + private String runtimeType; + + /** Runtime-specific JSON configuration. Null means provider defaults. */ + @TableField(value = "runtime_config", updateStrategy = FieldStrategy.ALWAYS) + private String runtimeConfig; + /** 系统提示词 */ @TableField(value = "system_prompt", updateStrategy = FieldStrategy.ALWAYS) private String systemPrompt; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeAggregator.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeAggregator.java index a7593330..e926a034 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeAggregator.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeAggregator.java @@ -10,6 +10,7 @@ import vip.mate.channel.web.ChatStreamTracker; import vip.mate.channel.web.ChatStreamTracker.RunSnapshot; import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; @@ -116,18 +117,26 @@ public class AgentRuntimeAggregator { ) {} public RuntimeSnapshot snapshot() { + return snapshot(null); + } + + public RuntimeSnapshot snapshot(Long workspaceId) { List rawRuns = streamTracker.getAllSnapshot(); Set agentIds = rawRuns.stream() .map(RunSnapshot::agentId) .filter(java.util.Objects::nonNull) .collect(Collectors.toSet()); - for (var rec : subagentRegistry.allActive()) { + Collection rawSubagents = subagentRegistry.allActive(); + for (var rec : rawSubagents) { if (rec.agentId() != null) agentIds.add(rec.agentId()); } Map agentInfo = resolveAgents(agentIds); Map subagentCountByParent = new HashMap<>(); - for (var rec : subagentRegistry.allActive()) { + for (var rec : rawSubagents) { + if (!belongsToWorkspace(rec.agentId(), agentInfo, workspaceId)) { + continue; + } String parent = rec.parentConversationId(); if (parent != null) { subagentCountByParent.merge(parent, 1L, Long::sum); @@ -141,6 +150,7 @@ public class AgentRuntimeAggregator { int runningCount = 0; for (RunSnapshot s : rawRuns) { if (s.done()) continue; + if (!belongsToWorkspace(s.agentId(), agentInfo, workspaceId)) continue; runningCount++; String stuckReason = computeStuckReason(s); boolean orphan = s.subscriberCount() == 0; @@ -181,7 +191,8 @@ public class AgentRuntimeAggregator { return Long.compare(b.msSinceLastEvent(), a.msSinceLastEvent()); }); - List subCards = subagentRegistry.allActive().stream() + List subCards = rawSubagents.stream() + .filter(rec -> belongsToWorkspace(rec.agentId(), agentInfo, workspaceId)) .map(rec -> { long now = System.currentTimeMillis(); AgentEntity ag = rec.agentId() == null ? null : agentInfo.get(rec.agentId()); @@ -216,6 +227,33 @@ public class AgentRuntimeAggregator { return new RuntimeSnapshot(summary, cards, subCards, System.currentTimeMillis()); } + public boolean runBelongsToWorkspace(String conversationId, Long workspaceId) { + if (conversationId == null || workspaceId == null) { + return false; + } + List rawRuns = streamTracker.getAllSnapshot(); + for (RunSnapshot run : rawRuns) { + if (run.done() || !conversationId.equals(run.conversationId())) { + continue; + } + AgentEntity agent = resolveAgent(run.agentId()); + return agent != null && workspaceId.equals(agent.getWorkspaceId()); + } + return false; + } + + public boolean subagentBelongsToWorkspace(String subagentId, Long workspaceId) { + if (subagentId == null || workspaceId == null) { + return false; + } + return subagentRegistry.get(subagentId) + .map(rec -> { + AgentEntity agent = resolveAgent(rec.agentId()); + return agent != null && workspaceId.equals(agent.getWorkspaceId()); + }) + .orElse(false); + } + /** * Returns null when the run looks healthy. The returned tag is a stable * machine-readable code (not a translated label) so the frontend can @@ -244,4 +282,25 @@ public class AgentRuntimeAggregator { } return out; } + + private AgentEntity resolveAgent(Long id) { + if (id == null) { + return null; + } + try { + return agentService.getAgent(id); + } catch (Exception e) { + log.debug("agent lookup failed for id={}: {}", id, e.getMessage()); + return null; + } + } + + private static boolean belongsToWorkspace(Long agentId, Map agentInfo, + Long workspaceId) { + if (workspaceId == null) { + return true; + } + AgentEntity agent = agentId == null ? null : agentInfo.get(agentId); + return agent != null && workspaceId.equals(agent.getWorkspaceId()); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeController.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeController.java index 362fc89b..1c3d0b81 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeController.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeController.java @@ -19,13 +19,13 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import vip.mate.workspace.core.annotation.RequireGlobalAdmin; +import vip.mate.agent.runtime.dsh.DshRuntimeService; /** - * Admin-only live runtime surface: the global view of every in-flight agent + * Admin-only live runtime surface: the workspace view of every in-flight agent * turn plus the controls to friendly-stop, force-recycle, or sweep stuck * runs. Distinct from {@code /api/v1/subagents/...} which is per-conversation - * owner-scoped — this controller is intentionally cross-tenant for the - * operator role. + * owner-scoped. */ @Slf4j @Tag(name = "Agent Runtime (Live)") @@ -40,21 +40,35 @@ public class AgentRuntimeController { private final AuditEventService auditEventService; private final ConversationService conversationService; private final I18nService i18nService; + private final DshRuntimeService dshRuntimeService; @Operation(summary = "Snapshot of every in-flight agent turn") @GetMapping("/snapshot") @RequireGlobalAdmin - public R snapshot(Authentication auth) { + public R snapshot( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + Authentication auth) { requireAdmin(auth); - return R.ok(aggregator.snapshot()); + requireWorkspace(workspaceId); + return R.ok(aggregator.snapshot(workspaceId)); + } + + @Operation(summary = "DSH runtime availability and capability diagnostics") + @GetMapping("/dsh/diagnostics") + @RequireGlobalAdmin + public R> dshDiagnostics(Authentication auth) { + requireAdmin(auth); + return R.ok(dshRuntimeService.diagnostics()); } @Operation(summary = "Friendly stop — request the run to wind down at its next checkpoint") @PostMapping("/runs/{conversationId}/stop") @RequireGlobalAdmin public R> stopFriendly(@PathVariable String conversationId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, Authentication auth) { requireAdmin(auth); + requireRunInWorkspace(conversationId, workspaceId); boolean ok = streamTracker.requestStop(conversationId); recordAudit(auth, "agent-runtime.stop", conversationId, Map.of("result", ok)); return R.ok(Map.of("stopped", ok)); @@ -64,8 +78,10 @@ public class AgentRuntimeController { @PostMapping("/runs/{conversationId}/recycle") @RequireGlobalAdmin public R> recycle(@PathVariable String conversationId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, Authentication auth) { requireAdmin(auth); + requireRunInWorkspace(conversationId, workspaceId); boolean ok = streamTracker.forceRecycle(conversationId); if (ok) { finalizeRecycledConversation(conversationId); @@ -78,8 +94,10 @@ public class AgentRuntimeController { @PostMapping("/subagents/{subagentId}/interrupt") @RequireGlobalAdmin public R> interruptSubagent(@PathVariable String subagentId, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, Authentication auth) { requireAdmin(auth); + requireSubagentInWorkspace(subagentId, workspaceId); boolean ok = subagentRegistry.interrupt(subagentId); recordAudit(auth, "agent-runtime.subagent.interrupt", subagentId, Map.of("result", ok)); return R.ok(Map.of("interrupted", ok)); @@ -93,9 +111,12 @@ public class AgentRuntimeController { @Operation(summary = "Recycle every run currently flagged as stuck") @PostMapping("/sweep") @RequireGlobalAdmin - public R> sweep(Authentication auth) { + public R> sweep( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + Authentication auth) { requireAdmin(auth); - AgentRuntimeAggregator.RuntimeSnapshot snap = aggregator.snapshot(); + requireWorkspace(workspaceId); + AgentRuntimeAggregator.RuntimeSnapshot snap = aggregator.snapshot(workspaceId); List ids = snap.runs().stream() .filter(r -> r.stuckReason() != null) .map(AgentRuntimeAggregator.RunCard::conversationId) @@ -154,6 +175,26 @@ public class AgentRuntimeController { } } + private void requireWorkspace(Long workspaceId) { + if (workspaceId == null) { + throw new MateClawException(400, "workspace id required"); + } + } + + private void requireRunInWorkspace(String conversationId, Long workspaceId) { + requireWorkspace(workspaceId); + if (!aggregator.runBelongsToWorkspace(conversationId, workspaceId)) { + throw new MateClawException(404, "runtime run not found in workspace"); + } + } + + private void requireSubagentInWorkspace(String subagentId, Long workspaceId) { + requireWorkspace(workspaceId); + if (!aggregator.subagentBelongsToWorkspace(subagentId, workspaceId)) { + throw new MateClawException(404, "subagent not found in workspace"); + } + } + private void recordAudit(Authentication auth, String action, String resourceId, Map detail) { try { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/ConversationTurnGate.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/ConversationTurnGate.java new file mode 100644 index 00000000..020862e6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/ConversationTurnGate.java @@ -0,0 +1,36 @@ +package vip.mate.agent.runtime; + +import org.springframework.stereotype.Component; +import java.util.concurrent.ConcurrentHashMap; + +/** Atomic local admission shared by interactive, replay and autonomous turns. */ +@Component +public class ConversationTurnGate { + private final ConcurrentHashMap owners = new ConcurrentHashMap<>(); + private final ThreadLocal admitted = new ThreadLocal<>(); + + public Permit tryAcquire(String conversationId) { + if (conversationId == null || conversationId.isBlank()) return new Permit(null); + Permit current = admitted.get(); + if (current != null && conversationId.equals(current.conversationId) + && owners.get(conversationId) == current) return new Permit(null); + Permit permit = new Permit(conversationId); + return owners.putIfAbsent(conversationId, permit) == null ? permit : null; + } + + /** Enter the already-admitted call synchronously; inner lifecycle cleanup must not release its owner. */ + public T withPermit(Permit permit, java.util.function.Supplier call) { + Permit previous=admitted.get(); + admitted.set(permit); + try { return call.get(); } + finally { if (previous==null) admitted.remove(); else admitted.set(previous); } + } + + public final class Permit implements AutoCloseable { + private final String conversationId; + private Permit(String conversationId) { this.conversationId = conversationId; } + @Override public void close() { + if (conversationId != null) owners.remove(conversationId, this); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeEventProjector.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeEventProjector.java new file mode 100644 index 00000000..554929b0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeEventProjector.java @@ -0,0 +1,63 @@ +package vip.mate.agent.runtime; + +import vip.mate.agent.AgentService; +import vip.mate.agent.runtime.contract.RuntimeEvent; +import vip.mate.agent.runtime.contract.RuntimeEventType; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Projects normalized runtime events onto the existing chat stream vocabulary. */ +public final class RuntimeEventProjector { + private RuntimeEventProjector() {} + + public static AgentService.StreamDelta project(RuntimeEvent event) { + if (event == null) return AgentService.StreamDelta.empty(); + Map data = new LinkedHashMap<>(event.data()); + data.putIfAbsent("runtimeSessionId", event.sessionId()); + data.putIfAbsent("runtimeSequence", event.sequence()); + return switch (event.type()) { + case RUNTIME_READY -> AgentService.StreamDelta.event("phase", + with(data, "phase", "runtime_ready")); + case ASSISTANT_DELTA -> new AgentService.StreamDelta( + text(event, data), null, null, null, false, false, null); + case THINKING_DELTA -> new AgentService.StreamDelta( + null, text(event, data), null, null, false, false, null); + case TOOL_STARTED -> AgentService.StreamDelta.event("tool_call_started", + rename(data, "toolName", "name", "callId", "toolCallId")); + case TOOL_APPROVAL_REQUIRED -> AgentService.StreamDelta.event("tool_approval_requested", + rename(data, "requestId", "pendingId", "toolName", "toolName")); + case TOOL_FINISHED -> AgentService.StreamDelta.event("tool_call_completed", + rename(data, "callId", "toolCallId", "toolName", "toolName")); + case SUBAGENT_STARTED -> AgentService.StreamDelta.event("subagent_start", data); + case SUBAGENT_FINISHED -> AgentService.StreamDelta.event("subagent_complete", data); + case CONTEXT_USAGE -> AgentService.StreamDelta.event("_usage_final", data); + case COMPLETED -> AgentService.StreamDelta.event("done", data); + case FAILED -> AgentService.StreamDelta.event("error", data); + case CANCELLED -> AgentService.StreamDelta.event("cancelled", data); + }; + } + + private static Map with(Map source, String key, Object value) { + Map result = new LinkedHashMap<>(source); + result.put(key, value); + return result; + } + + private static String text(RuntimeEvent event, Map data) { + Object delta = data.get("delta"); + return delta != null ? String.valueOf(delta) : event.text() == null ? "" : event.text(); + } + + private static Map rename(Map source, String from, String to, + String secondFrom, String secondTo) { + Map result = new LinkedHashMap<>(source); + copyIfPresent(result, from, to); + copyIfPresent(result, secondFrom, secondTo); + return result; + } + + private static void copyIfPresent(Map data, String from, String to) { + if (!data.containsKey(to) && data.containsKey(from)) data.put(to, data.get(from)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeEventStreamAdapter.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeEventStreamAdapter.java new file mode 100644 index 00000000..45f712fe --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeEventStreamAdapter.java @@ -0,0 +1,15 @@ +package vip.mate.agent.runtime; + +import reactor.core.publisher.Flux; +import vip.mate.agent.AgentService; +import vip.mate.agent.runtime.contract.RuntimeEvent; + +/** Adapts provider event streams to the native chat stream contract. */ +public final class RuntimeEventStreamAdapter { + private RuntimeEventStreamAdapter() {} + + public static Flux adapt(Flux events) { + if (events == null) return Flux.empty(); + return events.map(RuntimeEventProjector::project); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeProviderConfiguration.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeProviderConfiguration.java new file mode 100644 index 00000000..f00d1fd4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeProviderConfiguration.java @@ -0,0 +1,25 @@ +package vip.mate.agent.runtime; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import vip.mate.agent.runtime.contract.AgentRuntimeCoordinator; +import vip.mate.agent.runtime.contract.AgentRuntimeProvider; +import vip.mate.agent.runtime.contract.RuntimeProviderRegistry; + +import java.util.List; + +/** Spring wiring for the runtime SPI. Native execution remains owned by AgentService. */ +@Configuration +public class RuntimeProviderConfiguration { + @Bean + RuntimeProviderRegistry runtimeProviderRegistry(List providers) { + return new RuntimeProviderRegistry(providers); + } + + @Bean + AgentRuntimeCoordinator agentRuntimeCoordinator(RuntimeProviderRegistry registry, + ObjectMapper objectMapper) { + return new AgentRuntimeCoordinator(registry, objectMapper); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeConnection.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeConnection.java new file mode 100644 index 00000000..c79594f6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeConnection.java @@ -0,0 +1,17 @@ +package vip.mate.agent.runtime.contract; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface AgentRuntimeConnection extends AutoCloseable { + Flux prompt(String message); + + Mono cancel(); + + Mono contextUsage(); + + @Override + default void close() { + cancel().block(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeCoordinator.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeCoordinator.java new file mode 100644 index 00000000..e4a0fad9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeCoordinator.java @@ -0,0 +1,24 @@ +package vip.mate.agent.runtime.contract; + +import com.fasterxml.jackson.databind.ObjectMapper; +import vip.mate.agent.model.AgentEntity; + +import java.nio.file.Path; + +/** Selects, validates, and starts the provider chosen by an employee. */ +public final class AgentRuntimeCoordinator { + private final RuntimeProviderRegistry providerRegistry; + private final RuntimeSessionFactory sessionFactory; + + public AgentRuntimeCoordinator(RuntimeProviderRegistry providerRegistry, ObjectMapper objectMapper) { + this.providerRegistry = providerRegistry; + this.sessionFactory = new RuntimeSessionFactory(providerRegistry, objectMapper); + } + + public AgentRuntimeConnection start(AgentEntity agent, String conversationId, String sessionId, + String modelName, Path workspaceRoot, Path workingDirectory) { + RuntimeSession session = sessionFactory.create(agent, conversationId, sessionId, + modelName, workspaceRoot, workingDirectory); + return providerRegistry.resolve(agent.getRuntimeType()).start(session); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeProvider.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeProvider.java new file mode 100644 index 00000000..adff59cc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeProvider.java @@ -0,0 +1,11 @@ +package vip.mate.agent.runtime.contract; + +public interface AgentRuntimeProvider { + String type(); + + RuntimeValidation validate(RuntimeSession session); + + RuntimeCapabilities capabilities(); + + AgentRuntimeConnection start(RuntimeSession session); +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeCapabilities.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeCapabilities.java new file mode 100644 index 00000000..536e0ca6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeCapabilities.java @@ -0,0 +1,8 @@ +package vip.mate.agent.runtime.contract; + +public record RuntimeCapabilities( + boolean supportsCancellation, + boolean supportsApprovals, + boolean supportsSubagents, + boolean supportsContextUsage +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeContextUsage.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeContextUsage.java new file mode 100644 index 00000000..7bbc342d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeContextUsage.java @@ -0,0 +1,9 @@ +package vip.mate.agent.runtime.contract; + +public record RuntimeContextUsage(long inputTokens, long outputTokens, long contextWindow) { + public RuntimeContextUsage { + if (inputTokens < 0 || outputTokens < 0 || contextWindow < 0) { + throw new IllegalArgumentException("usage values must be non-negative"); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEvent.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEvent.java new file mode 100644 index 00000000..4db9e2b1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEvent.java @@ -0,0 +1,38 @@ +package vip.mate.agent.runtime.contract; + +import java.util.Map; + +public record RuntimeEvent( + String sessionId, + long sequence, + RuntimeEventType type, + String text, + Map data, + boolean terminal +) { + public RuntimeEvent { + if (sessionId == null || sessionId.isBlank()) { + throw new IllegalArgumentException("sessionId is required"); + } + if (sequence < 0) { + throw new IllegalArgumentException("sequence must be non-negative"); + } + if (type == null) { + throw new IllegalArgumentException("type is required"); + } + if (terminal != type.terminal()) { + throw new IllegalArgumentException("terminal flag does not match event type"); + } + data = data == null ? Map.of() : Map.copyOf(data); + } + + public static RuntimeEvent of(String sessionId, long sequence, RuntimeEventType type, + String text, Map data) { + return new RuntimeEvent(sessionId, sequence, type, text, data, false); + } + + public static RuntimeEvent terminal(String sessionId, long sequence, RuntimeEventType type, + Map data) { + return new RuntimeEvent(sessionId, sequence, type, null, data, true); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEventLog.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEventLog.java new file mode 100644 index 00000000..8321074e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEventLog.java @@ -0,0 +1,41 @@ +package vip.mate.agent.runtime.contract; + +import java.util.ArrayList; +import java.util.List; + +public final class RuntimeEventLog { + private final String sessionId; + private final List events = new ArrayList<>(); + private boolean terminal; + private long lastSequence = -1; + + public RuntimeEventLog(String sessionId) { + if (sessionId == null || sessionId.isBlank()) { + throw new IllegalArgumentException("sessionId is required"); + } + this.sessionId = sessionId; + } + + public synchronized void append(RuntimeEvent event) { + if (!sessionId.equals(event.sessionId())) { + throw new IllegalArgumentException("event belongs to another session"); + } + if (event.sequence() <= lastSequence) { + throw new IllegalArgumentException("event sequence must increase"); + } + if (terminal) { + throw new IllegalStateException("terminal event already appended"); + } + events.add(event); + lastSequence = event.sequence(); + terminal = event.terminal(); + } + + public synchronized List snapshot() { + return List.copyOf(events); + } + + public synchronized boolean terminal() { + return terminal; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEventType.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEventType.java new file mode 100644 index 00000000..3338b41a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEventType.java @@ -0,0 +1,20 @@ +package vip.mate.agent.runtime.contract; + +public enum RuntimeEventType { + RUNTIME_READY, + ASSISTANT_DELTA, + THINKING_DELTA, + TOOL_STARTED, + TOOL_APPROVAL_REQUIRED, + TOOL_FINISHED, + SUBAGENT_STARTED, + SUBAGENT_FINISHED, + CONTEXT_USAGE, + COMPLETED, + FAILED, + CANCELLED; + + public boolean terminal() { + return this == COMPLETED || this == FAILED || this == CANCELLED; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeProviderRegistry.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeProviderRegistry.java new file mode 100644 index 00000000..6cd1052c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeProviderRegistry.java @@ -0,0 +1,41 @@ +package vip.mate.agent.runtime.contract; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public final class RuntimeProviderRegistry { + public static final String DEFAULT_RUNTIME = "native"; + + private final Map providers; + + public RuntimeProviderRegistry(List providers) { + Map registered = new LinkedHashMap<>(); + for (AgentRuntimeProvider provider : providers == null ? List.of() : providers) { + if (provider == null || provider.type() == null || provider.type().isBlank()) { + throw new IllegalArgumentException("runtime provider type is required"); + } + String type = normalize(provider.type()); + if (registered.putIfAbsent(type, provider) != null) { + throw new IllegalArgumentException("duplicate runtime provider: " + type); + } + } + this.providers = Map.copyOf(registered); + } + + public AgentRuntimeProvider resolve(String requestedType) { + String type = requestedType == null || requestedType.isBlank() + ? DEFAULT_RUNTIME + : normalize(requestedType); + AgentRuntimeProvider provider = providers.get(type); + if (provider == null) { + throw new IllegalArgumentException("unknown runtime provider: " + type); + } + return provider; + } + + private static String normalize(String type) { + return type.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeResult.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeResult.java new file mode 100644 index 00000000..0a437651 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeResult.java @@ -0,0 +1,24 @@ +package vip.mate.agent.runtime.contract; + +public record RuntimeResult(Status status, String answer, String errorCode, String errorMessage) { + public enum Status { COMPLETED, FAILED, CANCELLED } + + public RuntimeResult { + if (status == null) throw new IllegalArgumentException("status is required"); + if (status == Status.COMPLETED && (errorCode != null || errorMessage != null)) { + throw new IllegalArgumentException("completed result cannot contain an error"); + } + } + + public static RuntimeResult completed(String answer) { + return new RuntimeResult(Status.COMPLETED, answer, null, null); + } + + public static RuntimeResult failed(String code, String message) { + return new RuntimeResult(Status.FAILED, null, code, message); + } + + public static RuntimeResult cancelled() { + return new RuntimeResult(Status.CANCELLED, null, null, null); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeSession.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeSession.java new file mode 100644 index 00000000..ec91ef84 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeSession.java @@ -0,0 +1,20 @@ +package vip.mate.agent.runtime.contract; + +import java.nio.file.Path; +import java.util.Map; + +public record RuntimeSession( + String sessionId, + String conversationId, + Long agentId, + Long workspaceId, + String modelName, + Path workingDirectory, + Map configuration +) { + public RuntimeSession { + if (sessionId == null || sessionId.isBlank()) throw new IllegalArgumentException("sessionId is required"); + if (conversationId == null || conversationId.isBlank()) throw new IllegalArgumentException("conversationId is required"); + configuration = configuration == null ? Map.of() : Map.copyOf(configuration); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeSessionFactory.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeSessionFactory.java new file mode 100644 index 00000000..6313a7b0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeSessionFactory.java @@ -0,0 +1,75 @@ +package vip.mate.agent.runtime.contract; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import vip.mate.agent.model.AgentEntity; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Map; + +/** Builds and validates the runtime-neutral session boundary for an employee turn. */ +public final class RuntimeSessionFactory { + private static final TypeReference> CONFIG_TYPE = new TypeReference<>() {}; + + private final RuntimeProviderRegistry providerRegistry; + private final ObjectMapper objectMapper; + + public RuntimeSessionFactory(RuntimeProviderRegistry providerRegistry, ObjectMapper objectMapper) { + this.providerRegistry = providerRegistry; + this.objectMapper = objectMapper; + } + + public RuntimeSession create(AgentEntity agent, String conversationId, String sessionId, + String modelName, Path workspaceRoot, Path workingDirectory) { + if (agent == null) throw new IllegalArgumentException("agent is required"); + AgentRuntimeProvider provider = providerRegistry.resolve(agent.getRuntimeType()); + String runtimeType = agent.getRuntimeType() == null || agent.getRuntimeType().isBlank() + ? RuntimeProviderRegistry.DEFAULT_RUNTIME : agent.getRuntimeType().trim().toLowerCase(); + + Path normalizedRoot = normalize(workspaceRoot); + Path normalizedWorkingDirectory = normalize(workingDirectory); + if ("dsh".equals(runtimeType)) { + if (agent.getWorkspaceId() == null) { + throw new IllegalArgumentException("dsh runtime requires a workspace"); + } + if (normalizedRoot == null || normalizedWorkingDirectory == null + || !normalizedWorkingDirectory.startsWith(normalizedRoot)) { + throw new IllegalArgumentException("dsh working directory must stay inside workspace"); + } + } + + RuntimeSession session = new RuntimeSession(sessionId, conversationId, agent.getId(), + agent.getWorkspaceId(), modelName, normalizedWorkingDirectory, + parseConfig(agent.getRuntimeConfig())); + RuntimeValidation validation = provider.validate(session); + if (validation == null || !validation.valid()) { + String code = validation == null ? "runtime.invalid" : validation.code(); + String message = validation == null ? "runtime provider rejected session" : validation.message(); + throw new IllegalArgumentException(code + ": " + message); + } + return session; + } + + private Map parseConfig(String raw) { + if (raw == null || raw.isBlank()) return Map.of(); + try { + JsonNode node = objectMapper.readTree(raw); + if (node == null || !node.isObject()) { + throw new IllegalArgumentException("runtime config must be a JSON object"); + } + return objectMapper.convertValue(node, CONFIG_TYPE); + } catch (IOException | IllegalArgumentException e) { + if (e instanceof IllegalArgumentException iae + && "runtime config must be a JSON object".equals(iae.getMessage())) { + throw iae; + } + throw new IllegalArgumentException("runtime config must be valid JSON", e); + } + } + + private static Path normalize(Path path) { + return path == null ? null : path.toAbsolutePath().normalize(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeValidation.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeValidation.java new file mode 100644 index 00000000..8b9bf269 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeValidation.java @@ -0,0 +1,11 @@ +package vip.mate.agent.runtime.contract; + +public record RuntimeValidation(boolean valid, String code, String message) { + public static RuntimeValidation success() { + return new RuntimeValidation(true, null, null); + } + + public static RuntimeValidation invalid(String code, String message) { + return new RuntimeValidation(false, code, message); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBinaryResolver.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBinaryResolver.java new file mode 100644 index 00000000..ce395883 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBinaryResolver.java @@ -0,0 +1,9 @@ +package vip.mate.agent.runtime.dsh; + +import java.nio.file.Path; +import java.util.Optional; + +@FunctionalInterface +public interface DshBinaryResolver { + Optional resolve(); +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeAuthenticator.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeAuthenticator.java new file mode 100644 index 00000000..9bba8e58 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeAuthenticator.java @@ -0,0 +1,21 @@ +package vip.mate.agent.runtime.dsh; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +public final class DshBridgeAuthenticator { + private final byte[] expectedToken; + + public DshBridgeAuthenticator(String expectedToken) { + if (expectedToken == null || expectedToken.isBlank()) { + throw new IllegalArgumentException("bridge token is required"); + } + this.expectedToken = expectedToken.getBytes(StandardCharsets.UTF_8); + } + + public boolean accepts(String providedToken) { + if (providedToken == null) return false; + return MessageDigest.isEqual(expectedToken, + providedToken.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeConnection.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeConnection.java new file mode 100644 index 00000000..f6386e04 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeConnection.java @@ -0,0 +1,65 @@ +package vip.mate.agent.runtime.dsh; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; + +public final class DshBridgeConnection implements AutoCloseable { + private static final int MAX_LINE_BYTES = 1_048_576; + + private final BufferedReader reader; + private final BufferedWriter writer; + private final DshBridgeProtocol protocol; + private final DshBridgeAuthenticator authenticator; + private boolean authenticated; + + public DshBridgeConnection(InputStream input, OutputStream output, + DshBridgeProtocol protocol, + DshBridgeAuthenticator authenticator) { + this.reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8)); + this.writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8)); + this.protocol = protocol; + this.authenticator = authenticator; + } + + public boolean authenticate(String token) { + authenticated = authenticator.accepts(token); + return authenticated; + } + + public DshBridgeMessage receive() throws IOException { + requireAuthenticated(); + String line = reader.readLine(); + if (line == null) throw new IOException("DSH bridge closed"); + if (line.getBytes(StandardCharsets.UTF_8).length > MAX_LINE_BYTES) { + throw new IOException("DSH bridge message exceeds size limit"); + } + return protocol.decode(line); + } + + public void send(DshBridgeMessage message) throws IOException { + requireAuthenticated(); + String encoded = protocol.encode(message); + if (encoded.getBytes(StandardCharsets.UTF_8).length > MAX_LINE_BYTES) { + throw new IOException("DSH bridge message exceeds size limit"); + } + writer.write(encoded); + writer.flush(); + } + + private void requireAuthenticated() throws IOException { + if (!authenticated) throw new IOException("DSH bridge authentication required"); + } + + @Override + public void close() throws IOException { + reader.close(); + writer.close(); + authenticated = false; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeEvents.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeEvents.java new file mode 100644 index 00000000..953c934f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeEvents.java @@ -0,0 +1,45 @@ +package vip.mate.agent.runtime.dsh; + +import java.util.LinkedHashMap; +import java.util.Map; + +public final class DshBridgeEvents { + private DshBridgeEvents() {} + + public static DshBridgeMessage ready(String sessionId) { + return DshBridgeMessage.notification("ready", Map.of("sessionId", require(sessionId, "sessionId"))); + } + + public static DshBridgeMessage toolCall(String callId, String toolName, Map arguments) { + Map params = new LinkedHashMap<>(); + params.put("toolName", require(toolName, "toolName")); + params.put("arguments", arguments == null ? Map.of() : Map.copyOf(arguments)); + return DshBridgeMessage.request(require(callId, "callId"), "tool/call", params); + } + + public static DshBridgeMessage approvalAsk(String requestId, String toolName, String reason) { + Map params = new LinkedHashMap<>(); + params.put("toolName", require(toolName, "toolName")); + params.put("reason", reason == null ? "" : reason); + return DshBridgeMessage.request(require(requestId, "requestId"), "approval/ask", params); + } + + public static DshBridgeMessage subagentLifecycle(String subagentId, String phase, + Map data) { + Map params = new LinkedHashMap<>(); + params.put("subagentId", require(subagentId, "subagentId")); + params.put("phase", require(phase, "phase")); + if (data != null) params.putAll(Map.copyOf(data)); + return DshBridgeMessage.notification("subagent/lifecycle", params); + } + + public static DshBridgeMessage toolCancel(String callId) { + return DshBridgeMessage.notification("tool/cancel", + Map.of("callId", require(callId, "callId"))); + } + + private static String require(String value, String name) { + if (value == null || value.isBlank()) throw new IllegalArgumentException(name + " is required"); + return value; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeMessage.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeMessage.java new file mode 100644 index 00000000..ec0dff25 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeMessage.java @@ -0,0 +1,26 @@ +package vip.mate.agent.runtime.dsh; + +import java.util.Map; + +public record DshBridgeMessage( + String id, + String method, + Map params, + Object result, + String errorCode, + String errorMessage +) { + public DshBridgeMessage { + if (method == null || method.isBlank()) throw new IllegalArgumentException("method is required"); + params = params == null ? Map.of() : Map.copyOf(params); + } + + public static DshBridgeMessage request(String id, String method, Map params) { + if (id == null || id.isBlank()) throw new IllegalArgumentException("request id is required"); + return new DshBridgeMessage(id, method, params, null, null, null); + } + + public static DshBridgeMessage notification(String method, Map params) { + return new DshBridgeMessage(null, method, params, null, null, null); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeMethods.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeMethods.java new file mode 100644 index 00000000..1476aa29 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeMethods.java @@ -0,0 +1,15 @@ +package vip.mate.agent.runtime.dsh; + +import java.util.Set; + +public final class DshBridgeMethods { + private static final Set SUPPORTED = Set.of( + "session/open", "session/prompt", "session/cancel", "policy/update", "context/usage", + "ready", "tool/call", "approval/ask", "subagent/lifecycle", "tool/cancel"); + + private DshBridgeMethods() {} + + public static boolean isSupported(String method) { + return method != null && SUPPORTED.contains(method); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeProtocol.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeProtocol.java new file mode 100644 index 00000000..db22898b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeProtocol.java @@ -0,0 +1,33 @@ +package vip.mate.agent.runtime.dsh; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +public final class DshBridgeProtocol { + private final ObjectMapper objectMapper; + + public DshBridgeProtocol(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public String encode(DshBridgeMessage message) { + try { + return objectMapper.writeValueAsString(message) + "\n"; + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Unable to encode DSH bridge message", e); + } + } + + public DshBridgeMessage decode(String line) { + if (line == null || line.isBlank()) throw new IllegalArgumentException("bridge message is empty"); + try { + return objectMapper.readValue(line.trim(), DshBridgeMessage.class); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Invalid DSH bridge message", e); + } + } + + public boolean isNotification(DshBridgeMessage message) { + return message.id() == null; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeRequests.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeRequests.java new file mode 100644 index 00000000..85c487d3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeRequests.java @@ -0,0 +1,56 @@ +package vip.mate.agent.runtime.dsh; + +import vip.mate.agent.runtime.contract.RuntimeSession; + +import java.util.LinkedHashMap; +import java.util.Map; + +public final class DshBridgeRequests { + private DshBridgeRequests() {} + + public static DshBridgeMessage sessionOpen(RuntimeSession session, Map policy) { + Map params = new LinkedHashMap<>(); + params.put("sessionId", session.sessionId()); + params.put("conversationId", session.conversationId()); + putIfPresent(params, "agentId", session.agentId()); + putIfPresent(params, "workspaceId", session.workspaceId()); + putIfPresent(params, "model", session.modelName()); + putIfPresent(params, "cwd", session.workingDirectory() == null + ? null : session.workingDirectory().toString()); + params.putAll(session.configuration()); + if (policy != null) params.put("policy", Map.copyOf(policy)); + return DshBridgeMessage.request("open-" + session.sessionId(), "session/open", params); + } + + public static DshBridgeMessage prompt(String requestId, String message) { + require(requestId, "requestId"); + if (message == null) throw new IllegalArgumentException("message is required"); + return DshBridgeMessage.request(requestId, "session/prompt", Map.of("message", message)); + } + + public static DshBridgeMessage cancel(String requestId, String sessionId) { + require(requestId, "requestId"); + require(sessionId, "sessionId"); + return DshBridgeMessage.request(requestId, "session/cancel", Map.of("sessionId", sessionId)); + } + + public static DshBridgeMessage policyUpdate(String requestId, Map policy) { + require(requestId, "requestId"); + return DshBridgeMessage.request(requestId, "policy/update", + Map.of("policy", policy == null ? Map.of() : Map.copyOf(policy))); + } + + public static DshBridgeMessage contextUsage(String requestId, String sessionId) { + require(requestId, "requestId"); + require(sessionId, "sessionId"); + return DshBridgeMessage.request(requestId, "context/usage", Map.of("sessionId", sessionId)); + } + + private static void require(String value, String name) { + if (value == null || value.isBlank()) throw new IllegalArgumentException(name + " is required"); + } + + private static void putIfPresent(Map target, String key, Object value) { + if (value != null) target.put(key, value); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshManagedProcess.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshManagedProcess.java new file mode 100644 index 00000000..fdda7fe4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshManagedProcess.java @@ -0,0 +1,66 @@ +package vip.mate.agent.runtime.dsh; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class DshManagedProcess implements AutoCloseable { + private final DshProcessHandle process; + private final String sessionId; + private final Path binary; + private final Path sessionHome; + private final String bridgeToken; + private final Runnable onClosed; + private final AtomicBoolean closed = new AtomicBoolean(); + + DshManagedProcess(DshProcessHandle process, String sessionId, Path binary, + Path sessionHome, String bridgeToken) { + this(process, sessionId, binary, sessionHome, bridgeToken, () -> { }); + } + + DshManagedProcess(DshProcessHandle process, String sessionId, Path binary, + Path sessionHome, String bridgeToken, Runnable onClosed) { + this.process = process; + this.sessionId = sessionId; + this.binary = binary; + this.sessionHome = sessionHome; + this.bridgeToken = bridgeToken; + this.onClosed = onClosed == null ? () -> { } : onClosed; + } + + public DshProcessDiagnostics diagnostics() { + return new DshProcessDiagnostics(sessionId, binary, sessionHome, + process.isAlive(), bridgeToken != null && !bridgeToken.isBlank()); + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) return; + if (process.isAlive()) { + process.destroy(); + if (!process.awaitExit(1_000L) && process.isAlive()) { + process.destroyForcibly(); + process.awaitExit(1_000L); + } + } + deleteRecursively(sessionHome); + onClosed.run(); + } + + private static void deleteRecursively(Path root) { + if (root == null || !Files.exists(root)) return; + try (var paths = Files.walk(root)) { + paths.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // Cleanup is best effort; the process is already stopped. + } + }); + } catch (IOException ignored) { + // Cleanup is best effort; diagnostics retain the path for operators. + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessDiagnostics.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessDiagnostics.java new file mode 100644 index 00000000..06fdabaa --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessDiagnostics.java @@ -0,0 +1,11 @@ +package vip.mate.agent.runtime.dsh; + +import java.nio.file.Path; + +public record DshProcessDiagnostics( + String sessionId, + Path binary, + Path sessionHome, + boolean alive, + boolean bridgeTokenRedacted +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessHandle.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessHandle.java new file mode 100644 index 00000000..25e2345a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessHandle.java @@ -0,0 +1,11 @@ +package vip.mate.agent.runtime.dsh; + +public interface DshProcessHandle { + boolean isAlive(); + + void destroy(); + + void destroyForcibly(); + + boolean awaitExit(long millis); +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessLauncher.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessLauncher.java new file mode 100644 index 00000000..be724595 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessLauncher.java @@ -0,0 +1,10 @@ +package vip.mate.agent.runtime.dsh; + +import vip.mate.agent.runtime.contract.RuntimeSession; + +import java.nio.file.Path; + +@FunctionalInterface +public interface DshProcessLauncher { + DshProcessHandle launch(Path binary, RuntimeSession session, Path sessionHome, String bridgeToken); +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessManager.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessManager.java new file mode 100644 index 00000000..c87d61ac --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessManager.java @@ -0,0 +1,70 @@ +package vip.mate.agent.runtime.dsh; + +import vip.mate.agent.runtime.contract.RuntimeSession; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public final class DshProcessManager { + private final DshBinaryResolver binaryResolver; + private final DshProcessLauncher launcher; + private final Map active = new ConcurrentHashMap<>(); + + public DshProcessManager(DshBinaryResolver binaryResolver, DshProcessLauncher launcher) { + this.binaryResolver = binaryResolver; + this.launcher = launcher; + } + + public DshManagedProcess start(RuntimeSession session) { + stop(session.sessionId()); + Path binary = binaryResolver.resolve() + .filter(Files::isExecutable) + .orElseThrow(() -> new IllegalStateException("DSH binary is unavailable")); + Path sessionHome; + try { + sessionHome = Files.createTempDirectory("mateclaw-dsh-" + safeSessionId(session.sessionId()) + "-"); + } catch (IOException e) { + throw new IllegalStateException("Unable to create DSH session home", e); + } + String bridgeToken = UUID.randomUUID().toString(); + try { + DshProcessHandle process = launcher.launch(binary, session, sessionHome, bridgeToken); + if (process == null) throw new IllegalStateException("DSH launcher returned no process"); + DshManagedProcess managed = new DshManagedProcess(process, session.sessionId(), binary, + sessionHome, bridgeToken, () -> active.remove(session.sessionId())); + active.put(session.sessionId(), managed); + return managed; + } catch (RuntimeException e) { + deleteSessionHome(sessionHome); + throw e; + } + } + + public boolean stop(String sessionId) { + DshManagedProcess process = active.remove(sessionId); + if (process == null) return false; + process.close(); + return true; + } + + public Set activeSessionIds() { + return Set.copyOf(active.keySet()); + } + + private static String safeSessionId(String sessionId) { + return sessionId.replaceAll("[^A-Za-z0-9._-]", "_"); + } + + private static void deleteSessionHome(Path path) { + try (var paths = Files.walk(path)) { + paths.sorted(java.util.Comparator.reverseOrder()).forEach(candidate -> { + try { Files.deleteIfExists(candidate); } catch (IOException ignored) { } + }); + } catch (IOException ignored) { } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshRuntimeService.java new file mode 100644 index 00000000..608ac54b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshRuntimeService.java @@ -0,0 +1,655 @@ +package vip.mate.agent.runtime.dsh; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.scheduler.Schedulers; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.runtime.RuntimeEventProjector; +import vip.mate.agent.runtime.contract.RuntimeEvent; +import vip.mate.agent.runtime.contract.RuntimeEventType; +import vip.mate.agent.runtime.contract.RuntimeSession; +import vip.mate.agent.runtime.contract.AgentRuntimeConnection; +import vip.mate.agent.runtime.contract.AgentRuntimeProvider; +import vip.mate.agent.runtime.contract.RuntimeCapabilities; +import vip.mate.agent.runtime.contract.RuntimeContextUsage; +import vip.mate.agent.runtime.contract.RuntimeValidation; +import vip.mate.agent.runtime.dsh.management.DshRuntimeConfigService; +import vip.mate.agent.runtime.dsh.management.DshRuntimeConfiguration; +import vip.mate.agent.AgentService; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.model.ModelProviderEntity; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.llm.service.ModelProviderService; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Adapter for the official DeepSeek Harness SDK JSON-RPC runtime. + * + *

The runtime is intentionally an external process. This keeps the Node + * plugin graph out of the Spring classpath and lets deployments pin the DSH + * runtime independently from MateClaw.

+ */ +@Service +@Slf4j +public class DshRuntimeService implements AgentRuntimeProvider { + private final ObjectMapper objectMapper; + private final ModelConfigService modelConfigService; + private final ModelProviderService modelProviderService; + private final DshRuntimeConfigService runtimeConfigService; + + public DshRuntimeService( + ObjectMapper objectMapper, + ModelConfigService modelConfigService, + ModelProviderService modelProviderService, + DshRuntimeConfigService runtimeConfigService) { + this.objectMapper = objectMapper; + this.modelConfigService = modelConfigService; + this.modelProviderService = modelProviderService; + this.runtimeConfigService = runtimeConfigService; + DshRuntimeConfiguration configuration = runtimeConfig(); + log.info("[DSH] runtime configured: command={}, cordisConfig={}", configuration.executablePath(), + configuration.cordisConfigPath().isBlank() ? "" : configuration.cordisConfigPath()); + } + + private DshRuntimeConfiguration runtimeConfig() { + DshRuntimeConfiguration raw = runtimeConfigService.resolve(); + String command = raw.executablePath(); + if (command == null || command.isBlank()) command = "dsh-jsonrpc-agent"; + String cordis = resolveCordisConfig(raw.cordisConfigPath()); + String cwd = raw.workingDirectory(); + if (cwd == null || cwd.isBlank()) cwd = System.getProperty("user.dir"); + return new DshRuntimeConfiguration(command, cordis, cwd, raw.baseUrl(), raw.modelName(), raw.apiKey()); + } + + private String resolveCordisConfig(String configuredPath) { + if (configuredPath == null || configuredPath.isBlank()) return ""; + Path path = Path.of(configuredPath).toAbsolutePath().normalize(); + if (Files.isRegularFile(path)) return path.toString(); + // The documented source checkout path points at the package directory; + // the checked-in composition lives below its runtime subdirectory. + Path packageDirectory = Files.isDirectory(path) ? path : path.getParent(); + Path packagedConfig = packageDirectory == null + ? path + : packageDirectory.resolve("runtime").resolve("cordis.yml"); + return Files.isRegularFile(packagedConfig) ? packagedConfig.toString() : path.toString(); + } + + @Override + public String type() { + return "dsh"; + } + + @Override + public RuntimeValidation validate(RuntimeSession session) { + DshRuntimeConfiguration configuration = runtimeConfig(); + if (session == null || session.workspaceId() == null) { + return RuntimeValidation.invalid("dsh.workspace_required", "DSH runtime requires a workspace"); + } + if (session.workingDirectory() == null || !Files.isDirectory(session.workingDirectory())) { + return RuntimeValidation.invalid("dsh.working_directory_unavailable", "DSH working directory is unavailable"); + } + if (configuration.executablePath().isBlank()) { + return RuntimeValidation.invalid("dsh.command_missing", "DSH runtime command is not configured"); + } + Path executable = Path.of(commandLine(configuration.executablePath()).get(0)); + if (!executable.isAbsolute() || !Files.isExecutable(executable)) { + return RuntimeValidation.invalid("dsh.command_unavailable", "DSH runtime command is not executable"); + } + if (!configuration.cordisConfigPath().isBlank() && !Files.isRegularFile(Path.of(configuration.cordisConfigPath()))) { + return RuntimeValidation.invalid("dsh.cordis_missing", "DSH Cordis configuration is unavailable"); + } + return RuntimeValidation.success(); + } + + @Override + public RuntimeCapabilities capabilities() { + return new RuntimeCapabilities(true, false, true, true); + } + + public Map diagnostics() { + DshRuntimeConfiguration configuration = runtimeConfig(); + Path executable = configuration.executablePath().isBlank() ? null : Path.of(commandLine(configuration.executablePath()).get(0)); + return Map.of( + "type", type(), + "commandConfigured", !configuration.executablePath().isBlank(), + "command", configuration.executablePath(), + "executable", executable == null ? "" : executable.toString(), + "executableAvailable", executable != null && Files.isExecutable(executable), + "cordisConfig", configuration.cordisConfigPath(), + "cordisConfigAvailable", !configuration.cordisConfigPath().isBlank() && Files.isRegularFile(Path.of(configuration.cordisConfigPath())), + "workingDirectory", configuration.workingDirectory(), + "apiKeyConfigured", configuration.apiKey() != null && !configuration.apiKey().isBlank(), + "capabilities", Map.of( + "cancellation", true, + "approvals", false, + "subagents", true, + "contextUsage", true)); + } + + public void validateAgentConfiguration(AgentEntity agent) { + if (agent == null || agent.getWorkspaceId() == null) { + throw new IllegalArgumentException("dsh.workspace_required: DSH runtime requires a workspace"); + } + if (agent.getRuntimeConfig() != null && !agent.getRuntimeConfig().isBlank()) { + try { + JsonNode node = objectMapper.readTree(agent.getRuntimeConfig()); + if (node == null || !node.isObject()) throw new IllegalArgumentException(); + } catch (Exception error) { + throw new IllegalArgumentException("dsh.runtime_config_invalid: runtime config must be a JSON object", error); + } + } + } + + @Override + public AgentRuntimeConnection start(RuntimeSession session) { + RuntimeValidation validation = validate(session); + if (!validation.valid()) { + throw new IllegalArgumentException(validation.code() + ": " + validation.message()); + } + AgentEntity agent = new AgentEntity(); + agent.setId(session.agentId()); + agent.setWorkspaceId(session.workspaceId()); + agent.setModelName(session.modelName()); + AtomicReference activeProcess = new AtomicReference<>(); + AtomicReference latestUsage = new AtomicReference<>( + new RuntimeContextUsage(0, 0, 0)); + return new AgentRuntimeConnection() { + @Override + public Flux prompt(String message) { + return stream(agent, message, session.conversationId(), session.modelName(), + session.workingDirectory(), activeProcess, latestUsage) + .map(DshRuntimeService.this::toRuntimeEvent); + } + + @Override + public reactor.core.publisher.Mono cancel() { + return reactor.core.publisher.Mono.fromRunnable( + () -> cancelProcess(activeProcess.get())); + } + + @Override + public reactor.core.publisher.Mono contextUsage() { + return reactor.core.publisher.Mono.just(latestUsage.get()); + } + }; + } + + private RuntimeEvent toRuntimeEvent(AgentService.StreamDelta delta) { + if (delta == null) return RuntimeEvent.of("dsh", 0, RuntimeEventType.RUNTIME_READY, null, Map.of()); + if (delta.content() != null) { + return RuntimeEvent.of("dsh", 0, RuntimeEventType.ASSISTANT_DELTA, delta.content(), Map.of()); + } + if (delta.thinking() != null) { + return RuntimeEvent.of("dsh", 0, RuntimeEventType.THINKING_DELTA, delta.thinking(), Map.of()); + } + RuntimeEventType type = switch (delta.eventType() == null ? "" : delta.eventType()) { + case "done" -> RuntimeEventType.COMPLETED; + case "error" -> RuntimeEventType.FAILED; + case "cancelled" -> RuntimeEventType.CANCELLED; + case "tool_call_started" -> RuntimeEventType.TOOL_STARTED; + case "tool_call_completed" -> RuntimeEventType.TOOL_FINISHED; + case "tool_approval_requested" -> RuntimeEventType.TOOL_APPROVAL_REQUIRED; + default -> RuntimeEventType.RUNTIME_READY; + }; + return type.terminal() + ? RuntimeEvent.terminal("dsh", 0, type, delta.eventData()) + : RuntimeEvent.of("dsh", 0, type, null, delta.eventData()); + } + + public Flux stream(AgentEntity agent, String message, + String conversationId, String modelName) { + DshRuntimeConfiguration configuration = runtimeConfig(); + return stream(agent, message, conversationId, modelName, + resolveWorkingDirectory(null, configuration), new AtomicReference<>(), + new AtomicReference<>(new RuntimeContextUsage(0, 0, 0))); + } + + private Flux stream(AgentEntity agent, String message, + String conversationId, String modelName, + Path workingDirectory, + AtomicReference processRef, + AtomicReference latestUsage) { + return Flux.create(sink -> { + Process process = null; + try { + if (sink.isCancelled()) return; + DshRuntimeConfiguration configuration = runtimeConfig(); + RuntimeSession session = new RuntimeSession( + conversationId, + conversationId, + agent.getId(), + agent.getWorkspaceId(), + modelName, + workingDirectory, + Map.of()); + // Each prompt runs in a fresh child process. DSH persists its + // own session log, so reusing the MateClaw conversation id + // would make the next turn look like a conflicting live session. + String dshSessionId = conversationId + "-" + UUID.randomUUID(); + Files.createDirectories(session.workingDirectory()); + String requestedModel = modelName == null || modelName.isBlank() ? configuration.modelName() : modelName; + ModelProviderEntity provider = resolveProvider(requestedModel); + String effectiveModelName = resolveModelName(requestedModel); + log.debug("[DSH] model route: requestedModel={}, effectiveModel={}, provider={}, apiKeyConfigured={}, baseUrlConfigured={}", + modelName == null || modelName.isBlank() ? "" : modelName, + effectiveModelName, + provider == null ? "" : provider.getProviderId(), + provider != null && provider.getApiKey() != null && !provider.getApiKey().isBlank(), + provider != null && provider.getBaseUrl() != null && !provider.getBaseUrl().isBlank()); + List command = commandLine(configuration.executablePath()); + ProcessBuilder builder = new ProcessBuilder(command) + .directory(session.workingDirectory().toFile()) + .redirectError(ProcessBuilder.Redirect.PIPE); + Map environment = builder.environment(); + Map childEnvironment = childEnvironment(environment, session, configuration, provider); + environment.clear(); + environment.putAll(childEnvironment); + log.debug("[DSH] child environment: keys={}, cordisConfig={}, exists={}", + environment.keySet(), + environment.getOrDefault("DSH_CORDIS_CONFIG", ""), + !configuration.cordisConfigPath().isBlank() && Files.isRegularFile(Path.of(configuration.cordisConfigPath()))); + process = builder.start(); + processRef.set(process); + if (sink.isCancelled()) { + cancelProcess(process); + return; + } + Process startedProcess = process; + Thread stderrLogger = new Thread(() -> logProcessStderr(startedProcess), + "dsh-runtime-stderr-" + conversationId); + stderrLogger.setDaemon(true); + stderrLogger.start(); + sink.onCancel(() -> cancelProcess(startedProcess)); + try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter( + process.getOutputStream(), StandardCharsets.UTF_8)); + BufferedReader reader = new BufferedReader(new InputStreamReader( + process.getInputStream(), StandardCharsets.UTF_8))) { + send(writer, request("initialize", "init-" + conversationId, Map.of( + "cwd", session.workingDirectory().toString(), + "provider", "deepseek-official", + "model", effectiveModelName))); + awaitResponse(reader, "init-" + conversationId); + long sequence = 0; + sink.next(RuntimeEventProjector.project(RuntimeEvent.of( + conversationId, sequence++, RuntimeEventType.RUNTIME_READY, null, + Map.of("runtimeProvider", "dsh", "runtimeCommand", configuration.executablePath())))); + + String promptId = "prompt-" + conversationId; + send(writer, request("session/prompt", promptId, Map.of( + "sessionId", dshSessionId, + "contentBlocks", List.of(Map.of("type", "text", "text", message))))); + + // DSH may emit session events before the JSON-RPC response + // for session/prompt. Read both on the same loop so those + // notifications are not discarded while waiting for id. + boolean terminal = false; + boolean promptResponseReceived = false; + String line; + while (!terminal && (line = reader.readLine()) != null) { + JsonNode payload = objectMapper.readTree(line); + if (payload == null) continue; + if (payload.has("id") && promptId.equals(payload.path("id").asText(null))) { + promptResponseReceived = true; + log.debug("[DSH] prompt response received: id={}, error={}", promptId, + payload.has("error")); + if (payload.has("error")) { + throw new IllegalStateException(payload.path("error").path("message") + .asText("DSH prompt failed")); + } + continue; + } + if (!payload.has("method")) continue; + String method = payload.path("method").asText(); + JsonNode params = payload.path("params"); + if (payload.has("id")) { + send(writer, errorResponse(payload.get("id"), -32601, "MateClaw does not support runtime request: " + method)); + continue; + } + if ("session.event".equals(method)) { + JsonNode event = params.path("event"); + log.debug("[DSH] event: type={}", event.path("type").asText("")); + logChunkMetadata(event); + logTerminalReason(event); + RuntimeEvent mapped = mapEvent(conversationId, sequence++, event); + if (mapped != null) { + if (mapped.type() == RuntimeEventType.CONTEXT_USAGE) { + latestUsage.set(usageFrom(mapped)); + } + sink.next(RuntimeEventProjector.project(mapped)); + terminal = mapped.terminal(); + } + } else if ("session.status".equals(method) + && promptResponseReceived + && "idle".equals(params.path("status").asText())) { + log.debug("[DSH] session idle after prompt"); + sink.next(RuntimeEventProjector.project(RuntimeEvent.terminal( + conversationId, sequence++, RuntimeEventType.COMPLETED, Map.of()))); + terminal = true; + } + } + if (!terminal) { + int exitCode = process.waitFor(); + sink.next(RuntimeEventProjector.project(RuntimeEvent.terminal( + conversationId, sequence, RuntimeEventType.FAILED, + Map.of("error", "DSH runtime closed before completion (exit=" + exitCode + ")")))); + } + sink.complete(); + } + } catch (Exception error) { + sink.error(new IllegalStateException("DSH runtime unavailable: " + error.getMessage(), error)); + if (process != null) process.destroyForcibly(); + } finally { + if (process != null) processRef.compareAndSet(process, null); + } + }).subscribeOn(Schedulers.boundedElastic()); + } + + static Path resolveWorkingDirectory(RuntimeSession session, DshRuntimeConfiguration configuration) { + if (session != null && session.workingDirectory() != null) { + return session.workingDirectory().toAbsolutePath().normalize(); + } + return Path.of(configuration.workingDirectory()).toAbsolutePath().normalize(); + } + + static void cancelProcess(Process process) { + if (process == null || !process.isAlive()) return; + + // DSH tools can spawn commands such as `sleep` that inherit the + // JSON-RPC process' stdout pipe. Close the pipes and terminate the + // descendants first; otherwise the parent may die while readLine() + // remains blocked until the child exits naturally. + try { + var descendants = process.descendants(); + if (descendants != null) { + descendants.toList().forEach(DshRuntimeService::cancelProcessHandle); + } + } catch (Exception ignored) { + // The parent teardown below is still the best-effort fallback. + } + closeQuietly(process.getInputStream()); + closeQuietly(process.getErrorStream()); + closeQuietly(process.getOutputStream()); + process.destroy(); + if (process.isAlive()) process.destroyForcibly(); + } + + private static void cancelProcessHandle(ProcessHandle process) { + if (process == null || !process.isAlive()) return; + process.destroy(); + if (process.isAlive()) process.destroyForcibly(); + } + + private static void closeQuietly(java.io.Closeable stream) { + if (stream == null) return; + try { + stream.close(); + } catch (Exception ignored) { + // Cancellation is best effort; the process termination is authoritative. + } + } + + private void logProcessStderr(Process process) { + try (BufferedReader errors = new BufferedReader(new InputStreamReader( + process.getErrorStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = errors.readLine()) != null) { + log.warn("[DSH] {}", line); + } + } catch (IOException error) { + log.debug("[DSH] stderr reader closed: {}", error.getMessage()); + } + } + + static List commandLine(String commandLine) { + List result = new ArrayList<>(); + StringBuilder token = new StringBuilder(); + char quote = 0; + boolean escaped = false; + for (char current : commandLine == null ? "".toCharArray() : commandLine.toCharArray()) { + if (escaped) { + token.append(current); + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (quote != 0) { + if (current == quote) quote = 0; + else token.append(current); + } else if (current == '\'' || current == '"') { + quote = current; + } else if (Character.isWhitespace(current)) { + if (!token.isEmpty()) { + result.add(token.toString()); + token.setLength(0); + } + } else { + token.append(current); + } + } + if (escaped) token.append('\\'); + if (quote != 0) throw new IllegalArgumentException("DSH runtime command has an unterminated quote"); + if (!token.isEmpty()) result.add(token.toString()); + if (result.isEmpty()) throw new IllegalStateException("DSH runtime command is empty"); + log.debug("[DSH] launching command: {}", result); + return result; + } + + static Map childEnvironment(Map inherited, + RuntimeSession session, + DshRuntimeConfiguration configuration, + ModelProviderEntity provider) { + Map environment = new LinkedHashMap<>(); + copyIfPresent(inherited, environment, "PATH"); + copyIfPresent(inherited, environment, "HOME"); + copyIfPresent(inherited, environment, "USERPROFILE"); + copyIfPresent(inherited, environment, "TMPDIR"); + copyIfPresent(inherited, environment, "TEMP"); + copyIfPresent(inherited, environment, "TMP"); + copyIfPresent(inherited, environment, "SystemRoot"); + copyIfPresent(inherited, environment, "WINDIR"); + + environment.put("DSH_CWD", session.workingDirectory().toString()); + putIfPresent(environment, "DSH_CORDIS_CONFIG", configuration.cordisConfigPath()); + putIfPresent(environment, "DEEPSEEK_API_KEY", + firstNonBlank(configuration.apiKey(), provider == null ? null : provider.getApiKey())); + putIfPresent(environment, "DEEPSEEK_BASE_URL", + firstNonBlank(configuration.baseUrl(), provider == null ? null : provider.getBaseUrl())); + return environment; + } + + private static void copyIfPresent(Map source, Map target, String key) { + if (source == null) return; + putIfPresent(target, key, source.get(key)); + } + + private static void putIfPresent(Map target, String key, String value) { + if (value == null || value.isBlank()) return; + target.put(key, value); + } + + private static String firstNonBlank(String primary, String fallback) { + return primary != null && !primary.isBlank() ? primary : fallback; + } + + private ModelProviderEntity resolveProvider(String modelName) { + ModelConfigEntity model = null; + try { + model = modelConfigService.resolveModel(modelName); + } catch (RuntimeException ignored) { + // Fall back to the dedicated DeepSeek provider below. + } + if (model != null && model.getProvider() != null && !model.getProvider().isBlank()) { + try { + return modelProviderService.getProviderConfig(model.getProvider()); + } catch (RuntimeException ignored) { + // The model row may outlive its provider row; use the runtime default. + } + } + try { + return modelProviderService.getProviderConfig("deepseek"); + } catch (RuntimeException ignored) { + return null; + } + } + + private String resolveModelName(String modelName) { + try { + ModelConfigEntity model = modelConfigService.resolveModel(modelName); + if (model != null && model.getModelName() != null && !model.getModelName().isBlank()) { + return model.getModelName(); + } + } catch (RuntimeException ignored) { + // Fall back to the DSH catalog default for a not-yet-configured agent. + } + return modelName == null || modelName.isBlank() ? "deepseek-v4-flash" : modelName; + } + + RuntimeEvent mapEvent(String sessionId, long sequence, JsonNode event) { + String type = event.path("type").asText(""); + JsonNode data = event.path("data"); + if ("assistant/chunk".equals(type)) { + JsonNode chunk = data.has("chunk") ? data.path("chunk") : data; + if ("usage".equals(chunk.path("type").asText())) { + JsonNode usage = chunk.path("usage"); + long inputTokens = usage.path("inputTokens").asLong(0); + long outputTokens = usage.path("outputTokens").asLong(0); + return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.CONTEXT_USAGE, + null, Map.of( + "promptTokens", inputTokens, + "completionTokens", outputTokens, + "inputTokens", inputTokens, + "outputTokens", outputTokens)); + } + String text = firstText(chunk, data); + if (text != null && !text.isEmpty()) { + RuntimeEventType eventType = "reasoning-delta".equals(chunk.path("type").asText()) + ? RuntimeEventType.THINKING_DELTA + : RuntimeEventType.ASSISTANT_DELTA; + return RuntimeEvent.of(sessionId, sequence, eventType, text, + Map.of("chunkType", chunk.path("type").asText("unknown"))); + } + if ("finish".equals(chunk.path("type").asText()) + && "error".equals(chunk.path("reason").path("kind").asText())) { + JsonNode failure = chunk.path("reason").path("failure"); + return RuntimeEvent.terminal(sessionId, sequence, RuntimeEventType.FAILED, + Map.of("error", failure.path("message").asText("DSH assistant failed"), + "code", failure.path("code").asText("DSH_RUNTIME_ERROR"))); + } + } + // The DSH stream emits text-delta chunks followed by an assistant/message + // snapshot. Mapping both would append the same answer twice to the UI. + if ("text-delta".equals(type)) { + String text = firstText(data, event); + if (text != null && !text.isEmpty()) { + return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.ASSISTANT_DELTA, text, Map.of()); + } + } + if (type.contains("tool") && (type.contains("start") || type.contains("call"))) { + return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.TOOL_STARTED, null, + Map.of("toolName", data.path("toolName").asText("dsh-tool"))); + } + if (type.contains("tool") && (type.contains("end") || type.contains("result"))) { + return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.TOOL_FINISHED, null, Map.of()); + } + if ("turn/end".equals(type)) { + String kind = data.path("reason").path("kind").asText(""); + if ("error".equals(kind)) { + return RuntimeEvent.terminal(sessionId, sequence, RuntimeEventType.FAILED, + Map.of("error", data.path("reason").path("error").path("message").asText("DSH turn failed"))); + } + } + return null; + } + + private RuntimeContextUsage usageFrom(RuntimeEvent event) { + return new RuntimeContextUsage( + number(event.data().get("inputTokens")), + number(event.data().get("outputTokens")), + number(event.data().get("contextWindow"))); + } + + private long number(Object value) { + return value instanceof Number number ? Math.max(0, number.longValue()) : 0; + } + + private String firstText(JsonNode primary, JsonNode fallback) { + String text = primary.path("text").asText(null); + if (text != null) return text; + text = primary.path("delta").path("text").asText(null); + if (text != null) return text; + text = fallback.path("text").asText(null); + if (text != null) return text; + return fallback.path("delta").path("text").asText(null); + } + + private void logChunkMetadata(JsonNode event) { + if (!"assistant/chunk".equals(event.path("type").asText())) return; + JsonNode data = event.path("data"); + JsonNode chunk = data.has("chunk") ? data.path("chunk") : data; + log.debug("[DSH] assistant chunk: type={}, fields={}, dataFields={}, textPresent={}, textLength={}", + chunk.path("type").asText(""), + chunk.fieldNames().hasNext(), data.fieldNames().hasNext(), + chunk.has("text"), chunk.path("text").isTextual() ? chunk.path("text").textValue().length() : 0); + } + + private void logTerminalReason(JsonNode event) { + String type = event.path("type").asText(""); + if (!"assistant/chunk".equals(type) && !"turn/end".equals(type)) return; + JsonNode reason = "assistant/chunk".equals(type) + ? event.path("data").path("chunk").path("reason") + : event.path("data").path("reason"); + if (reason.isMissingNode() || reason.isNull()) return; + JsonNode failure = reason.path("failure").isMissingNode() + ? reason.path("error") : reason.path("failure"); + log.warn("[DSH] terminal reason: eventType={}, kind={}, code={}, message={}", + type, + reason.path("kind").asText(""), + failure.path("code").asText(""), + failure.path("message").asText("")); + } + + private void awaitResponse(BufferedReader reader, String id) throws IOException { + String line; + while ((line = reader.readLine()) != null) { + JsonNode payload = objectMapper.readTree(line); + if (payload != null && id.equals(payload.path("id").asText(null))) { + if (payload.has("error")) { + throw new IllegalStateException(payload.path("error").path("message").asText("DSH JSON-RPC error")); + } + return; + } + } + throw new IOException("DSH runtime closed while waiting for " + id); + } + + private Map request(String method, String id, Map params) { + return Map.of("jsonrpc", "2.0", "id", id, "method", method, "params", params); + } + + private Map errorResponse(JsonNode id, int code, String message) { + return Map.of("jsonrpc", "2.0", "id", objectMapper.convertValue(id, Object.class), + "error", Map.of("code", code, "message", message)); + } + + private void send(BufferedWriter writer, Map payload) throws IOException { + writer.write(objectMapper.writeValueAsString(payload)); + writer.newLine(); + writer.flush(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolCatalog.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolCatalog.java new file mode 100644 index 00000000..ef90980a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolCatalog.java @@ -0,0 +1,22 @@ +package vip.mate.agent.runtime.dsh; + +import org.springframework.ai.tool.ToolCallback; + +import java.util.LinkedHashMap; +import java.util.List; + +public final class DshToolCatalog { + private DshToolCatalog() {} + + public static List fromCallbacks(List callbacks) { + LinkedHashMap descriptors = new LinkedHashMap<>(); + if (callbacks == null) return List.of(); + for (ToolCallback callback : callbacks) { + if (callback == null || callback.getToolDefinition() == null) continue; + var definition = callback.getToolDefinition(); + descriptors.putIfAbsent(definition.name(), new DshToolDescriptor( + definition.name(), definition.description(), definition.inputSchema())); + } + return List.copyOf(descriptors.values()); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDecision.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDecision.java new file mode 100644 index 00000000..1f91ad0f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDecision.java @@ -0,0 +1,7 @@ +package vip.mate.agent.runtime.dsh; + +public enum DshToolDecision { + ALLOW, + APPROVAL, + DENY +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDescriptor.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDescriptor.java new file mode 100644 index 00000000..cf645409 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDescriptor.java @@ -0,0 +1,9 @@ +package vip.mate.agent.runtime.dsh; + +public record DshToolDescriptor(String name, String description, String inputSchema) { + public DshToolDescriptor { + if (name == null || name.isBlank()) throw new IllegalArgumentException("tool name is required"); + description = description == null ? "" : description; + inputSchema = inputSchema == null || inputSchema.isBlank() ? "{}" : inputSchema; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDispatchResult.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDispatchResult.java new file mode 100644 index 00000000..0b5ff7dc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDispatchResult.java @@ -0,0 +1,15 @@ +package vip.mate.agent.runtime.dsh; + +public record DshToolDispatchResult(DshToolDecision decision, String output, String error) { + public static DshToolDispatchResult allowed(String output) { + return new DshToolDispatchResult(DshToolDecision.ALLOW, output, null); + } + + public static DshToolDispatchResult denied(String error) { + return new DshToolDispatchResult(DshToolDecision.DENY, null, error); + } + + public static DshToolDispatchResult approval(String reason) { + return new DshToolDispatchResult(DshToolDecision.APPROVAL, null, reason); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDispatcher.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDispatcher.java new file mode 100644 index 00000000..b2f35136 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDispatcher.java @@ -0,0 +1,42 @@ +package vip.mate.agent.runtime.dsh; + +import org.springframework.ai.tool.ToolCallback; + +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class DshToolDispatcher { + private final Map callbacks; + private final DshToolPolicy policy; + private final DshToolPolicyEvaluator policyEvaluator; + + public DshToolDispatcher(List callbacks, DshToolPolicy policy, + DshToolPolicyEvaluator policyEvaluator) { + Map byName = new LinkedHashMap<>(); + if (callbacks != null) { + for (ToolCallback callback : callbacks) { + if (callback != null && callback.getToolDefinition() != null) { + byName.putIfAbsent(callback.getToolDefinition().name(), callback); + } + } + } + this.callbacks = Map.copyOf(byName); + this.policy = policy; + this.policyEvaluator = policyEvaluator; + } + + public DshToolDispatchResult dispatch(String toolName, String argumentsJson, Path targetPath) { + ToolCallback callback = callbacks.get(toolName); + if (callback == null) return DshToolDispatchResult.denied("unknown tool"); + DshToolDecision decision = policyEvaluator.decide(policy, toolName, targetPath); + if (decision == DshToolDecision.DENY) return DshToolDispatchResult.denied("tool denied by policy"); + if (decision == DshToolDecision.APPROVAL) return DshToolDispatchResult.approval("tool approval required"); + try { + return DshToolDispatchResult.allowed(callback.call(argumentsJson == null ? "{}" : argumentsJson)); + } catch (RuntimeException e) { + return DshToolDispatchResult.denied("tool execution failed"); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolPolicy.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolPolicy.java new file mode 100644 index 00000000..020c96d8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolPolicy.java @@ -0,0 +1,21 @@ +package vip.mate.agent.runtime.dsh; + +import java.nio.file.Path; +import java.util.Set; + +public record DshToolPolicy( + Path workspaceRoot, + String permissionMode, + Set disabledTools, + Set readTools, + Set editTools, + Set autoApprovedTools +) { + public DshToolPolicy { + permissionMode = permissionMode == null ? "read-only" : permissionMode; + disabledTools = disabledTools == null ? Set.of() : Set.copyOf(disabledTools); + readTools = readTools == null ? Set.of() : Set.copyOf(readTools); + editTools = editTools == null ? Set.of() : Set.copyOf(editTools); + autoApprovedTools = autoApprovedTools == null ? Set.of() : Set.copyOf(autoApprovedTools); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolPolicyEvaluator.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolPolicyEvaluator.java new file mode 100644 index 00000000..ac6f2959 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolPolicyEvaluator.java @@ -0,0 +1,27 @@ +package vip.mate.agent.runtime.dsh; + +import java.nio.file.Path; + +public final class DshToolPolicyEvaluator { + public DshToolDecision decide(DshToolPolicy policy, String toolName, Path targetPath) { + if (policy == null || toolName == null || toolName.isBlank()) return DshToolDecision.DENY; + if (policy.disabledTools().contains(toolName)) return DshToolDecision.DENY; + if (targetPath != null && !withinWorkspace(policy.workspaceRoot(), targetPath)) { + return DshToolDecision.DENY; + } + boolean edit = policy.editTools().contains(toolName); + if (edit && "read-only".equalsIgnoreCase(policy.permissionMode())) { + return DshToolDecision.DENY; + } + if (policy.autoApprovedTools().contains(toolName)) return DshToolDecision.ALLOW; + if (policy.readTools().contains(toolName) && !edit) return DshToolDecision.ALLOW; + return DshToolDecision.APPROVAL; + } + + private boolean withinWorkspace(Path root, Path target) { + if (root == null) return false; + Path normalizedRoot = root.toAbsolutePath().normalize(); + Path normalizedTarget = target.toAbsolutePath().normalize(); + return normalizedTarget.startsWith(normalizedRoot); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshArtifactInstaller.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshArtifactInstaller.java new file mode 100644 index 00000000..58a87642 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshArtifactInstaller.java @@ -0,0 +1,200 @@ +package vip.mate.agent.runtime.dsh.management; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** Downloads and atomically installs the server-selected DSH artifact. */ +@Service +public class DshArtifactInstaller { + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + private final URI manifestUri; + private final URI githubReleaseUri; + private final Path installRoot; + + public DshArtifactInstaller( + ObjectMapper objectMapper, + @Value("${mateclaw.agent.runtime.dsh.manifest-url:}") String manifestUrl, + @Value("${mateclaw.agent.runtime.dsh.github-release-url:https://api.github.com/repos/deepseek-ai/deepseek-harness/releases/latest}") String githubReleaseUrl, + @Value("${mateclaw.agent.runtime.dsh.install-root:${user.home}/.mateclaw/runtimes/deepseek-harness}") String installRoot) { + this.objectMapper = objectMapper; + this.httpClient = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build(); + this.manifestUri = manifestUrl == null || manifestUrl.isBlank() ? null : URI.create(manifestUrl.trim()); + this.githubReleaseUri = URI.create(githubReleaseUrl.trim()); + this.installRoot = Path.of(installRoot).toAbsolutePath().normalize(); + } + + public boolean isInstalled() { + return Files.isExecutable(installRoot.resolve("dsh-jsonrpc-agent")) + || Files.isExecutable(installRoot.resolve("bin/dsh-jsonrpc-agent")); + } + + public boolean manifestConfigured() { + return manifestUri != null || githubReleaseUri != null; + } + + public boolean privateManifestConfigured() { + return manifestUri != null; + } + + public Path installedExecutable() { + Path direct = installRoot.resolve("dsh-jsonrpc-agent"); + return Files.isExecutable(direct) ? direct : installRoot.resolve("bin/dsh-jsonrpc-agent"); + } + + public Path installedCordisConfig() { + if (!Files.exists(installRoot)) return null; + try (var paths = Files.walk(installRoot)) { + return paths.filter(path -> path.getFileName().toString().equals("cordis.yml")) + .findFirst().orElse(null); + } catch (Exception ignored) { + return null; + } + } + + public DshArtifactManifest loadManifest() throws Exception { + if (manifestUri != null) { + HttpRequest request = HttpRequest.newBuilder(manifestUri).GET().build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() / 100 == 2) return objectMapper.readValue(response.body(), DshArtifactManifest.class); + } + return loadGithubManifest(); + } + + private DshArtifactManifest loadGithubManifest() throws Exception { + HttpRequest request = HttpRequest.newBuilder(githubReleaseUri) + .header("Accept", "application/vnd.github+json").GET().build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() / 100 != 2) throw new IllegalStateException("DSH private manifest unavailable and GitHub fallback failed: HTTP " + response.statusCode()); + JsonNode root = objectMapper.readTree(response.body()); + for (JsonNode asset : root.path("assets")) { + String name = asset.path("name").asText("").toLowerCase(); + String digest = asset.path("digest").asText(""); + if ((name.contains("macos") || name.contains("darwin")) && name.contains("arm64") && digest.startsWith("sha256:")) { + return new DshArtifactManifest("deepseek-harness", root.path("tag_name").asText("latest"), "macos-arm64", + asset.path("browser_download_url").asText(), digest.substring("sha256:".length()), asset.path("size").asLong(0), null); + } + } + throw new IllegalStateException("GitHub DSH release has no macos-arm64 asset with a SHA-256 digest"); + } + + public Path install(DshArtifactManifest manifest) throws Exception { + validateManifest(manifest); + Path parent = installRoot.getParent(); + Files.createDirectories(parent); + Path archive = Files.createTempFile(parent, ".dsh-download-", ".tar.gz"); + Path staging = Files.createTempDirectory(parent, ".dsh-staging-"); + try { + HttpRequest request = HttpRequest.newBuilder(URI.create(manifest.downloadUrl())).GET().build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() / 100 != 2) throw new IllegalStateException("DSH artifact request failed: HTTP " + response.statusCode()); + try (InputStream input = response.body()) { + Files.copy(input, archive, StandardCopyOption.REPLACE_EXISTING); + } + if (manifest.size() > 0 && Files.size(archive) != manifest.size()) { + throw new IllegalStateException("DSH artifact size mismatch"); + } + verifyChecksum(archive, manifest.sha256()); + verifyArchiveEntries(archive); + runTar(archive, staging); + verifyExtractedTree(staging); + Path executable = findExecutable(staging); + Path executableRelativePath = staging.relativize(executable); + executable.toFile().setExecutable(true, false); + Path backup = parent.resolve(".dsh-previous"); + if (Files.exists(installRoot)) Files.move(installRoot, backup, StandardCopyOption.REPLACE_EXISTING); + Files.move(staging, installRoot, StandardCopyOption.ATOMIC_MOVE); + Files.deleteIfExists(backup); + return installRoot.resolve(executableRelativePath); + } finally { + Files.deleteIfExists(archive); + deleteTree(staging); + } + } + + private void validateManifest(DshArtifactManifest manifest) { + if (manifest == null || manifest.downloadUrl() == null || manifest.downloadUrl().isBlank() + || manifest.sha256() == null || !manifest.sha256().matches("[0-9a-fA-F]{64}")) { + throw new IllegalArgumentException("DSH artifact manifest is incomplete or has an invalid checksum"); + } + URI uri = URI.create(manifest.downloadUrl()); + if (!"https".equalsIgnoreCase(uri.getScheme())) throw new IllegalArgumentException("DSH artifact must use HTTPS"); + } + + private void verifyChecksum(Path archive, String expected) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream input = Files.newInputStream(archive)) { + input.transferTo(new java.security.DigestOutputStream(OutputStreamDiscard.INSTANCE, digest)); + } + String actual = HexFormat.of().formatHex(digest.digest()); + if (!actual.equalsIgnoreCase(expected)) throw new IllegalStateException("DSH artifact checksum mismatch"); + } + + private void verifyArchiveEntries(Path archive) throws Exception { + Process process = new ProcessBuilder("tar", "-tzf", archive.toString()).redirectErrorStream(true).start(); + List entries; + try (InputStream input = process.getInputStream()) { + entries = new String(input.readAllBytes(), StandardCharsets.UTF_8).lines().toList(); + } + if (!process.waitFor(30, TimeUnit.SECONDS) || process.exitValue() != 0) throw new IllegalStateException("DSH archive is not a readable tar.gz"); + for (String entry : entries) { + Path normalized = Path.of(entry).normalize(); + if (entry.startsWith("/") || normalized.startsWith("..")) throw new IllegalArgumentException("DSH archive contains an unsafe path"); + } + } + + private void runTar(Path archive, Path destination) throws Exception { + Process process = new ProcessBuilder("tar", "-xzf", archive.toString(), "-C", destination.toString()).redirectErrorStream(true).start(); + String output; + try (InputStream input = process.getInputStream()) { output = new String(input.readAllBytes(), StandardCharsets.UTF_8); } + if (!process.waitFor(60, TimeUnit.SECONDS) || process.exitValue() != 0) throw new IllegalStateException("DSH archive extraction failed: " + output); + } + + private Path findExecutable(Path staging) throws Exception { + try (var paths = Files.walk(staging)) { + return paths.filter(path -> path.getFileName().toString().equals("dsh-jsonrpc-agent")) + .findFirst().orElseThrow(() -> new IllegalStateException("DSH artifact has no dsh-jsonrpc-agent executable")); + } + } + + private void verifyExtractedTree(Path staging) throws Exception { + try (var paths = Files.walk(staging)) { + for (Path path : paths.toList()) { + if (!Files.isSymbolicLink(path)) continue; + Path target = path.getParent().resolve(Files.readSymbolicLink(path)).normalize(); + if (!target.startsWith(staging)) throw new IllegalArgumentException("DSH archive contains a link outside its staging directory"); + } + } + } + + private void deleteTree(Path root) throws Exception { + if (root == null || !Files.exists(root)) return; + try (var paths = Files.walk(root)) { + paths.sorted(java.util.Comparator.reverseOrder()).forEach(path -> { + try { Files.deleteIfExists(path); } catch (Exception ignored) { } + }); + } + } + + private static final class OutputStreamDiscard extends java.io.OutputStream { + private static final OutputStreamDiscard INSTANCE = new OutputStreamDiscard(); + @Override public void write(int b) { } + @Override public void write(byte[] b, int off, int len) { } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshArtifactManifest.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshArtifactManifest.java new file mode 100644 index 00000000..f8410fa7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshArtifactManifest.java @@ -0,0 +1,13 @@ +package vip.mate.agent.runtime.dsh.management; + +import java.time.Instant; + +public record DshArtifactManifest( + String name, + String version, + String platform, + String downloadUrl, + String sha256, + long size, + Instant releasedAt) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementController.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementController.java new file mode 100644 index 00000000..01b4eb82 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementController.java @@ -0,0 +1,55 @@ +package vip.mate.agent.runtime.dsh.management; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import vip.mate.common.result.R; +import vip.mate.workspace.core.annotation.RequireGlobalAdmin; + +import java.util.Map; + +@Tag(name = "DeepSeek Harness Runtime Management") +@RestController +@RequestMapping("/api/v1/admin/dsh") +@RequiredArgsConstructor +public class DshManagementController { + private final DshManagementService managementService; + + @Operation(summary = "Get managed DSH runtime status") + @GetMapping("/status") + @RequireGlobalAdmin + public R> status() { return R.ok(managementService.status()); } + + @Operation(summary = "Save managed DSH runtime configuration") + @PutMapping("/config") + @RequireGlobalAdmin + public R> saveConfig(@RequestBody Map values) { + return R.ok(managementService.saveConfig(values)); + } + + @Operation(summary = "Install the server-selected DSH artifact") + @PostMapping("/install") + @RequireGlobalAdmin + public R> install() throws Exception { return R.ok(managementService.install()); } + + @Operation(summary = "Verify DSH runtime configuration") + @PostMapping("/verify") + @RequireGlobalAdmin + public R> verify() { return R.ok(managementService.verify()); } + + @Operation(summary = "Test starting the DSH process") + @PostMapping("/test-connection") + @RequireGlobalAdmin + public R> testConnection() { return R.ok(managementService.testConnection()); } + + @Operation(summary = "Enable managed DSH runtime") + @PostMapping("/enable") + @RequireGlobalAdmin + public R> enable() { return R.ok(managementService.enable()); } + + @Operation(summary = "Disable managed DSH runtime") + @PostMapping("/disable") + @RequireGlobalAdmin + public R> disable() { return R.ok(managementService.disable()); } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementService.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementService.java new file mode 100644 index 00000000..d48e1cf4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementService.java @@ -0,0 +1,130 @@ +package vip.mate.agent.runtime.dsh.management; + +import org.springframework.stereotype.Service; +import vip.mate.system.service.SystemSettingService; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@Service +public class DshManagementService { + private static final String ENABLED_KEY = "dsh.enabled"; + + private final DshRuntimeConfigService configService; + private final DshArtifactInstaller installer; + private final SystemSettingService settings; + + public DshManagementService(DshRuntimeConfigService configService, + DshArtifactInstaller installer, + SystemSettingService settings) { + this.configService = configService; + this.installer = installer; + this.settings = settings; + } + + public Map status() { + DshRuntimeConfiguration configuration = configService.resolve(); + boolean executableAvailable = isExecutable(configuration.executablePath()); + boolean workingDirectoryAvailable = configuration.workingDirectory() != null + && Files.isDirectory(Path.of(configuration.workingDirectory())); + boolean cordisAvailable = configuration.cordisConfigPath() == null + || configuration.cordisConfigPath().isBlank() + || Files.isRegularFile(Path.of(configuration.cordisConfigPath())); + // An empty managed key is valid: DshRuntimeService can reuse the + // existing DeepSeek provider key. The page may still store a managed + // key when the operator wants DSH to be independent from model rows. + boolean enabled = settings.getBool(ENABLED_KEY, false); + DshManagementState state; + if (!executableAvailable) state = DshManagementState.NOT_INSTALLED; + else if (!workingDirectoryAvailable || !cordisAvailable) state = DshManagementState.CONFIG_INVALID; + else if (enabled) state = DshManagementState.ENABLED; + else state = DshManagementState.READY; + + Map result = new LinkedHashMap<>(); + result.put("state", state.name()); + result.put("installed", executableAvailable); + result.put("enabled", enabled); + result.put("config", configuration.publicStatus()); + result.put("managed", configService.managedValues()); + result.put("artifactManifestConfigured", installer.manifestConfigured()); + result.put("privateArtifactManifestConfigured", installer.privateManifestConfigured()); + result.put("checkedAt", Instant.now().toString()); + return result; + } + + public Map saveConfig(Map values) { + configService.save(values); + return status(); + } + + public Map install() throws Exception { + DshArtifactManifest manifest = installer.loadManifest(); + Path executable = installer.install(manifest); + Map installed = new LinkedHashMap<>(); + installed.put("dsh.executable_path", executable.toString()); + Path cordis = installer.installedCordisConfig(); + if (cordis != null) installed.put("dsh.cordis_config_path", cordis.toString()); + configService.save(installed); + return status(); + } + + public Map verify() { + Map result = status(); + boolean ok = "ENABLED".equals(result.get("state")) || "READY".equals(result.get("state")); + result.put("verified", ok); + result.put("verificationMessage", ok ? "DSH executable and configuration are available" : "DSH executable or configuration is unavailable"); + return result; + } + + public Map testConnection() { + DshRuntimeConfiguration configuration = configService.resolve(); + if (!isExecutable(configuration.executablePath())) return Map.of("success", false, "message", "DSH executable is unavailable"); + if (configuration.cordisConfigPath() == null || configuration.cordisConfigPath().isBlank()) { + return Map.of("success", false, "message", "DSH Cordis configuration is unavailable"); + } + try { + ProcessBuilder builder = new ProcessBuilder(configuration.executablePath(), configuration.cordisConfigPath()) + .directory(Path.of(configuration.workingDirectory()).toFile()) + .redirectErrorStream(true); + builder.environment().put("DSH_CWD", configuration.workingDirectory()); + builder.environment().put("DSH_CORDIS_CONFIG", configuration.cordisConfigPath()); + if (configuration.apiKey() != null && !configuration.apiKey().isBlank()) { + builder.environment().put("DEEPSEEK_API_KEY", configuration.apiKey()); + } + if (configuration.baseUrl() != null && !configuration.baseUrl().isBlank()) { + builder.environment().put("DEEPSEEK_BASE_URL", configuration.baseUrl()); + } + Process process = builder.start(); + boolean finished = process.waitFor(5, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + return Map.of("success", true, "message", "DSH process started"); + } + String output = new String(process.getInputStream().readAllBytes()); + if (process.exitValue() != 0) throw new IllegalStateException(output.isBlank() ? "DSH process exited with code " + process.exitValue() : output.trim()); + return Map.of("success", true, "message", output.trim()); + } catch (Exception error) { + return Map.of("success", false, "message", "DSH connection test failed: " + error.getMessage()); + } + } + + public Map enable() { + Map current = verify(); + if (!Boolean.TRUE.equals(current.get("verified"))) throw new IllegalStateException("DSH must pass verification before enabling"); + settings.saveBool(ENABLED_KEY, true, "Enable managed DeepSeek Harness runtime"); + return status(); + } + + public Map disable() { + settings.saveBool(ENABLED_KEY, false, "Enable managed DeepSeek Harness runtime"); + return status(); + } + + private boolean isExecutable(String path) { + return path != null && !path.isBlank() && Files.isExecutable(Path.of(path)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementState.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementState.java new file mode 100644 index 00000000..2710634c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshManagementState.java @@ -0,0 +1,21 @@ +package vip.mate.agent.runtime.dsh.management; + +/** Lifecycle states exposed by the DSH runtime management screen. */ +public enum DshManagementState { + NOT_INSTALLED, + INSTALLING, + INSTALLED_UNCONFIGURED, + CONFIG_INVALID, + CHECKING, + CHECK_FAILED, + READY, + ENABLED; + + public boolean canEnable() { + return this == READY; + } + + public boolean isOperational() { + return this == ENABLED; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigResolver.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigResolver.java new file mode 100644 index 00000000..8cc710a9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigResolver.java @@ -0,0 +1,55 @@ +package vip.mate.agent.runtime.dsh.management; + +import java.util.Map; + +/** Resolves managed settings first, then application properties, then legacy environment variables. */ +public final class DshRuntimeConfigResolver { + + private DshRuntimeConfigResolver() { + } + + public static DshRuntimeConfiguration resolve( + Map managed, + Map properties, + Map environment) { + return new DshRuntimeConfiguration( + firstNonBlank(managed, properties, environment, + "dsh.executable_path", "mateclaw.agent.runtime.dsh.command", "DSH_JSONRPC_AGENT"), + firstNonBlank(managed, properties, environment, + "dsh.cordis_config_path", "mateclaw.agent.runtime.dsh.cordis-config", "DSH_CORDIS_CONFIG"), + firstNonBlank(managed, properties, environment, + "dsh.working_directory", "mateclaw.agent.runtime.dsh.working-directory", "DSH_CWD"), + firstNonBlank(managed, properties, environment, + "dsh.base_url", "mateclaw.agent.runtime.dsh.base-url", "DEEPSEEK_BASE_URL"), + firstNonBlank(managed, properties, environment, + "dsh.model_name", "mateclaw.agent.runtime.dsh.model-name", "DEEPSEEK_MODEL"), + firstNonBlank(managed, properties, environment, + "dsh.api_key", "mateclaw.agent.runtime.dsh.api-key", "DEEPSEEK_API_KEY")); + } + + private static String firstNonBlank( + Map managed, + Map properties, + Map environment, + String managedKey, + String propertyKey, + String environmentKey) { + String value = value(managed, managedKey); + if (value != null) { + return value; + } + value = value(properties, propertyKey); + if (value != null) { + return value; + } + return value(environment, environmentKey); + } + + private static String value(Map values, String key) { + if (values == null) { + return null; + } + String value = values.get(key); + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigService.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigService.java new file mode 100644 index 00000000..904218cd --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigService.java @@ -0,0 +1,112 @@ +package vip.mate.agent.runtime.dsh.management; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import vip.mate.system.service.SystemSettingService; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.nio.file.Files; +import java.nio.file.Path; + +/** Reads the current DSH configuration without requiring a backend restart. */ +@Service +public class DshRuntimeConfigService { + private static final String[] MANAGED_KEYS = { + "dsh.executable_path", "dsh.cordis_config_path", "dsh.working_directory", + "dsh.base_url", "dsh.model_name", SystemSettingService.DSH_API_KEY_KEY + }; + + private final SystemSettingService settings; + private final Map properties; + + public DshRuntimeConfigService( + SystemSettingService settings, + @Value("${mateclaw.agent.runtime.dsh.command:}") String command, + @Value("${mateclaw.agent.runtime.dsh.cordis-config:}") String cordisConfig, + @Value("${mateclaw.agent.runtime.dsh.working-directory:}") String workingDirectory, + @Value("${mateclaw.agent.runtime.dsh.base-url:}") String baseUrl, + @Value("${mateclaw.agent.runtime.dsh.model-name:}") String modelName, + @Value("${mateclaw.agent.runtime.dsh.api-key:}") String apiKey) { + this.settings = settings; + this.properties = Map.of( + "mateclaw.agent.runtime.dsh.command", command, + "mateclaw.agent.runtime.dsh.cordis-config", cordisConfig, + "mateclaw.agent.runtime.dsh.working-directory", workingDirectory, + "mateclaw.agent.runtime.dsh.base-url", baseUrl, + "mateclaw.agent.runtime.dsh.model-name", modelName, + "mateclaw.agent.runtime.dsh.api-key", apiKey); + } + + public DshRuntimeConfiguration resolve() { + Map managed = new LinkedHashMap<>(); + for (String key : MANAGED_KEYS) { + String defaultValue = key.equals("dsh.working_directory") ? "" : null; + managed.put(key, settings.getString(key, defaultValue)); + } + DshRuntimeConfiguration resolved = DshRuntimeConfigResolver.resolve(managed, properties, System.getenv()); + String workingDirectory = resolved.workingDirectory(); + if (workingDirectory == null || workingDirectory.isBlank()) workingDirectory = System.getProperty("user.dir"); + String cordisConfig = normalizeCordisConfig(resolved.cordisConfigPath()); + if (cordisConfig.isBlank()) cordisConfig = discoverCordisConfig(resolved.executablePath()); + return new DshRuntimeConfiguration(resolved.executablePath(), cordisConfig, workingDirectory, + resolved.baseUrl(), resolved.modelName(), resolved.apiKey()); + } + + private String discoverCordisConfig(String executable) { + if (executable == null || executable.isBlank()) return ""; + Path binary = Path.of(executable.split("\\s+")[0]).toAbsolutePath().normalize(); + Path packageRoot = binary.getParent(); + if (packageRoot == null) return ""; + Path[] candidates = { + packageRoot.resolve("runtime/cordis.yml"), + packageRoot.resolve("../runtime/cordis.yml").normalize(), + packageRoot.resolve("../examples/jsonrpc-agent/cordis.yml").normalize(), + packageRoot.resolve("../../examples/jsonrpc-agent/cordis.yml").normalize() + }; + for (Path candidate : candidates) if (Files.isRegularFile(candidate)) return candidate.toString(); + return ""; + } + + private String normalizeCordisConfig(String configured) { + if (configured == null || configured.isBlank()) return ""; + Path path = Path.of(configured).toAbsolutePath().normalize(); + if (Files.isRegularFile(path)) return path.toString(); + Path packageDirectory = Files.isDirectory(path) ? path : path.getParent(); + if (packageDirectory == null) return path.toString(); + Path packagedConfig = packageDirectory.resolve("runtime/cordis.yml"); + return Files.isRegularFile(packagedConfig) ? packagedConfig.toString() : path.toString(); + } + + public Map managedValues() { + Map values = new LinkedHashMap<>(); + for (String key : MANAGED_KEYS) { + String value = settings.getString(key, ""); + if (SystemSettingService.DSH_API_KEY_KEY.equals(key)) { + values.put(key, settings.maskSecret(value)); + } else { + values.put(key, value == null ? "" : value); + } + } + return values; + } + + public void save(Map values) { + if (values == null) return; + save(values, "dsh.executable_path"); + save(values, "dsh.cordis_config_path"); + save(values, "dsh.working_directory"); + save(values, "dsh.base_url"); + save(values, "dsh.model_name"); + String apiKey = values.get(SystemSettingService.DSH_API_KEY_KEY); + if (apiKey != null && !apiKey.isBlank() && !apiKey.startsWith("****")) { + settings.saveString(SystemSettingService.DSH_API_KEY_KEY, apiKey.trim(), "DeepSeek API key for DSH"); + } + } + + private void save(Map values, String key) { + if (values.containsKey(key)) { + settings.saveString(key, values.get(key), "Managed DeepSeek Harness runtime setting"); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfiguration.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfiguration.java new file mode 100644 index 00000000..56c88aa1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfiguration.java @@ -0,0 +1,25 @@ +package vip.mate.agent.runtime.dsh.management; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Resolved DSH settings. The API key is deliberately omitted from public projections. */ +public record DshRuntimeConfiguration( + String executablePath, + String cordisConfigPath, + String workingDirectory, + String baseUrl, + String modelName, + String apiKey) { + + public Map publicStatus() { + Map status = new LinkedHashMap<>(); + status.put("executablePath", executablePath); + status.put("cordisConfigPath", cordisConfigPath); + status.put("workingDirectory", workingDirectory); + status.put("baseUrl", baseUrl); + status.put("modelName", modelName); + status.put("apiKeyConfigured", apiKey != null && !apiKey.isBlank()); + return status; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index 10af9dbe..cd9999b8 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -19,6 +19,7 @@ import vip.mate.common.result.R; import vip.mate.workspace.core.service.ChatUploadLocationResolver; import vip.mate.agent.AgentService; import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.runtime.ConversationTurnGate; import vip.mate.approval.ApprovalWorkflowService; import vip.mate.approval.MetadataDecision; import vip.mate.approval.PendingApproval; @@ -43,6 +44,8 @@ import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; +import java.time.LocalDateTime; +import java.util.UUID; /** * Web 渠道聊天接口 @@ -66,6 +69,10 @@ public class ChatController { private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver; private final vip.mate.workspace.core.service.ChatUploadLocationResolver uploadLocationResolver; private final vip.mate.tool.document.preview.OfficePreviewService officePreviewService; + private final ConversationInputQueueStore inputQueue; + + @org.springframework.beans.factory.annotation.Autowired + private ConversationTurnGate turnGate = new ConversationTurnGate(); // Virtual thread per SSE task: matches the app-wide virtual-thread model // (spring.threads.virtual.enabled=true) and, unlike a cached platform-thread @@ -200,13 +207,29 @@ public class ChatController { return emitter; } + if (conversationService.conversationExists(conversationId) + && !conversationService.isConversationOwner(conversationId, username)) { + sendErrorDoneAndComplete(emitter, "无权操作该会话"); + return emitter; + } + + // Reserve before approval consumption, regeneration or stream mutation. + // Once registered, RunState protects the setup-to-subscription gap: + // autonomous admission checks isRunning while holding this same gate. + try (var setupPermit = turnGate.tryAcquire(conversationId)) { + if (setupPermit == null || streamTracker.isRunning(conversationId)) { + sendErrorDoneAndComplete(emitter, "正在生成回复,请先停止或排队后续消息"); + return emitter; + } + // ---- 审批命令拦截:/approve、/deny 走 SSE 流式 replay ---- String normalizedMsg = requestMessage.trim().toLowerCase(); boolean isApprovalCommand = "/approve".equals(normalizedMsg) || "approve".equals(normalizedMsg); boolean isDenyCommand = "/deny".equals(normalizedMsg) || "deny".equals(normalizedMsg); if (isApprovalCommand || isDenyCommand) { - PendingApproval pending = approvalService.findPendingByConversation(conversationId); + PendingApproval pending = findRequestedPendingApproval( + conversationId, request.getPendingApprovalId()); if (pending == null) { try { sendEvent(emitter, "error", Map.of("message", "当前没有待审批的工具调用")); @@ -249,6 +272,7 @@ public class ChatController { final String decision = isApprovalCommand ? "approved" : "denied"; streamTracker.register(conversationId); + setupPermit.close(); Long approvalAgentId = parseLongOrNull(pending.getAgentId()); streamTracker.bindRunMeta(conversationId, approvalAgentId, username); registerEmitterCallbacks(emitter, conversationId); @@ -279,8 +303,8 @@ public class ChatController { conversationService.getMessageCount(conversationId))); // deny 是正常 turn 终结,用户可能在 awaiting_approval 阶段排了消息 ChatStreamTracker.CompletionResult denyCr = streamTracker.completeAndConsumeIfLast(conversationId); - if (denyCr.allDone() && denyCr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, approvalEmitterDone, denyCr.queuedInput(), username, requestBaseUrl); + if (denyCr.allDone() && shouldDrainQueuedInput(conversationId, "completed")) { + startQueuedMessage(conversationId, emitter, approvalEmitterDone, username, requestBaseUrl); } else { completeEmitterQuietly(emitter, approvalEmitterDone); } @@ -293,8 +317,8 @@ public class ChatController { broadcastEvent(conversationId, "done", Map.of("status", "completed")); // 审批记录被另一个请求消费,但用户可能在等待期间排了消息 ChatStreamTracker.CompletionResult consumedNullCr = streamTracker.completeAndConsumeIfLast(conversationId); - if (consumedNullCr.allDone() && consumedNullCr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, approvalEmitterDone, consumedNullCr.queuedInput(), username, requestBaseUrl); + if (consumedNullCr.allDone() && shouldDrainQueuedInput(conversationId, "completed")) { + startQueuedMessage(conversationId, emitter, approvalEmitterDone, username, requestBaseUrl); } else { completeEmitterQuietly(emitter, approvalEmitterDone); } @@ -398,8 +422,8 @@ public class ChatController { } finally { ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); if (cr.allDone()) { - if (cr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username, requestBaseUrl); + if (shouldDrainQueuedInput(conversationId, persistStatus)) { + startQueuedMessage(conversationId, emitter, approvalEmitterDone, username, requestBaseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); completeEmitterQuietly(emitter, approvalEmitterDone); @@ -499,8 +523,8 @@ public class ChatController { streamTracker.clearInterruptState(conversationId); ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); if (cr.allDone()) { - if (cr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username, requestBaseUrl); + if (shouldDrainQueuedInput(conversationId, errStatus)) { + startQueuedMessage(conversationId, emitter, approvalEmitterDone, username, requestBaseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); completeEmitterQuietly(emitter, approvalEmitterDone); @@ -555,6 +579,7 @@ public class ChatController { // ---- 正常请求:注册流状态并附着首个订阅者 ---- streamTracker.register(conversationId); + setupPermit.close(); streamTracker.bindRunMeta(conversationId, agentId, username); registerEmitterCallbacks(emitter, conversationId); streamTracker.attach(conversationId, emitter); @@ -743,7 +768,7 @@ public class ChatController { ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); if (cr.allDone()) { // RFC follow-up (2026-04-27): the previous guard - // cr.queuedInput() != null && (isInterruptFollowup || !wasStopped) + // hasQueuedInput(conversationId) && (isInterruptFollowup || !wasStopped) // dropped legitimate queued messages when the user stopped // the running turn and then sent a new message via the // enqueue path (not the interrupt-with-followup path) — @@ -754,8 +779,8 @@ public class ChatController { // run it" condition; align with them. If the user // genuinely doesn't want continuation, no message would // have been in messageQueue to begin with. - if (cr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username, requestBaseUrl); + if (shouldDrainQueuedInput(conversationId, persistStatus)) { + startQueuedMessage(conversationId, emitter, emitterDone, username, requestBaseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); // 延迟关闭 emitter,确保最后的事件都已发送 @@ -847,9 +872,9 @@ public class ChatController { streamTracker.clearInterruptState(conversationId); ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); if (cr.allDone()) { - if (cr.queuedInput() != null) { + if (shouldDrainQueuedInput(conversationId, status)) { // 无论中断类型,都消费排队消息(修复 Disposable 不可用时队列被丢弃的 bug) - startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username, requestBaseUrl); + startQueuedMessage(conversationId, emitter, emitterDone, username, requestBaseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); completeEmitterQuietly(emitter, emitterDone); @@ -957,7 +982,7 @@ public class ChatController { streamTracker.clearInterruptState(conversationId); ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); log.info("SSE doOnError cleanup: conversationId={}, allDone={}, isInterruptFollowup={}, hasQueued={}", - conversationId, cr.allDone(), isInterruptFollowup, cr.queuedInput() != null); + conversationId, cr.allDone(), isInterruptFollowup, hasQueuedInput(conversationId)); if (cr.allDone()) { // RFC follow-up (2026-04-27): the previous guard // cr.queuedInput()!=null && !(isUserStop && !isInterruptFollowup) @@ -971,8 +996,8 @@ public class ChatController { // follow-up. Whoever puts a message in messageQueue means it // — just run it. Aligns with doOnComplete and the 4 other // queue-launch sites in this controller. - if (cr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username, requestBaseUrl); + if (shouldDrainQueuedInput(conversationId, status)) { + startQueuedMessage(conversationId, emitter, emitterDone, username, requestBaseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); completeEmitterQuietly(emitter, emitterDone); @@ -1005,6 +1030,7 @@ public class ChatController { }); return emitter; + } } /** @@ -1086,17 +1112,24 @@ public class ChatController { // 判断当前阶段(仅用于 reason 字段,行为对所有阶段一致:仅入队) boolean isAwaitingApproval = approvalService.findPendingByConversation(conversationId) != null; - // 仅入队、不 dispose。延迟持久化到 startQueuedMessage(让 Asst-N 先在 doOnComplete 落库, - // 否则 listMessages ORDER BY create_time ASC 会把 Q(N+1) 排到 Asst-N 前面) - boolean queued = streamTracker.enqueueMessage(conversationId, message, agentId, false, contentParts); + // Commit the payload before publishing acceptance. The stream tracker is + // only a wake signal; the database row remains authoritative on restart. + var stored = inputQueue.enqueue(conversationId, agentId, username, message, contentParts, + LocalDateTime.now()); + boolean queued = streamTracker.notifyQueuedInput(conversationId); + if (!queued) { + inputQueue.cancel(stored.id(), "stream_finished_before_queue_registration", + LocalDateTime.now()); + } log.info("Enqueued follow-up message during running turn: conversationId={}, user={}, queueSize={}, awaitingApproval={}", - conversationId, username, streamTracker.getQueueSize(conversationId), isAwaitingApproval); + conversationId, username, inputQueue.countQueued(conversationId), isAwaitingApproval); return R.ok(Map.of( "interrupted", false, "queued", queued, - "queueSize", streamTracker.getQueueSize(conversationId), - "reason", isAwaitingApproval ? "awaiting_approval" : "queued" + "queueItemId", stored.id().toString(), + "queueSize", inputQueue.countQueued(conversationId), + "reason", queued ? (isAwaitingApproval ? "awaiting_approval" : "queued") : "no_active_stream" )); } @@ -1123,6 +1156,10 @@ public class ChatController { if (username == null) { return R.fail(401, "未登录,请先登录"); } + try (var permit = turnGate.tryAcquire(request.getConversationId())) { + if (permit == null || streamTracker.isRunning(request.getConversationId())) { + return R.fail(409, "正在生成回复,请先停止或排队后续消息"); + } conversationService.getOrCreateConversation(request.getConversationId(), agentId, username, workspaceId); MessageEntity savedUser = conversationService.saveMessage( request.getConversationId(), "user", request.getMessage(), request.getContentParts()); @@ -1134,7 +1171,8 @@ public class ChatController { memoryOrigin(request.getConversationId(), username, requesterUserIdOf(auth), workspaceId, request.getEndUserId()).withOriginMessageId( savedUser == null ? null : savedUser.getId()); - AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin); + AgentService.ChatResult result = turnGate.withPermit(permit, () -> + agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin)); String response = result.content(); conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed", result.promptTokens(), result.completionTokens(), @@ -1142,6 +1180,7 @@ public class ChatController { completionPublisher.publish(agentId, request.getConversationId(), request.getMessage(), response, "web", memoryOwnerResolver.resolve(webOrigin)); return R.ok(response); + } } @Operation(summary = "上传聊天附件") @@ -1362,6 +1401,8 @@ public class ChatController { private String message; private String conversationId = "default"; private List contentParts; + /** Exact approval selected by the UI; absent for legacy FIFO clients. */ + private String pendingApprovalId; /** true 表示断线重连,不发送新消息,只附着到已有的流 */ private Boolean reconnect; /** @@ -1396,21 +1437,30 @@ public class ChatController { private Boolean regenerate; } - /** - * 自动启动排队消息(interrupt-with-followup 或自然完成后的续跑逻辑)。 - * 接受由 {@link ChatStreamTracker#completeAndConsumeIfLast} 预先消费的 QueuedInput 快照。 - * 快照已脱离 RunState 生命周期,不受后续 complete/register 影响。 - * 支持链式续跑:queued stream 自身完成时也通过 completeAndConsumeIfLast 检查并递归调用。 - */ + /** Claims and starts the next durable input after the current stream finishes. */ private void startQueuedMessage(String conversationId, SseEmitter emitter, AtomicBoolean emitterDone, - ChatStreamTracker.QueuedInput preConsumedInput, String requesterId, - String baseUrl) { + String requesterId, String baseUrl) { + String queueClaimId = UUID.randomUUID().toString(); + ConversationInputQueueStore.QueuedInput preConsumedInput = inputQueue + .claimNext(conversationId, queueClaimId, LocalDateTime.now()) + .orElse(null); if (preConsumedInput == null) { conversationService.updateStreamStatus(conversationId, "idle"); completeEmitterQuietly(emitter, emitterDone); return; } + Long agentId = preConsumedInput.agentId() != null ? preConsumedInput.agentId() : 1L; + var queuedConversation = conversationService.findByConversationId(conversationId); + if (queuedConversation == null || !agentId.equals(queuedConversation.getAgentId())) { + inputQueue.release(preConsumedInput.id(), queueClaimId, LocalDateTime.now()); + broadcastEvent(conversationId, "warning", Map.of( + "message", "排队消息对应的助手已变化,请确认后重试")); + conversationService.updateStreamStatus(conversationId, "idle"); + completeEmitterQuietly(emitter, emitterDone); + return; + } + // Rate Limit 防护:如果上一轮以 rate limit 错误结束,不立即续跑排队消息(必然再次 429)。 // 改为持久化用户消息 + 通知前端"稍后重试",避免连锁 429 浪费配额。 String lastMessage = conversationService.getLastMessage(conversationId); @@ -1418,11 +1468,14 @@ public class ChatController { || lastMessage.contains("429") || lastMessage.contains("速率限制"))) { log.warn("Skipping queued message after rate limit error: conversationId={}, lastMessage={}", conversationId, lastMessage.substring(0, Math.min(50, lastMessage.length()))); - // 持久化用户消息不丢失 - if (preConsumedInput.message() != null && !preConsumedInput.message().isBlank() - && !preConsumedInput.persisted()) { - conversationService.saveMessage(conversationId, "user", preConsumedInput.message()); + if (preConsumedInput.persistedMessageId() == null) { + MessageEntity saved = conversationService.saveMessage(conversationId, "user", + preConsumedInput.message(), preConsumedInput.contentParts(), "queued"); + if (saved != null) { + inputQueue.bindMessage(preConsumedInput.id(), queueClaimId, saved.getId(), LocalDateTime.now()); + } } + inputQueue.consume(preConsumedInput.id(), queueClaimId, LocalDateTime.now()); broadcastEvent(conversationId, "warning", Map.of( "message", "上一轮请求触发了频率限制,排队消息已保存,请稍后重新发送")); broadcastEvent(conversationId, "done", Map.of("status", "rate_limited")); @@ -1432,18 +1485,26 @@ public class ChatController { } String queuedMessage = preConsumedInput.message(); - Long agentId = preConsumedInput.agentId() != null ? preConsumedInput.agentId() : 1L; log.info("Starting queued message: conversationId={}, agentId={}, message={}", - conversationId, agentId, queuedMessage.substring(0, Math.min(30, queuedMessage.length()))); + conversationId, agentId, queuedMessage == null ? "" : queuedMessage.substring(0, Math.min(30, queuedMessage.length()))); // 持久化排队的用户消息(含 contentParts;幂等:如果 /interrupt 已提前持久化则跳过)。 // 这里持久化是为了确保 user 消息在 assistant 消息(doOnError/doOnCancel 已写入)之后落库, // 让 listMessages ORDER BY create_time ASC 后顺序正确:Q1 → Asst1 → Q2 → Asst2。 - Long queuedOriginMessageId = null; - if (queuedMessage != null && !queuedMessage.isBlank() && !preConsumedInput.persisted()) { + Long queuedOriginMessageId = preConsumedInput.persistedMessageId(); + if (queuedOriginMessageId == null) { MessageEntity savedUser = conversationService.saveMessage(conversationId, "user", queuedMessage, preConsumedInput.contentParts(), "queued"); queuedOriginMessageId = savedUser == null ? null : savedUser.getId(); + if (queuedOriginMessageId == null + || !inputQueue.bindMessage(preConsumedInput.id(), queueClaimId, + queuedOriginMessageId, LocalDateTime.now())) { + inputQueue.release(preConsumedInput.id(), queueClaimId, LocalDateTime.now()); + throw new IllegalStateException("Queued input could not be bound to its persisted message"); + } + } + if (!inputQueue.consume(preConsumedInput.id(), queueClaimId, LocalDateTime.now())) { + throw new IllegalStateException("Queued input claim was lost before execution"); } // 广播 queued_input_started 事件 @@ -1530,9 +1591,9 @@ public class ChatController { } finally { ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); if (cr.allDone()) { - if (cr.queuedInput() != null) { + if (shouldDrainQueuedInput(conversationId, persistStatus)) { // 链式续跑:queued stream 期间又排了新消息 - startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), requesterId, baseUrl); + startQueuedMessage(conversationId, emitter, emitterDone, requesterId, baseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); sseExecutor.execute(() -> { @@ -1576,8 +1637,8 @@ public class ChatController { } ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); if (cr.allDone()) { - if (cr.queuedInput() != null) { - startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), requesterId, baseUrl); + if (shouldDrainQueuedInput(conversationId, "failed")) { + startQueuedMessage(conversationId, emitter, emitterDone, requesterId, baseUrl); } else { conversationService.updateStreamStatus(conversationId, "idle"); completeEmitterQuietly(emitter, emitterDone); @@ -1590,6 +1651,10 @@ public class ChatController { () -> emergencySaveAccumulator(conversationId, accumulator)); } + private boolean hasQueuedInput(String conversationId) { + return inputQueue.countQueued(conversationId) > 0; + } + /** * Terminal error path for requests rejected before a stream is registered: * emit an {@code error} + terminal {@code done} pair and complete the @@ -1660,6 +1725,31 @@ public class ChatController { return "[本次没有输出]"; } + private boolean shouldDrainQueuedInput(String conversationId, String persistStatus) { + return shouldDrainQueuedInput( + persistStatus, + hasQueuedInput(conversationId), + approvalService.findPendingByConversation(conversationId) != null); + } + + private PendingApproval findRequestedPendingApproval(String conversationId, String pendingApprovalId) { + if (pendingApprovalId == null || pendingApprovalId.isBlank()) { + return approvalService.findPendingByConversation(conversationId); + } + return approvalService.getPending(pendingApprovalId) + .filter(pending -> conversationId.equals(pending.getConversationId())) + .filter(pending -> "pending".equals(pending.getStatus())) + .orElse(null); + } + + static boolean shouldDrainQueuedInput(String persistStatus, + boolean hasQueuedInput, + boolean hasPendingApproval) { + return hasQueuedInput + && !hasPendingApproval + && !"awaiting_approval".equals(persistStatus); + } + static boolean isAssistantPersisted(MessageEntity savedAssistant) { return savedAssistant != null; } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java index 7925c12d..122f30e0 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatStreamTracker.java @@ -190,8 +190,8 @@ public class ChatStreamTracker { /** 等待原因(审批等待时有值) */ volatile String waitingReason; - /** 排队的用户消息队列(支持多条排队消息,按序消费) */ - final java.util.Queue messageQueue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + /** Wake signal only; queued input payloads live in the database. */ + final AtomicBoolean queuedInputPending = new AtomicBoolean(false); /** * Emergency save callback registered by the SSE chain owner (ChatController). @@ -500,18 +500,7 @@ public class ChatStreamTracker { } if (current.done) { stopHeartbeat(current); - RunState nextState = new RunState(id); - int carried = 0; - QueuedInput queued; - while ((queued = current.messageQueue.poll()) != null) { - nextState.messageQueue.offer(queued); - carried++; - } - if (carried > 0) { - log.info("[ChatStreamTracker] Carried {} queued message(s) into next run: {}", - carried, id); - } - return nextState; + return new RunState(id); } // Registration is a fresh lifecycle entrance. Refresh every // stale-run input while holding the same lock cleanup uses to @@ -544,17 +533,41 @@ public class ChatStreamTracker { */ public void setDisposable(String conversationId, Disposable disposable) { RunState state = runs.get(conversationId); - if (state != null) { + if (state == null || disposable == null) return; + boolean disposeImmediately; + synchronized (state.lock) { + if (!isCurrent(state)) return; state.disposable = disposable; + // Stop can win before the asynchronous SSE setup has subscribed + // and registered its Disposable. Do not let that late subscription + // escape the cancellation request. + disposeImmediately = state.done || state.stopRequested.get(); + } + if (disposeImmediately) { + disposeSafely(conversationId, disposable); } } public void setDisposable(RunHandle handle, Disposable disposable) { - if (handle == null) return; + if (handle == null || disposable == null) return; RunState state = handle.state; + boolean disposeImmediately; synchronized (state.lock) { if (!isCurrent(state)) return; state.disposable = disposable; + disposeImmediately = state.done || state.stopRequested.get(); + } + if (disposeImmediately) { + disposeSafely(state.conversationId, disposable); + } + } + + private void disposeSafely(String conversationId, Disposable disposable) { + try { + disposable.dispose(); + } catch (Exception e) { + log.warn("Late stream disposable cancellation failed for {}: {}", + conversationId, e.getMessage()); } } @@ -630,8 +643,32 @@ public class ChatStreamTracker { * 取消 Flux 订阅(底层 HTTP 连接也会随之关闭),返回 true 表示确实停止了正在运行的流。 */ public boolean requestStop(String conversationId) { - RunState state = runs.get(conversationId); + // A goal may be between finite segments, with no live RunState to cancel. + // Persist the user's intent before looking up that ephemeral state. + try { + if (applicationContext != null) { + applicationContext.publishEvent(new vip.mate.goal.service.GoalExecutionSignal.Stop(conversationId)); + } + } catch (RuntimeException persistenceFailure) { + // Still cancel live work, but do not acknowledge a durable Stop that failed. + requestStopLive(conversationId); + throw persistenceFailure; + } + return requestStopLive(conversationId); + } + + private boolean requestStopLive(String conversationId) { + return requestStopLive(runs.get(conversationId)); + } + + /** Cancel only this generation, without publishing a new user Stop intent. */ + public boolean cancelRun(RunHandle handle) { + return handle != null && requestStopLive(handle.state); + } + + private boolean requestStopLive(RunState state) { if (state == null) return false; + String conversationId = state.conversationId; final boolean firstRequest; final Disposable d; @@ -714,11 +751,13 @@ public class ChatStreamTracker { /** * 广播事件到所有订阅者并缓存到 buffer. *

- * Two event categories survive {@code state.done=true}: + * Lifecycle event categories survive {@code state.done=true}: *

    *
  • {@code "done"} — the lifecycle marker itself. If a client missed * this on a broken pipe and reconnects within the 5-minute retention * window, replay surfaces it so the UI exits "生成中" state.
  • + *
  • {@code "goal_continuation"} — durable scheduling is settled after + * the graph segment completes, and remains available on reconnect.
  • *
  • {@code "async_task_*"} — task lifecycle events from * {@code AsyncTaskService} (image/video/music generation). These * routinely fire after the agent's reasoning turn finishes @@ -740,7 +779,8 @@ public class ChatStreamTracker { if (handle == null) return; RunState state = handle.state; boolean isDone = "done".equals(eventName); - boolean isAsyncTask = eventName != null && eventName.startsWith("async_task_"); + boolean isPostTurnEvent = "goal_continuation".equals(eventName) + || (eventName != null && eventName.startsWith("async_task_")); boolean isHeartbeat = "heartbeat".equals(eventName); List targets; long eventId = 0L; @@ -751,10 +791,10 @@ public class ChatStreamTracker { if (!isHeartbeat) { state.lastEventAt = System.currentTimeMillis(); } - if (!isDone && !isAsyncTask && !isHeartbeat && state.done) { + if (!isDone && !isPostTurnEvent && !isHeartbeat && state.done) { return; } - if ((isDone || isAsyncTask) || (!isHeartbeat && !skipBuffer)) { + if ((isDone || isPostTurnEvent) || (!isHeartbeat && !skipBuffer)) { eventId = EVENT_IDS.nextId(); state.buffer.add(new SseEvent(eventId, eventName, jsonData)); if (state.buffer.size() > MAX_BUFFER_SIZE) { @@ -762,7 +802,7 @@ public class ChatStreamTracker { } } targets = new ArrayList<>(state.subscribers); - forwardRelays = !isDone && !isAsyncTask && !isHeartbeat; + forwardRelays = !isDone && !isPostTurnEvent && !isHeartbeat; } List dead = new ArrayList<>(); @@ -816,7 +856,8 @@ public class ChatStreamTracker { RunState state = runs.get(conversationId); boolean isDone = "done".equals(eventName); - boolean isAsyncTask = eventName != null && eventName.startsWith("async_task_"); + boolean isPostTurnEvent = "goal_continuation".equals(eventName) + || (eventName != null && eventName.startsWith("async_task_")); boolean isHeartbeat = "heartbeat".equals(eventName); // Stamp last activity for stuck detection. Heartbeats are excluded @@ -826,7 +867,7 @@ public class ChatStreamTracker { state.lastEventAt = System.currentTimeMillis(); } - if (isDone || isAsyncTask) { + if (isDone || isPostTurnEvent) { if (state == null) return; synchronized (state.lock) { long id = EVENT_IDS.nextId(); @@ -850,7 +891,7 @@ public class ChatStreamTracker { } } } - // done events do not flow through eventRelays; async_task_* should + // done events do not flow through eventRelays; post-turn events should // also short-circuit since relays exist for delta-style streaming // events, not lifecycle markers. return; @@ -1244,10 +1285,8 @@ public class ChatStreamTracker { } } - /** - * 完成结果:包含是否全部完成、排队消息快照 - */ - public record CompletionResult(boolean allDone, QueuedInput queuedInput) {} + /** Completion result for the current in-memory stream generation. */ + public record CompletionResult(boolean allDone) {} /** * 标记一个 Flux 完成。仅在所有 Flux 都完成时才真正移除 RunState。 @@ -1313,22 +1352,19 @@ public class ChatStreamTracker { public CompletionResult completeAndConsumeIfLast(String conversationId) { RunState state = runs.get(conversationId); if (state == null) { - return new CompletionResult(true, null); + return new CompletionResult(true); } - QueuedInput consumed = null; ScheduledFuture oldHeartbeat; synchronized (state.lock) { if (!isCurrent(state)) { - return new CompletionResult(false, null); + return new CompletionResult(false); } state.activeFluxCount = Math.max(0, state.activeFluxCount - 1); if (state.activeFluxCount > 0) { - log.debug("Stream partially completed: {} (remaining flux={}, queuePreserved={})", - conversationId, state.activeFluxCount, !state.messageQueue.isEmpty()); - return new CompletionResult(false, null); + log.debug("Stream partially completed: {} (remaining flux={}, queuedInputPending={})", + conversationId, state.activeFluxCount, state.queuedInputPending.get()); + return new CompletionResult(false); } - // 最后一个 Flux:在同一个锁内消费排队消息(取队首) - consumed = state.messageQueue.poll(); state.done = true; state.cancellationHooks.clear(); state.termination.complete(null); @@ -1340,9 +1376,9 @@ public class ChatStreamTracker { if (oldHeartbeat != null) { oldHeartbeat.cancel(false); } - log.debug("Stream fully completed: {} (hasQueuedSnapshot={}, kept in map for {}ms reconnect window)", - conversationId, consumed != null, DONE_RETENTION_MS); - return new CompletionResult(true, consumed); + log.debug("Stream fully completed: {} (queuedInputPending={}, kept in map for {}ms reconnect window)", + conversationId, state.queuedInputPending.get(), DONE_RETENTION_MS); + return new CompletionResult(true); } /** @@ -1445,7 +1481,7 @@ public class ChatStreamTracker { "currentPhase", safe(state.currentPhase), "waitingReason", safe(state.waitingReason), "runningToolName", safe(state.runningToolName), - "queueLength", state.messageQueue.size(), + "queueLength", state.queuedInputPending.get() ? 1 : 0, "timestamp", System.currentTimeMillis() )); } catch (Exception e) { @@ -1590,8 +1626,7 @@ public class ChatStreamTracker { synchronized (state.lock) { Disposable d = state.disposable; canInterrupt = d != null && !d.isDisposed(); - // 无论是否可中断,都入队(支持多条排队消息) - state.messageQueue.offer(new QueuedInput(queuedMessage, agentId, persisted, contentParts)); + state.queuedInputPending.set(true); if (canInterrupt) { state.interruptType = InterruptType.USER_INTERRUPT_WITH_FOLLOWUP; state.stopRequested.set(true); @@ -1658,7 +1693,7 @@ public class ChatStreamTracker { if (state == null || state.done) { return false; } - state.messageQueue.offer(new QueuedInput(message, agentId, persisted, contentParts)); + state.queuedInputPending.set(true); // broadcast 在锁外 try { String json = objectMapper.writeValueAsString(Map.of( @@ -1688,9 +1723,7 @@ public class ChatStreamTracker { * 从队列头部取出一条消息。 */ public QueuedInput consumeQueuedInput(String conversationId) { - RunState state = runs.get(conversationId); - if (state == null) return null; - return state.messageQueue.poll(); + return null; } /** @@ -1734,7 +1767,7 @@ public class ChatStreamTracker { */ public boolean hasQueuedMessage(String conversationId) { RunState state = runs.get(conversationId); - return state != null && !state.messageQueue.isEmpty(); + return state != null && state.queuedInputPending.get(); } /** @@ -1742,7 +1775,20 @@ public class ChatStreamTracker { */ public int getQueueSize(String conversationId) { RunState state = runs.get(conversationId); - return state != null ? state.messageQueue.size() : 0; + return state != null && state.queuedInputPending.get() ? 1 : 0; + } + + /** Notify the live stream that durable queued input is ready to consume. */ + public boolean notifyQueuedInput(String conversationId) { + RunState state = runs.get(conversationId); + if (state == null || state.done) return false; + state.queuedInputPending.set(true); + return true; + } + + boolean hasQueuedInputNotification(String conversationId) { + RunState state = runs.get(conversationId); + return state != null && state.queuedInputPending.get(); } // ===== Approval idempotency ===== @@ -2172,7 +2218,7 @@ public class ChatStreamTracker { int queue; synchronized (s.lock) { subs = s.subscribers.size(); - queue = s.messageQueue.size(); + queue = s.queuedInputPending.get() ? 1 : 0; } out.add(new RunSnapshot( s.conversationId, diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ConversationInputQueueStore.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ConversationInputQueueStore.java new file mode 100644 index 00000000..7dfd820e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ConversationInputQueueStore.java @@ -0,0 +1,187 @@ +package vip.mate.channel.web; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import vip.mate.workspace.conversation.model.MessageContentPart; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** Database-backed FIFO for user input accepted while a conversation is busy. */ +@Repository +public class ConversationInputQueueStore { + private static final TypeReference> PARTS_TYPE = new TypeReference<>() {}; + + private final JdbcTemplate jdbc; + private final ObjectMapper mapper; + + public ConversationInputQueueStore(JdbcTemplate jdbc, ObjectMapper mapper) { + this.jdbc = jdbc; + this.mapper = mapper; + } + + public QueuedInput enqueue(String conversationId, Long agentId, String createdBy, + String message, List contentParts, + LocalDateTime now) { + long id = IdWorker.getId(); + jdbc.update(""" + INSERT INTO mate_conversation_input_queue( + id,conversation_id,agent_id,created_by,message,content_parts,state, + created_at,updated_at) + VALUES(?,?,?,?,?,?,'queued',?,?) + """, id, conversationId, agentId, createdBy, message == null ? "" : message, + writeParts(contentParts), now, now); + return get(id); + } + + public Optional claimNext(String conversationId, String attemptId, + LocalDateTime now) { + for (int tries = 0; tries < 8; tries++) { + List ids = jdbc.queryForList(""" + SELECT id FROM mate_conversation_input_queue + WHERE conversation_id=? AND state='queued' ORDER BY id LIMIT 1 + """, Long.class, conversationId); + if (ids.isEmpty()) return Optional.empty(); + long id = ids.getFirst(); + if (jdbc.update(""" + UPDATE mate_conversation_input_queue + SET state='claimed',claimed_by_attempt_id=?,updated_at=? + WHERE id=? AND state='queued' + """, attemptId, now, id) == 1) { + return Optional.of(get(id)); + } + } + return Optional.empty(); + } + + public boolean bindMessage(Long id, String attemptId, Long messageId, LocalDateTime now) { + return jdbc.update(""" + UPDATE mate_conversation_input_queue + SET persisted_message_id=COALESCE(persisted_message_id,?),updated_at=? + WHERE id=? AND claimed_by_attempt_id=? AND state='claimed' + """, messageId, now, id, attemptId) == 1; + } + + public boolean consume(Long id, String attemptId, LocalDateTime now) { + return jdbc.update(""" + UPDATE mate_conversation_input_queue SET state='consumed',updated_at=? + WHERE id=? AND claimed_by_attempt_id=? AND state='claimed' + """, now, id, attemptId) == 1; + } + + public boolean release(Long id, String attemptId, LocalDateTime now) { + return jdbc.update(""" + UPDATE mate_conversation_input_queue + SET state='queued',claimed_by_attempt_id=NULL,updated_at=? + WHERE id=? AND claimed_by_attempt_id=? AND state='claimed' + """, now, id, attemptId) == 1; + } + + public int releaseClaims(String attemptId,LocalDateTime now) { + return jdbc.update(""" + UPDATE mate_conversation_input_queue + SET state='queued',claimed_by_attempt_id=NULL,updated_at=? + WHERE claimed_by_attempt_id=? AND state='claimed' + """,now,attemptId); + } + + public int releaseClaimsBefore(LocalDateTime cutoff,LocalDateTime now) { + return jdbc.update(""" + UPDATE mate_conversation_input_queue + SET state='queued',claimed_by_attempt_id=NULL,updated_at=? + WHERE state='claimed' AND updated_at<=? + """,now,cutoff); + } + + public boolean cancel(Long id, String reason, LocalDateTime now) { + return jdbc.update(""" + UPDATE mate_conversation_input_queue + SET state='cancelled',cancel_reason=?,updated_at=? + WHERE id=? AND state='queued' + """, bounded(reason), now, id) == 1; + } + + public QueuedInput get(Long id) { + List rows = jdbc.query(""" + SELECT * FROM mate_conversation_input_queue WHERE id=? + """, (rs, row) -> read(rs), id); + return rows.isEmpty() ? null : rows.getFirst(); + } + + public List listQueued(String conversationId) { + return jdbc.query(""" + SELECT * FROM mate_conversation_input_queue + WHERE conversation_id=? AND state='queued' ORDER BY id + """, (rs, row) -> read(rs), conversationId); + } + + public int countQueued(String conversationId) { + Integer count = jdbc.queryForObject(""" + SELECT COUNT(*) FROM mate_conversation_input_queue + WHERE conversation_id=? AND state='queued' + """, Integer.class, conversationId); + return count == null ? 0 : count; + } + + private QueuedInput read(ResultSet rs) throws SQLException { + return new QueuedInput(rs.getLong("id"), rs.getString("conversation_id"), + nullableLong(rs, "agent_id"), rs.getString("created_by"), + rs.getString("message"), readParts(rs.getString("content_parts")), + rs.getString("state"), rs.getString("claimed_by_attempt_id"), + nullableLong(rs, "persisted_message_id"), rs.getString("cancel_reason"), + time(rs, "created_at"), time(rs, "updated_at")); + } + + private String writeParts(List parts) { + try { + return mapper.writeValueAsString(parts == null ? List.of() : parts); + } catch (JsonProcessingException error) { + throw new IllegalArgumentException("Queued input contains invalid content parts", error); + } + } + + private List readParts(String json) { + if (json == null || json.isBlank()) return List.of(); + try { + return mapper.readValue(json, PARTS_TYPE); + } catch (JsonProcessingException error) { + throw new IllegalStateException("Persisted queued input contains invalid content parts", error); + } + } + + private static LocalDateTime time(ResultSet rs, String column) throws SQLException { + Timestamp value = rs.getTimestamp(column); + return value == null ? null : value.toLocalDateTime(); + } + + private static Long nullableLong(ResultSet rs, String column) throws SQLException { + long value = rs.getLong(column); + return rs.wasNull() ? null : value; + } + + private static String bounded(String text) { + return text == null ? null : text.substring(0, Math.min(128, text.length())); + } + + public record QueuedInput( + Long id, + String conversationId, + Long agentId, + String createdBy, + String message, + List contentParts, + String state, + String claimedByAttemptId, + Long persistedMessageId, + String cancelReason, + LocalDateTime createdAt, + LocalDateTime updatedAt) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java index 826e7592..b310ee38 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java @@ -100,15 +100,13 @@ public class SecurityConfig { // KB Open API: authenticated by KbOpenApiAuthFilter (API key), // not JWT — must be permitAll so the filter is the sole gatekeeper (R1). "/api/v1/open/kb/**", + "/api/a2a/card", + "/.well-known/agent-card.json", "/api/v1/talk/ws", // Desktop local-tool tunnel — the handshake interceptor // authenticates the ?token= query param itself, so the // upgrade request is opened to the filter chain like talk/ws. - "/api/v1/desktop/ws", - // RFC-045: tool-generated files served via unguessable UUID; entries - // expire after GeneratedFileCache.TTL (7 days) — delayed access (e.g. an - // IM-delivered link opened later) is intentional, the UUID is the guard. - "/api/v1/files/generated/**" + "/api/v1/desktop/ws" ).permitAll(); // Swagger UI / OpenAPI document — explicit rule rather than the // permitAll() fallthrough. Public for local dev, admin-only in diff --git a/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java b/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java index 6909e9ef..564487ac 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java @@ -41,6 +41,9 @@ public class GoalProperties { */ private boolean defaultAutoFollowup = true; + /** Create-time default only; existing goals retain their persisted mode. */ + private boolean defaultPersistentExecution = true; + /** * Runtime hard gate for auto-followup. When false, no goal injects a * follow-up regardless of its per-goal {@code autoFollowupEnabled} flag — @@ -49,6 +52,27 @@ public class GoalProperties { */ private boolean allowAutoFollowup = true; + /** Maximum number of persistent goal segments executing in this backend instance. */ + private int maxConcurrentSegments = 4; + + public void setMaxConcurrentSegments(int maxConcurrentSegments) { + this.maxConcurrentSegments = Math.max(1, maxConcurrentSegments); + } + + /** Runtime floor between two ordinary persistent-goal segments. */ + private int minimumContinuationIntervalSeconds = 1; + + public void setMinimumContinuationIntervalSeconds(int minimumContinuationIntervalSeconds) { + this.minimumContinuationIntervalSeconds = Math.max(1, minimumContinuationIntervalSeconds); + } + + /** Instance-wide pause before claiming more work after a retryable provider failure. */ + private int providerFailureGlobalBackoffSeconds = 30; + + public void setProviderFailureGlobalBackoffSeconds(int providerFailureGlobalBackoffSeconds) { + this.providerFailureGlobalBackoffSeconds = Math.max(0, providerFailureGlobalBackoffSeconds); + } + /** * Auto-derive a goal from a multi-step Plan-Execute plan. The Plan-Execute * planner decomposes the request into steps and the step executor is a diff --git a/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalExecutionController.java b/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalExecutionController.java new file mode 100644 index 00000000..712106ea --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalExecutionController.java @@ -0,0 +1,56 @@ +package vip.mate.goal.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import vip.mate.common.result.R; +import vip.mate.exception.MateClawException; +import vip.mate.goal.service.GoalContinuationStore; +import vip.mate.goal.service.GoalAttemptStore; +import vip.mate.goal.model.GoalAttempt; +import vip.mate.goal.service.GoalService; +import vip.mate.workspace.conversation.ConversationService; + +/** Durable execution status, separate from goal acceptance status. */ +@RestController +@RequiredArgsConstructor +public class GoalExecutionController { + private final GoalService goals; + private final GoalContinuationStore store; + private final ConversationService conversations; + private final GoalAttemptStore attempts; + + @GetMapping("/api/v1/goals/{id}/execution") + public R execution(@PathVariable Long id, Authentication auth) { + authorize(id,auth); + return R.ok(store.get(id)); + } + + @GetMapping("/api/v1/goals/{id}/execution/attempts") + public R> attempts(@PathVariable Long id,Authentication auth) { + authorize(id,auth); + return R.ok(attempts.listRecent(id,50).stream().map(AttemptView::from).toList()); + } + + private void authorize(Long id,Authentication auth) { + var goal=goals.getById(id); + if(auth==null || !conversations.isConversationOwner(goal.getConversationId(),auth.getName())) { + throw new MateClawException("err.goal.forbidden",403,"Not the conversation owner"); + } + } + + public record AttemptView(String id,String parentAttemptId,String triggerType,String state, + Long inputItemId,Long assistantMessageId,String replaySafety, + String checkpointType,String finishReason,String errorCategory, + java.time.LocalDateTime startedAt,java.time.LocalDateTime finishedAt, + java.time.LocalDateTime createdAt,java.time.LocalDateTime updatedAt) { + static AttemptView from(GoalAttempt attempt) { + return new AttemptView(attempt.id(),attempt.parentAttemptId(),attempt.triggerType(),attempt.state(), + attempt.inputItemId(),attempt.assistantMessageId(),attempt.replaySafety(),attempt.checkpointType(), + attempt.finishReason(),attempt.errorCategory(),attempt.startedAt(),attempt.finishedAt(), + attempt.createdAt(),attempt.updatedAt()); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalAttempt.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalAttempt.java new file mode 100644 index 00000000..31198535 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalAttempt.java @@ -0,0 +1,33 @@ +package vip.mate.goal.model; + +import java.time.LocalDateTime; +import java.util.Set; + +/** One immutable-identity execution attempt for a bounded goal segment. */ +public record GoalAttempt( + String id, + Long goalId, + String conversationId, + String parentAttemptId, + String triggerType, + String state, + String leaseToken, + LocalDateTime leaseUntil, + Long inputItemId, + Long assistantMessageId, + String replaySafety, + String checkpointType, + String finishReason, + String errorCategory, + LocalDateTime startedAt, + LocalDateTime finishedAt, + LocalDateTime createdAt, + LocalDateTime updatedAt) { + + private static final Set TERMINAL_STATES = + Set.of("succeeded", "retryable", "blocked", "cancelled"); + + public boolean terminal() { + return TERMINAL_STATES.contains(state); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalContinuationDecision.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalContinuationDecision.java new file mode 100644 index 00000000..6b8a32bf --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalContinuationDecision.java @@ -0,0 +1,8 @@ +package vip.mate.goal.model; + +import java.time.LocalDateTime; + +/** Explicit continuation outcome shared by graph compatibility and durable scheduling. */ +public record GoalContinuationDecision(Action action, String prompt, LocalDateTime nextRunAt, String reason) { + public enum Action { CONTINUE, DEFER, DISABLED, COMPLETE, BUDGET_LIMITED, RETRY } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java index fc084d98..78f884bf 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java @@ -8,8 +8,8 @@ import java.util.List; * Request body for {@code POST /api/v1/goals}. * *

    Only {@code conversationId}, {@code agentId}, {@code workspaceId} and - * {@code title} are mandatory. Budgets default to the values in - * {@link vip.mate.goal.config.GoalProperties}. + * {@code title} are mandatory. Persistent goals default to unlimited budgets + * (zero); legacy goals use {@link vip.mate.goal.config.GoalProperties} defaults. * *

    ID fields stay as {@code Long} on the wire (Jackson accepts both * numeric and string forms via the project's default coercion), but the @@ -28,6 +28,9 @@ public class GoalCreateRequest { private String exitCriteria; private String successCheckPrompt; + /** Opts into durable continuation; zero budgets mean unlimited only in this mode. */ + private Boolean persistentExecution; + private Integer turnBudget; private Integer llmCallBudget; private Boolean autoFollowupEnabled; diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java index bbff046e..a1c89e46 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java @@ -67,7 +67,10 @@ public class GoalEntity { */ private GoalStatus status; - /** Maximum evaluation turns before exhaustion. */ + /** Opts into durable continuation; zero budgets mean unlimited only in this mode. */ + private Boolean persistentExecution; + + /** Maximum evaluation turns; zero is unlimited only for persistent execution. */ private Integer turnBudget; /** Cumulative turns evaluated; bumped by GoalEvaluationNode. */ diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalResponse.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalResponse.java index ea272710..d45f4ecf 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalResponse.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalResponse.java @@ -30,6 +30,9 @@ public class GoalResponse { private GoalStatus status; + /** Opts into durable continuation; zero budgets mean unlimited only in this mode. */ + private Boolean persistentExecution; + private Integer turnBudget; private Integer turnsUsed; private Integer llmCallBudget; diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalUpdateRequest.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalUpdateRequest.java index 1f6b1f5f..5d0a9d33 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalUpdateRequest.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalUpdateRequest.java @@ -18,6 +18,9 @@ public class GoalUpdateRequest { private String exitCriteria; private String successCheckPrompt; + /** Opts into durable continuation; zero budgets mean unlimited only in this mode. */ + private Boolean persistentExecution; + private Integer turnBudget; private Integer llmCallBudget; private Boolean autoFollowupEnabled; diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/SegmentOutcome.java b/mateclaw-server/src/main/java/vip/mate/goal/model/SegmentOutcome.java new file mode 100644 index 00000000..e860f8fe --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/SegmentOutcome.java @@ -0,0 +1,20 @@ +package vip.mate.goal.model; + +/** Durable scheduling facts returned by one bounded goal segment. */ +public sealed interface SegmentOutcome { + String reason(); + + default String finishReason() { return reason(); } + default boolean awaitingApproval() { return this instanceof AwaitApproval; } + default boolean evaluationUnavailable() { return this instanceof Retry retry + && "evaluation".equals(retry.category()); } + + record Continue(String reason) implements SegmentOutcome {} + record Defer(String reason, java.time.LocalDateTime nextRunAt) implements SegmentOutcome {} + record Complete(String reason) implements SegmentOutcome {} + record AwaitApproval(String reason) implements SegmentOutcome {} + record WaitInput(String reason) implements SegmentOutcome {} + record Retry(String category, String reason) implements SegmentOutcome {} + record Blocked(String category, String reason) implements SegmentOutcome {} + record Cancelled(String reason) implements SegmentOutcome {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalAttemptStore.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalAttemptStore.java new file mode 100644 index 00000000..b0289a37 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalAttemptStore.java @@ -0,0 +1,132 @@ +package vip.mate.goal.service; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import vip.mate.goal.model.GoalAttempt; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +/** Fenced persistence for bounded goal execution attempts. */ +@Repository +public class GoalAttemptStore { + private final JdbcTemplate jdbc; + + public GoalAttemptStore(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public GoalAttempt create(Long goalId, String conversationId, String parentAttemptId, + String triggerType, String leaseToken, LocalDateTime leaseUntil, + Long inputItemId, LocalDateTime now) { + String id = UUID.randomUUID().toString(); + jdbc.update(""" + INSERT INTO mate_goal_attempt( + attempt_id,goal_id,conversation_id,parent_attempt_id,trigger_type,state, + lease_token,lease_until,input_item_id,replay_safety,checkpoint_type, + created_at,updated_at) + VALUES(?,?,?,?,?,'claimed',?,?,?,'safe','claimed',?,?) + """, id, goalId, conversationId, parentAttemptId, triggerType, leaseToken, + leaseUntil, inputItemId, now, now); + return get(id); + } + + public GoalAttempt get(String id) { + List rows = jdbc.query(""" + SELECT * FROM mate_goal_attempt WHERE attempt_id=? + """, (rs, row) -> read(rs), id); + return rows.isEmpty() ? null : rows.getFirst(); + } + + public List listRecent(Long goalId, int limit) { + return jdbc.query(""" + SELECT * FROM mate_goal_attempt WHERE goal_id=? + ORDER BY created_at DESC,attempt_id DESC LIMIT ? + """, (rs, row) -> read(rs), goalId, Math.max(1, Math.min(limit, 100))); + } + + public boolean markRunning(String id, String leaseToken, LocalDateTime now) { + return jdbc.update(""" + UPDATE mate_goal_attempt SET state='running',started_at=?,updated_at=? + WHERE attempt_id=? AND lease_token=? AND state='claimed' + """, now, now, id, leaseToken) == 1; + } + + public boolean renew(String id, String leaseToken, LocalDateTime leaseUntil, + LocalDateTime now) { + return jdbc.update(""" + UPDATE mate_goal_attempt SET lease_until=?,updated_at=? + WHERE attempt_id=? AND lease_token=? AND state IN ('claimed','running') + """, leaseUntil, now, id, leaseToken) == 1; + } + + public boolean checkpoint(String id, String leaseToken, String replaySafety, + String checkpointType, Long assistantMessageId, + LocalDateTime now) { + return jdbc.update(""" + UPDATE mate_goal_attempt + SET replay_safety=?,checkpoint_type=?, + assistant_message_id=COALESCE(?,assistant_message_id),updated_at=? + WHERE attempt_id=? AND lease_token=? AND state IN ('claimed','running') + """, replaySafety, checkpointType, assistantMessageId, now, id, leaseToken) == 1; + } + + public boolean finish(String id, String leaseToken, String state, String finishReason, + String errorCategory, LocalDateTime now) { + if (!GoalAttemptTerminalState.valid(state)) { + throw new IllegalArgumentException("Unsupported terminal attempt state: " + state); + } + return jdbc.update(""" + UPDATE mate_goal_attempt + SET state=?,finish_reason=?,error_category=?,finished_at=?,updated_at=? + WHERE attempt_id=? AND lease_token=? AND state IN ('claimed','running') + """, state, bounded(finishReason), bounded(errorCategory), now, now, + id, leaseToken) == 1; + } + + public List expired(LocalDateTime now, int limit) { + return jdbc.query(""" + SELECT * FROM mate_goal_attempt + WHERE state IN ('claimed','running') AND lease_until<=? + ORDER BY lease_until,created_at LIMIT ? + """, (rs, row) -> read(rs), now, Math.max(1, Math.min(limit, 100))); + } + + private static GoalAttempt read(ResultSet rs) throws SQLException { + return new GoalAttempt( + rs.getString("attempt_id"), rs.getLong("goal_id"), + rs.getString("conversation_id"), rs.getString("parent_attempt_id"), + rs.getString("trigger_type"), rs.getString("state"), + rs.getString("lease_token"), time(rs, "lease_until"), + nullableLong(rs, "input_item_id"), nullableLong(rs, "assistant_message_id"), + rs.getString("replay_safety"), rs.getString("checkpoint_type"), + rs.getString("finish_reason"), rs.getString("error_category"), + time(rs, "started_at"), time(rs, "finished_at"), + time(rs, "created_at"), time(rs, "updated_at")); + } + + private static LocalDateTime time(ResultSet rs, String column) throws SQLException { + Timestamp value = rs.getTimestamp(column); + return value == null ? null : value.toLocalDateTime(); + } + + private static Long nullableLong(ResultSet rs, String column) throws SQLException { + long value = rs.getLong(column); + return rs.wasNull() ? null : value; + } + + private static String bounded(String text) { + return text == null ? null : text.substring(0, Math.min(128, text.length())); + } + + private static final class GoalAttemptTerminalState { + private static boolean valid(String state) { + return "succeeded".equals(state) || "retryable".equals(state) + || "blocked".equals(state) || "cancelled".equals(state); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalContinuationStore.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalContinuationStore.java new file mode 100644 index 00000000..034e32e2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalContinuationStore.java @@ -0,0 +1,182 @@ +package vip.mate.goal.service; + +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; + +/** Durable scheduling state. Every worker write is fenced by its unique lease token. */ +@Repository +public class GoalContinuationStore { + private final JdbcTemplate jdbc; + private static final String ELIGIBLE = """ + g.status='active' AND g.deleted=0 AND g.persistent_execution=TRUE + AND g.auto_followup_enabled=TRUE + """; + private static final String DUE = """ + ((c.state IN ('queued','retry') AND c.next_run_at<=?) + OR (c.state='running' AND c.lease_until<=?)) + """; + + public GoalContinuationStore(JdbcTemplate jdbc) { this.jdbc = jdbc; } + + public record Continuation(Long goalId, String conversationId, String state, + LocalDateTime nextRunAt, String leaseOwner, + LocalDateTime leaseUntil, int failures, String reason, + String currentAttemptId, long revision) { + public Continuation(Long goalId, String conversationId, String state, + LocalDateTime nextRunAt, String leaseOwner, + LocalDateTime leaseUntil, int failures, String reason) { + this(goalId, conversationId, state, nextRunAt, leaseOwner, leaseUntil, + failures, reason, null, 0); + } + } + + public void discover(LocalDateTime now) { + // Bounded discovery; another instance may insert the same goal concurrently. + List ids = jdbc.queryForList(""" + SELECT g.id FROM mate_agent_goal g WHERE + """ + ELIGIBLE + """ + AND NOT EXISTS(SELECT 1 FROM mate_goal_continuation c WHERE c.goal_id=g.id) + ORDER BY g.id LIMIT 100 + """, Long.class); + for (Long id : ids) { + try { + jdbc.update(""" + INSERT INTO mate_goal_continuation(goal_id,state,next_run_at,failures,reason,updated_at) + VALUES(?,'queued',?,0,'goal_active',?) + """, id, now, now); + } catch (DuplicateKeyException ignored) { /* the other instance owns discovery */ } + } + } + + public List due(LocalDateTime now, int limit) { + return jdbc.query(""" + SELECT c.*,g.conversation_id FROM mate_goal_continuation c + JOIN mate_agent_goal g ON g.id=c.goal_id WHERE + """ + ELIGIBLE + " AND " + DUE + " ORDER BY c.next_run_at,c.goal_id LIMIT ?", + (rs, row) -> read(rs), now, now, Math.max(1, Math.min(limit, 100))); + } + + public Continuation get(Long goalId) { + List rows = jdbc.query(""" + SELECT c.*,g.conversation_id FROM mate_goal_continuation c + JOIN mate_agent_goal g ON g.id=c.goal_id WHERE c.goal_id=? + """, (rs, row) -> read(rs), goalId); + return rows.isEmpty() ? null : rows.getFirst(); + } + + public boolean claim(Long goalId, String token, LocalDateTime now, LocalDateTime until) { + return jdbc.update(""" + UPDATE mate_goal_continuation SET state='running',lease_owner=?,lease_until=?,updated_at=?, + wake_requested=FALSE,revision=revision+1 + WHERE goal_id=? AND + ((state IN ('queued','retry') AND next_run_at<=?) + OR (state='running' AND lease_until<=?)) + AND EXISTS(SELECT 1 FROM mate_agent_goal g WHERE g.id=goal_id AND + """ + ELIGIBLE + ")", token, until, now, goalId, now, now) == 1; + } + + public boolean renew(Long goalId, String token, LocalDateTime until) { + return jdbc.update(""" + UPDATE mate_goal_continuation SET lease_until=? + WHERE goal_id=? AND lease_owner=? AND state='running' + """, until, goalId, token) == 1; + } + + public boolean bindAttempt(Long goalId, String token, String attemptId, long expectedRevision) { + return jdbc.update(""" + UPDATE mate_goal_continuation SET current_attempt_id=?,revision=revision+1,updated_at=? + WHERE goal_id=? AND lease_owner=? AND state='running' + AND current_attempt_id IS NULL AND revision=? + """, attemptId, LocalDateTime.now(), goalId, token, expectedRevision) == 1; + } + + public boolean matchesFence(Long goalId, String token, String attemptId, long revision) { + Integer count=jdbc.queryForObject(""" + SELECT COUNT(*) FROM mate_goal_continuation + WHERE goal_id=? AND lease_owner=? AND current_attempt_id=? + AND revision=? AND state='running' + """,Integer.class,goalId,token,attemptId,revision); + return count!=null && count==1; + } + + public boolean renewFenced(Long goalId,String token,String attemptId,long revision,LocalDateTime until) { + return jdbc.update(""" + UPDATE mate_goal_continuation SET lease_until=?,updated_at=? + WHERE goal_id=? AND lease_owner=? AND current_attempt_id=? + AND revision=? AND state='running' + """,until,LocalDateTime.now(),goalId,token,attemptId,revision)==1; + } + + public boolean settleFenced(Long goalId,String token,String attemptId,long revision,String state, + LocalDateTime nextRunAt,int failures,String reason,LocalDateTime now) { + return jdbc.update(""" + UPDATE mate_goal_continuation + SET state=CASE WHEN ?='waiting_approval' AND wake_requested=TRUE THEN 'queued' ELSE ? END, + next_run_at=?,failures=?,reason=?,wake_requested=FALSE,lease_owner=NULL,lease_until=NULL, + current_attempt_id=NULL,revision=revision+1,updated_at=? + WHERE goal_id=? AND lease_owner=? AND current_attempt_id=? AND revision=? AND state='running' + """,state,state,nextRunAt,failures,bounded(reason),now,goalId,token,attemptId,revision)==1; + } + + public boolean recoverExpired(Long goalId,String token,String attemptId,LocalDateTime expiredAt, + String state,LocalDateTime nextRunAt,int failures,String reason,LocalDateTime now) { + return jdbc.update(""" + UPDATE mate_goal_continuation + SET state=?,next_run_at=?,failures=?,reason=?,wake_requested=FALSE, + lease_owner=NULL,lease_until=NULL,current_attempt_id=NULL,revision=revision+1,updated_at=? + WHERE goal_id=? AND lease_owner=? AND current_attempt_id=? AND state='running' + AND lease_until<=? + """,state,nextRunAt,failures,bounded(reason),now,goalId,token,attemptId,expiredAt)==1; + } + + public boolean settle(Long goalId, String token, String state, LocalDateTime nextRunAt, + int failures, String reason) { + return jdbc.update(""" + UPDATE mate_goal_continuation SET state=CASE WHEN ?='waiting_approval' AND wake_requested=TRUE THEN 'queued' ELSE ? END, + next_run_at=?,failures=?,reason=?,wake_requested=FALSE,lease_owner=NULL,lease_until=NULL,updated_at=? + WHERE goal_id=? AND lease_owner=? AND state='running' + """, state, state, nextRunAt, failures, bounded(reason), LocalDateTime.now(), goalId, token) == 1; + } + + public void suspendConversation(String conversationId, String reason) { + jdbc.update(""" + UPDATE mate_goal_continuation SET state='paused',reason=?,lease_owner=NULL,lease_until=NULL, + current_attempt_id=NULL,revision=revision+1,updated_at=? + WHERE goal_id IN (SELECT id FROM mate_agent_goal WHERE conversation_id=?) + """, bounded(reason), LocalDateTime.now(), conversationId); + } + + public void resume(Long goalId, LocalDateTime now) { + jdbc.update(""" + UPDATE mate_goal_continuation SET state='queued',next_run_at=?,failures=0,reason='resumed', + lease_owner=NULL,lease_until=NULL,current_attempt_id=NULL,revision=revision+1,updated_at=? + WHERE goal_id=? AND state<>'running' + """, now, now, goalId); + } + + public void turnFinished(String conversationId, LocalDateTime now) { + jdbc.update(""" + UPDATE mate_goal_continuation SET state=CASE WHEN state='waiting_approval' THEN 'queued' ELSE state END, + wake_requested=TRUE,next_run_at=?,reason='interactive_turn_finished',revision=revision+1,updated_at=? + WHERE state IN ('waiting_approval','running') AND goal_id IN + (SELECT id FROM mate_agent_goal WHERE conversation_id=? AND status='active' AND deleted=0) + """,now,now,conversationId); + } + + private static Continuation read(java.sql.ResultSet rs) throws java.sql.SQLException { + Timestamp until = rs.getTimestamp("lease_until"); + return new Continuation(rs.getLong("goal_id"), rs.getString("conversation_id"), rs.getString("state"), + rs.getTimestamp("next_run_at").toLocalDateTime(), rs.getString("lease_owner"), + until == null ? null : until.toLocalDateTime(), rs.getInt("failures"), rs.getString("reason"), + rs.getString("current_attempt_id"),rs.getLong("revision")); + } + + private static String bounded(String text) { + return text == null ? "" : text.substring(0, Math.min(1000, text.length())); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalContinuationSupervisor.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalContinuationSupervisor.java new file mode 100644 index 00000000..a7f7b993 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalContinuationSupervisor.java @@ -0,0 +1,231 @@ +package vip.mate.goal.service; + +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import vip.mate.agent.runtime.RunningConversationRegistry; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.goal.config.GoalProperties; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalEvaluationResult; +import vip.mate.goal.model.GoalStatus; +import vip.mate.goal.model.SegmentOutcome; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; + +/** Owns cross-turn liveness. Graph recursion limits bound segments, not goal lifetime. */ +@Slf4j +@Component +public class GoalContinuationSupervisor { + private final GoalContinuationStore store; + private final GoalService goals; + private final GoalProperties properties; + private final GoalFollowupService followups; + private final GoalSegmentRunner runner; + private final RunningConversationRegistry running; + private final ChatStreamTracker streams; + private final Clock clock; + private final Executor executor; + private final GoalRunCoordinator coordinator; + private final GoalRecoveryService recovery; + private final ConcurrentHashMap active = new ConcurrentHashMap<>(); + private final AtomicReference providerBackoffUntil = new AtomicReference<>(); + private volatile boolean closing; + + @Autowired + public GoalContinuationSupervisor(GoalContinuationStore store, GoalService goals, GoalProperties properties, + GoalFollowupService followups, GoalSegmentRunner runner, RunningConversationRegistry running, + ChatStreamTracker streams,GoalRunCoordinator coordinator,GoalRecoveryService recovery) { + this(store,goals,properties,followups,runner,running,streams,coordinator,recovery,Clock.systemDefaultZone(), + Executors.newVirtualThreadPerTaskExecutor()); + } + + GoalContinuationSupervisor(GoalContinuationStore store, GoalService goals, GoalProperties properties, + GoalFollowupService followups, GoalSegmentRunner runner, RunningConversationRegistry running, + ChatStreamTracker streams,GoalRunCoordinator coordinator,GoalRecoveryService recovery, + Clock clock, Executor executor) { + this.store=store; this.goals=goals; this.properties=properties; this.followups=followups; + this.runner=runner; this.running=running; this.streams=streams; this.coordinator=coordinator;this.recovery=recovery; + this.clock=clock; this.executor=executor; + } + + @Scheduled(fixedDelayString="${mateclaw.goal.supervisor-poll-ms:5000}", initialDelayString="${mateclaw.goal.supervisor-poll-ms:5000}") + public void tick() { + if (closing || !properties.isEnabled() || !properties.isAllowAutoFollowup()) return; + LocalDateTime now = LocalDateTime.now(clock); + recovery.recoverExpired(now); + active.forEach((id, claimed) -> { + GoalEntity goal = goals.getById(id); + boolean cancelled = goal.getStatus()==GoalStatus.PAUSED || goal.getStatus()==GoalStatus.ABANDONED + || !Boolean.TRUE.equals(goal.getAutoFollowupEnabled()); + if (cancelled || !coordinator.renew(claimed,now)) runner.cancel(id); + }); + LocalDateTime backoffUntil=providerBackoffUntil.get(); + if (backoffUntil!=null && now.isBefore(backoffUntil)) return; + store.discover(now); + int maxConcurrent = properties.getMaxConcurrentSegments(); + // Scan beyond the execution capacity: a due conversation may currently + // belong to an interactive user turn and must not starve later goals. + for (var candidate : store.due(now, Math.max(20,maxConcurrent))) { + if (active.size() >= maxConcurrent) break; + String conv = candidate.conversationId(); + if (active.containsKey(candidate.goalId()) || running.isActive(conv) + || streams.isRunning(conv)) continue; + GoalEntity goal = goals.getById(candidate.goalId()); + if (!eligible(goal)) continue; + if (active.containsKey(goal.getId())) continue; + GoalRunCoordinator.ClaimedRun claimed=coordinator.claim(candidate,goal,now); + if(claimed==null || active.putIfAbsent(goal.getId(),claimed)!=null) continue; + try { + executor.execute(() -> execute(claimed)); + } catch (RuntimeException error) { + active.remove(goal.getId(),claimed); + settle(claimed,new SegmentOutcome.Retry("dispatch","dispatch_failed"),now); + log.warn("Goal {} dispatch failed",goal.getId(),error); + } + } + } + + private void execute(GoalRunCoordinator.ClaimedRun claimed) { + GoalEntity initial=claimed.goal(); + LocalDateTime now = LocalDateTime.now(clock); + try { + if (closing) return; + GoalEntity goal = goals.getById(initial.getId()); + if (!eligible(goal)) { + settle(claimed,new SegmentOutcome.Cancelled("goal_not_runnable"),now); return; + } + var decision = followups.decide(goal,new GoalEvaluationResult(0,goal.getProgressSummary(), + GoalEvaluationResult.DECISION_CONTINUE,false,"",0,0,List.of(),null),now); + switch (decision.action()) { + case DEFER, RETRY -> { + settle(claimed,new SegmentOutcome.Defer(decision.reason(),decision.nextRunAt()),now); return; + } + case BUDGET_LIMITED -> { + goals.markExhausted(goal.getId(),decision.reason()); + settle(claimed,new SegmentOutcome.Cancelled(decision.reason()),now); return; + } + case COMPLETE, DISABLED -> { + settle(claimed,new SegmentOutcome.Cancelled(decision.reason()),now); return; + } + case CONTINUE -> { } + } + if(!coordinator.markRunning(claimed,now)) return; + SegmentOutcome outcome = runner.run(claimed,decision.prompt(),"running".equals(claimed.candidate().state())); + if (outcome instanceof SegmentOutcome.Retry retry + && ("provider".equals(retry.category()) || "evaluation".equals(retry.category()))) { + activateProviderBackoff(LocalDateTime.now(clock)); + } + // Shutdown cancellation is not user Stop: retain the lease for recovery. + if (closing) return; + settle(claimed,outcome,LocalDateTime.now(clock)); + } catch (RuntimeException error) { + // A shutdown/lost-lease cancellation is not a task failure. Leave the + // running lease for restart recovery; the runner saves partial evidence. + if (closing || Thread.currentThread().isInterrupted()) return; + boolean transientError = retryable(error); + if (transientError) activateProviderBackoff(now); + if (!transientError) { + GoalEntity fresh=goals.getById(initial.getId()); + if (eligible(fresh)) goals.pause(fresh.getId(),fresh.getCreatedBy()); + } + settle(claimed,transientError + ? new SegmentOutcome.Retry("provider","transient_provider_error") + : new SegmentOutcome.Blocked("execution","execution_requires_review"),now); + log.warn("Goal {} segment failed ({})",initial.getId(),transientError ? "retry" : "blocked",error); + } finally { + active.remove(initial.getId(),claimed); + } + } + + private void activateProviderBackoff(LocalDateTime now) { + int seconds=properties.getProviderFailureGlobalBackoffSeconds(); + if (seconds<=0) return; + LocalDateTime proposed=now.plusSeconds(seconds); + LocalDateTime effective=providerBackoffUntil.updateAndGet(current -> + current==null || current.isBefore(proposed) ? proposed : current); + log.warn("Goal dispatch paused until {} after retryable provider failure",effective); + } + + private void settle(GoalRunCoordinator.ClaimedRun claimed,SegmentOutcome outcome,LocalDateTime now) { + if (coordinator.settle(claimed,outcome,now)) { + streams.broadcastObject(claimed.goal().getConversationId(),"goal_continuation",store.get(claimed.goal().getId())); + } + } + + private static boolean eligible(GoalEntity goal) { + return goal != null && goal.getStatus()==GoalStatus.ACTIVE + && Boolean.TRUE.equals(goal.getPersistentExecution()) && Boolean.TRUE.equals(goal.getAutoFollowupEnabled()); + } + + static boolean retryable(Throwable error) { + for (Throwable e=error; e!=null; e=e.getCause()) { + if (e instanceof java.io.IOException || e instanceof java.util.concurrent.TimeoutException + || e instanceof org.springframework.web.client.ResourceAccessException) return true; + if (e instanceof org.springframework.web.client.RestClientResponseException response) { + int code = response.getStatusCode().value(); + return code==408 || code==429 || code>=500; + } + if (e instanceof vip.mate.exception.MateClawException mate && mate.getCode()==409) return true; + } + return false; + } + + @EventListener + public void stopped(GoalExecutionSignal.Stop event) { + runner.stopConversation(event.conversationId()); + GoalEntity goal = goals.findActiveByConversation(event.conversationId()); + if (goal != null && Boolean.TRUE.equals(goal.getPersistentExecution())) { + runner.cancel(goal.getId()); + goals.pause(goal.getId(),goal.getCreatedBy()); + store.suspendConversation(event.conversationId(),"user_stopped"); + } + } + + @TransactionalEventListener(phase=TransactionPhase.BEFORE_COMMIT, fallbackExecution=true) + public void resumed(GoalExecutionSignal.Resume event) { + store.resume(event.goalId(),LocalDateTime.now(clock)); + } + + @EventListener + public void turnFinished(GoalExecutionSignal.TurnFinished event) { + store.turnFinished(event.conversationId(),LocalDateTime.now(clock)); + } + + @EventListener + @Transactional(propagation=Propagation.REQUIRES_NEW) + public void approvalResolved(vip.mate.approval.event.ApprovalResolutionEvent event) { + if ("denied".equals(event.resolutionNote()) || "TIMEOUT".equals(event.decisionSource())) { + stopped(new GoalExecutionSignal.Stop(event.conversationId())); + } + // Approval execution belongs to the existing replay path. Only its + // TurnFinished event releases waiting_approval; never consume/replay here. + } + + @PreDestroy public void close() { + closing=true; + runner.cancelAll(); + if (executor instanceof java.util.concurrent.ExecutorService workers) { + // Cancellation must finish checkpoint persistence without interrupting JDBC I/O. + workers.shutdown(); + try { + if (!workers.awaitTermination(10,java.util.concurrent.TimeUnit.SECONDS)) { + log.warn("Goal workers did not finish shutdown persistence within 10 seconds"); + } + } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java index 5aca7f1d..7b3a926f 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalEvaluationService.java @@ -131,7 +131,9 @@ public class GoalEvaluationService implements Evaluator { + "\n\n" + format; List messages = new ArrayList<>(2); - messages.add(new SystemMessage(bootstrap ? BOOTSTRAP_SYSTEM_PROMPT : VERDICT_SYSTEM_PROMPT)); + messages.add(new SystemMessage(bootstrap ? BOOTSTRAP_SYSTEM_PROMPT + : Boolean.TRUE.equals(goal.getPersistentExecution()) + ? PERSISTENT_VERDICT_SYSTEM_PROMPT : VERDICT_SYSTEM_PROMPT)); messages.add(new UserMessage(userPrompt)); ChatOptions options = ChatOptions.builder() @@ -219,6 +221,17 @@ public class GoalEvaluationService implements Evaluator { + "'all requirements met'. If a criterion lacks specific evidence, " + "mark it not passed. Output only the requested JSON."; + private static final String PERSISTENT_VERDICT_SYSTEM_PROMPT = + "Judge cumulative progress toward a persistent goal using the latest reply, " + + "conversation evidence and previously verified checklist evidence. " + + "A later step need not repeat completed earlier work. Preserve a prior " + + "passed=true item with nonblank evidence unless new concrete evidence contradicts it. " + + "Revoke it when such contradictory evidence exists, citing that evidence. " + + "An attempted action, a goal description or a claim of completion is not proof. " + + "Newly passed criteria require concrete observable evidence. Return only changed " + + "criterion verdicts; omitted criteria retain their previous state. Keep evidence concise. " + + "Output only the requested JSON."; + private String buildUserPrompt(GoalEntity goal, List existing, List recentMessages, @@ -238,6 +251,12 @@ public class GoalEvaluationService implements Evaluator { sb.append("Current checklist (judge each by id):\n"); for (GoalCriterion c : existing) { sb.append("- ").append(c.id()).append(": ").append(c.text()).append('\n'); + if (Boolean.TRUE.equals(goal.getPersistentExecution())) { + String evidence = safe(c.evidence()); + sb.append(" Previous passed=").append(c.passed()).append("; evidence: ") + .append(evidence.length() > 1000 ? evidence.substring(0, 1000) + " [truncated]" : evidence) + .append('\n'); + } } sb.append('\n'); } @@ -263,6 +282,10 @@ public class GoalEvaluationService implements Evaluator { .append(MAX_BOOTSTRAP_CRITERIA) .append(" criteria. Leave every 'passed' false and 'evidence' empty — " + "this round only defines the checklist."); + } else if (Boolean.TRUE.equals(goal.getPersistentExecution())) { + sb.append("Return only changed criteria with specific evidence. Preserve verified prior work " + + "unless new evidence contradicts it; absence from the latest reply is not a contradiction. " + + "Never treat passed=true without nonblank evidence as verified completion."); } else { sb.append("For every criterion above, return its id with passed=true ONLY when " + "the reply shows concrete evidence; otherwise passed=false with a short " diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalExecutionSignal.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalExecutionSignal.java new file mode 100644 index 00000000..2eb95064 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalExecutionSignal.java @@ -0,0 +1,9 @@ +package vip.mate.goal.service; + +/** Explicit user controls, distinct from a graph segment reaching its limit. */ +public final class GoalExecutionSignal { + private GoalExecutionSignal() {} + public record Stop(String conversationId) {} + public record Resume(Long goalId) {} + public record TurnFinished(String conversationId) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalFollowupService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalFollowupService.java index 2bf5066f..f9573745 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalFollowupService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalFollowupService.java @@ -1,27 +1,25 @@ package vip.mate.goal.service; import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import vip.mate.goal.config.GoalProperties; +import vip.mate.goal.model.GoalContinuationDecision; +import vip.mate.goal.model.GoalContinuationDecision.Action; import vip.mate.goal.model.GoalCriteriaCodec; import vip.mate.goal.model.GoalCriterion; import vip.mate.goal.model.GoalEntity; import vip.mate.goal.model.GoalEvaluationResult; +import vip.mate.goal.model.GoalStatus; -import java.time.Duration; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; -/** - * Decides whether to inject a follow-up user prompt for the next graph pass, - * driving the autonomous "continue until the checklist is complete" loop. - */ -@Slf4j +/** Shared continuation policy for bounded graph passes and durable scheduling. */ @Service public class GoalFollowupService { + private static final int EVALUATION_RETRY_SECONDS = 30; private final GoalProperties properties; private final ObjectMapper objectMapper; @@ -30,78 +28,118 @@ public class GoalFollowupService { this.objectMapper = objectMapper; } - /** - * Build the follow-up prompt to inject, or empty when no follow-up should - * fire this turn. Gating order: - *

      - *
    1. {@code allow-auto-followup} runtime hard gate (operator kill - * switch; overrides per-goal flag).
    2. - *
    3. Per-goal {@code autoFollowupEnabled}.
    4. - *
    5. Evaluator decision is "continue" (not all criteria passed).
    6. - *
    7. Cooldown since the last follow-up has elapsed.
    8. - *
    9. turn_budget has at least one slot left after this turn.
    10. - *
    11. (agent + eval) LLM calls below 90% of llm_call_budget.
    12. - *
    - */ - public Optional maybeBuildFollowup(GoalEntity goal, - GoalEvaluationResult result) { - if (goal == null || result == null) return Optional.empty(); - // Runtime hard gate first — overrides any per-goal flag. - if (!properties.isAllowAutoFollowup()) return Optional.empty(); - if (!Boolean.TRUE.equals(goal.getAutoFollowupEnabled())) return Optional.empty(); - // Completion is deterministic now: the evaluator sets decision=completed - // only when every checklist criterion passed. Anything still "continue" - // has remaining work regardless of the numeric score, so there is no - // score threshold here — a 20/21 goal (score 0.95) must still follow up. - if (!GoalEvaluationResult.DECISION_CONTINUE.equals(result.decision())) { - return Optional.empty(); + /** Pure decision: callers persist due times and perform state transitions. */ + public GoalContinuationDecision decide(GoalEntity goal, GoalEvaluationResult result, LocalDateTime now) { + if (goal == null) return decision(Action.DISABLED, null, null, "goal_missing"); + if (goal.getStatus() == GoalStatus.COMPLETED) { + return decision(Action.COMPLETE, null, null, "goal_completed"); + } + if (goal.getStatus() != GoalStatus.ACTIVE) { + return decision(Action.DISABLED, null, null, "goal_not_active"); + } + if (!properties.isEnabled() || !properties.isAllowAutoFollowup() + || !Boolean.TRUE.equals(goal.getAutoFollowupEnabled())) { + return decision(Action.DISABLED, null, null, "auto_followup_disabled"); + } + boolean fallback = result == null || GoalEvaluationResult.DECISION_FALLBACK.equals(result.decision()); + // A fallback cannot prove completion, even if a malformed caller sets completed=true. + boolean persistent = Boolean.TRUE.equals(goal.getPersistentExecution()); + boolean claimedComplete = !fallback && (result.completed() + || GoalEvaluationResult.DECISION_COMPLETED.equals(result.decision())); + boolean completionUnverified = claimedComplete && persistent && !hasVerifiedChecklist(goal); + if (claimedComplete && !completionUnverified) { + return decision(Action.COMPLETE, null, null, "criteria_completed"); } - // Cooldown — last_followup_at recorded by recordFollowupInjected(). - Integer cooldownSec = goal.getFollowupCooldownSeconds(); - if (cooldownSec != null && cooldownSec > 0 && goal.getLastFollowupAt() != null) { - Duration since = Duration.between(goal.getLastFollowupAt(), LocalDateTime.now()); - if (since.getSeconds() < cooldownSec) { - log.debug("[GoalFollowup] cooldown not elapsed: {}s < {}s", since.getSeconds(), cooldownSec); - return Optional.empty(); - } - } - - int turnsUsed = goal.getTurnsUsed() != null ? goal.getTurnsUsed() : 0; + int turns = goal.getTurnsUsed() != null ? goal.getTurnsUsed() : 0; int turnBudget = goal.getTurnBudget() != null ? goal.getTurnBudget() : Integer.MAX_VALUE; - // Leave at least one turn slot for the real user — refuse to burn the - // final slot on an auto-followup the user can't watch. - if (turnsUsed >= turnBudget - 1) return Optional.empty(); - + if ((persistent && turnBudget != 0 && turns >= turnBudget) + || (!persistent && turns >= turnBudget - 1)) { + return decision(Action.BUDGET_LIMITED, null, null, "turn_budget"); + } int callBudget = goal.getLlmCallBudget() != null ? goal.getLlmCallBudget() : Integer.MAX_VALUE; - if (goal.totalLlmCallsUsed() >= (int) (callBudget * 0.9)) return Optional.empty(); + if ((persistent && callBudget != 0 && goal.totalLlmCallsUsed() >= callBudget) + || (!persistent && goal.totalLlmCallsUsed() >= (int) (callBudget * 0.9))) { + return decision(Action.BUDGET_LIMITED, null, null, "llm_call_budget"); + } - return Optional.of(buildPrompt(goal, result)); + String prompt = buildPrompt(goal, result); + LocalDateTime cooldownDeadline = now; + Integer cooldown = goal.getFollowupCooldownSeconds(); + if (cooldown != null && cooldown > 0 && goal.getLastFollowupAt() != null) { + cooldownDeadline = goal.getLastFollowupAt().plusSeconds(cooldown); + } + if (fallback || completionUnverified || !GoalEvaluationResult.DECISION_CONTINUE.equals(result.decision())) { + LocalDateTime retryAt = now.plusSeconds(EVALUATION_RETRY_SECONDS); + if (cooldownDeadline.isAfter(retryAt)) retryAt = cooldownDeadline; + return decision(Action.RETRY, prompt, retryAt, + completionUnverified ? "completion_not_verified" + : result == null ? "evaluation_missing" : bounded(result.gap(), 1000)); + } + if (cooldownDeadline.isAfter(now)) { + return decision(Action.DEFER, prompt, cooldownDeadline, "followup_cooldown"); + } + return decision(Action.CONTINUE, prompt, now, "remaining_criteria"); } - /** - * Prefer a concrete remaining-criteria list when the goal has a checklist; - * fall back to the free-text gap otherwise. Both end with the same "take - * the next concrete step" instruction. - */ + private boolean hasVerifiedChecklist(GoalEntity goal) { + List checklist = GoalCriteriaCodec.parse(goal.getCriteria(), objectMapper); + return !checklist.isEmpty() && checklist.stream().allMatch(c -> c != null && c.passed() + && c.evidence() != null && !c.evidence().isBlank()); + } + + /** Compatibility wrapper for graph-local followups; deferred/retry work is not injected. */ + public Optional maybeBuildFollowup(GoalEntity goal, GoalEvaluationResult result) { + GoalContinuationDecision decision = decide(goal, result, LocalDateTime.now()); + return decision.action() == Action.CONTINUE ? Optional.of(decision.prompt()) : Optional.empty(); + } + + private GoalContinuationDecision decision(Action action, String prompt, LocalDateTime nextRunAt, String reason) { + return new GoalContinuationDecision(action, prompt, nextRunAt, reason); + } + + /** Bound each evidence section while always retaining recovery/safety instructions. */ private String buildPrompt(GoalEntity goal, GoalEvaluationResult result) { + StringBuilder prompt = new StringBuilder(); + prompt.append("Continue working toward the original objective; preserve its scope and exit criteria.\n") + .append("Title: ").append(bounded(goal.getTitle(), 255)).append('\n') + .append("Objective: ").append(bounded(goal.getDescription(), 2500)).append('\n') + .append("Exit criteria: ").append(bounded(goal.getExitCriteria(), 2000)).append('\n'); List all = GoalCriteriaCodec.parse(goal.getCriteria(), objectMapper); - List remaining = GoalCriteriaCodec.remaining(all); + boolean persistent = Boolean.TRUE.equals(goal.getPersistentExecution()); + List remaining = persistent + ? all.stream().filter(c -> !c.passed() || c.evidence() == null || c.evidence().isBlank()).toList() + : GoalCriteriaCodec.remaining(all); if (!remaining.isEmpty()) { - int total = all.size(); - int passed = total - remaining.size(); - StringBuilder sb = new StringBuilder(); - sb.append("Continue working toward the goal. ") - .append(passed).append('/').append(total).append(" criteria passed. Remaining:\n"); - for (GoalCriterion c : remaining) { - sb.append(" - ").append(c.text()).append('\n'); + prompt.append(all.size() - remaining.size()).append('/').append(all.size()) + .append(persistent ? " criteria verified. Remaining checklist (verify missing evidence):\n" + : " criteria passed. Remaining checklist:\n"); + StringBuilder checklist = new StringBuilder(); + for (GoalCriterion criterion : remaining) { + if (checklist.length() >= 4000) break; + checklist.append(" - ").append(bounded(criterion.text(), 600)).append('\n'); } - sb.append("Take the next concrete step on the remaining criteria."); - return sb.toString(); + prompt.append(bounded(checklist.toString(), 4000)); } - String gap = result.gap(); - if (gap == null || gap.isBlank()) gap = "the goal is not yet complete."; - return "Continue working on the goal. Still missing: " + gap - + "\nTake the next concrete step."; + String gap = result != null ? result.gap() : null; + if (gap != null && !gap.isBlank()) { + prompt.append("\nLatest evaluation: ").append(bounded(gap, 1000)); + } + if (persistent) { + prompt.append("\nIf essential input or permission is still unavailable after checking existing state, ") + .append("call waitForGoalInput with the precise missing requirement and ask the user once. ") + .append("Do not use this boundary because of difficulty, elapsed time, incomplete work, or transient errors."); + } + prompt.append("\nInspect authoritative state and any existing async handles before repeating side effects. ") + .append("Poll or resume existing operations instead of starting duplicates. ") + .append("Verify completed work against evidence; do not treat a prior attempt as success. ") + .append("If a section above was truncated, retrieve the full goal/checklist before acting. ") + .append("Take the next concrete step on the remaining criteria without changing the original objective."); + return prompt.toString(); + } + + private static String bounded(String text, int limit) { + if (text == null) return ""; + return text.length() <= limit ? text : text.substring(0, limit - 14) + "… [truncated]"; } } diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalRecoveryService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalRecoveryService.java new file mode 100644 index 00000000..5d75487c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalRecoveryService.java @@ -0,0 +1,84 @@ +package vip.mate.goal.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vip.mate.channel.web.ConversationInputQueueStore; +import vip.mate.goal.model.GoalAttempt; + +import java.time.LocalDateTime; + +/** Reconciles expired attempts from durable checkpoints before dispatching new work. */ +@Service +public class GoalRecoveryService { + public enum RecoveryDecision { + RETRY_SAFE, + RESUME_FROM_EVIDENCE, + RECONCILE_MESSAGE, + BLOCK_UNCERTAIN_SIDE_EFFECT + } + + private final GoalAttemptStore attempts; + private final GoalContinuationStore continuations; + private final ConversationInputQueueStore inputs; + private final GoalService goals; + private final LocalDateTime startupCutoff=LocalDateTime.now(); + private volatile boolean orphanClaimsReleased; + + public GoalRecoveryService(GoalAttemptStore attempts,GoalContinuationStore continuations, + ConversationInputQueueStore inputs,GoalService goals) { + this.attempts=attempts;this.continuations=continuations;this.inputs=inputs;this.goals=goals; + } + + public RecoveryDecision classify(GoalAttempt attempt) { + if("tool_started".equals(attempt.checkpointType()) && "uncertain".equals(attempt.replaySafety())) { + return RecoveryDecision.BLOCK_UNCERTAIN_SIDE_EFFECT; + } + if("message_saved".equals(attempt.checkpointType()) && attempt.assistantMessageId()!=null) { + return RecoveryDecision.RECONCILE_MESSAGE; + } + if("tool_completed".equals(attempt.checkpointType()) && "resolved".equals(attempt.replaySafety())) { + return RecoveryDecision.RESUME_FROM_EVIDENCE; + } + return RecoveryDecision.RETRY_SAFE; + } + + public int recoverExpired(LocalDateTime now) { + if(!orphanClaimsReleased) { + synchronized(this) { + if(!orphanClaimsReleased) { + inputs.releaseClaimsBefore(startupCutoff,now); + orphanClaimsReleased=true; + } + } + } + int recovered=0; + for(GoalAttempt attempt:attempts.expired(now,100)) { + if(recover(attempt,now)) recovered++; + } + return recovered; + } + + @Transactional + boolean recover(GoalAttempt attempt,LocalDateTime now) { + var continuation=continuations.get(attempt.goalId()); + if(continuation==null || !attempt.id().equals(continuation.currentAttemptId()) + || !attempt.leaseToken().equals(continuation.leaseOwner())) return false; + RecoveryDecision decision=classify(attempt); + String attemptState=decision==RecoveryDecision.BLOCK_UNCERTAIN_SIDE_EFFECT ? "blocked" : "retryable"; + String projectionState=decision==RecoveryDecision.BLOCK_UNCERTAIN_SIDE_EFFECT ? "blocked" : "retry"; + String reason=decision==RecoveryDecision.BLOCK_UNCERTAIN_SIDE_EFFECT + ? "uncertain_tool_outcome_requires_review" : "restart_recovery"; + if(!attempts.finish(attempt.id(),attempt.leaseToken(),attemptState,reason, + decision.name().toLowerCase(),now)) return false; + if(!continuations.recoverExpired(attempt.goalId(),attempt.leaseToken(),attempt.id(),now, + projectionState,now,continuation.failures()+1,reason,now)) { + throw new IllegalStateException("Expired goal projection changed during recovery"); + } + inputs.releaseClaims(attempt.id(),now); + if(decision==RecoveryDecision.BLOCK_UNCERTAIN_SIDE_EFFECT) { + var goal=goals.getById(attempt.goalId()); + if(goal!=null) goals.pause(goal.getId(),goal.getCreatedBy()); + } + return true; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalRunCoordinator.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalRunCoordinator.java new file mode 100644 index 00000000..9bbbebd6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalRunCoordinator.java @@ -0,0 +1,143 @@ +package vip.mate.goal.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vip.mate.goal.config.GoalProperties; +import vip.mate.goal.model.GoalAttempt; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalStatus; +import vip.mate.goal.model.SegmentOutcome; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** Owns fenced claim, renewal and settlement for one durable goal segment. */ +@Service +public class GoalRunCoordinator { + private static final int LEASE_SECONDS=60; + private final GoalContinuationStore continuations; + private final GoalAttemptStore attempts; + private final GoalService goals; + private final GoalProperties properties; + + public GoalRunCoordinator(GoalContinuationStore continuations,GoalAttemptStore attempts,GoalService goals, + GoalProperties properties) { + this.continuations=continuations;this.attempts=attempts;this.goals=goals;this.properties=properties; + } + + public record ClaimedRun(GoalContinuationStore.Continuation candidate,GoalEntity goal, + GoalAttempt attempt,long revision) {} + + @Transactional + public ClaimedRun claim(GoalContinuationStore.Continuation candidate,GoalEntity goal,LocalDateTime now) { + if(candidate==null || goal==null || candidate.currentAttemptId()!=null) return null; + String token=UUID.randomUUID().toString(); + LocalDateTime until=now.plusSeconds(LEASE_SECONDS); + if(!continuations.claim(goal.getId(),token,now,until)) return null; + GoalContinuationStore.Continuation claimed=continuations.get(goal.getId()); + String parentAttemptId=null; + if("restart_recovery".equals(candidate.reason())) { + var recent=attempts.listRecent(goal.getId(),1); + if(!recent.isEmpty()) parentAttemptId=recent.getFirst().id(); + } + GoalAttempt attempt=attempts.create(goal.getId(),goal.getConversationId(),parentAttemptId, + "continuation",token,until,null,now); + if(!continuations.bindAttempt(goal.getId(),token,attempt.id(),claimed.revision())) { + throw new IllegalStateException("Goal attempt could not be bound to its continuation"); + } + return new ClaimedRun(candidate,goal,attempt,claimed.revision()+1); + } + + @Transactional + public boolean markRunning(ClaimedRun run,LocalDateTime now) { + if(!current(run)) return false; + return attempts.markRunning(run.attempt().id(),run.attempt().leaseToken(),now); + } + + @Transactional + public boolean renew(ClaimedRun run,LocalDateTime now) { + LocalDateTime until=now.plusSeconds(LEASE_SECONDS); + if(!continuations.renewFenced(run.goal().getId(),run.attempt().leaseToken(), + run.attempt().id(),run.revision(),until)) return false; + return attempts.renew(run.attempt().id(),run.attempt().leaseToken(),until,now); + } + + @Transactional + public boolean checkpoint(ClaimedRun run,String replaySafety,String checkpointType, + Long assistantMessageId,LocalDateTime now) { + if(!current(run)) return false; + return attempts.checkpoint(run.attempt().id(),run.attempt().leaseToken(),replaySafety, + checkpointType,assistantMessageId,now); + } + + @Transactional + public boolean settle(ClaimedRun run,SegmentOutcome outcome,LocalDateTime now) { + if(!current(run)) return false; + GoalEntity fresh=goals.getById(run.goal().getId()); + Settlement settlement=classify(run,outcome,fresh,now); + if((outcome instanceof SegmentOutcome.Continue || outcome instanceof SegmentOutcome.Complete) + && !attempts.checkpoint(run.attempt().id(),run.attempt().leaseToken(),"resolved", + "evaluation_saved",null,now)) return false; + if(!attempts.finish(run.attempt().id(),run.attempt().leaseToken(),settlement.attemptState, + outcome.reason(),settlement.errorCategory,now)) return false; + if(!continuations.settleFenced(run.goal().getId(),run.attempt().leaseToken(),run.attempt().id(), + run.revision(),settlement.projectionState,settlement.nextRunAt,settlement.failures, + settlement.reason,now)) { + throw new IllegalStateException("Goal projection fence changed during settlement"); + } + return true; + } + + private boolean current(ClaimedRun run) { + return run!=null && continuations.matchesFence(run.goal().getId(),run.attempt().leaseToken(), + run.attempt().id(),run.revision()); + } + + private Settlement classify(ClaimedRun run,SegmentOutcome outcome,GoalEntity fresh,LocalDateTime now) { + int failures=run.candidate().failures(); + if(fresh!=null && fresh.getStatus()==GoalStatus.COMPLETED || outcome instanceof SegmentOutcome.Complete) { + return new Settlement("succeeded","completed",now,0,"goal_completed",null); + } + if(fresh!=null && fresh.getStatus()==GoalStatus.PAUSED && goals.isBudgetExhausted(fresh)) { + return new Settlement("succeeded","budget_limited",now,0,goals.exhaustionReason(fresh),null); + } + if(outcome instanceof SegmentOutcome.AwaitApproval) { + return new Settlement("succeeded","waiting_approval",now,0,outcome.reason(),null); + } + if(outcome instanceof SegmentOutcome.WaitInput) { + return new Settlement("succeeded","waiting_input",now,0,outcome.reason(),null); + } + if(outcome instanceof SegmentOutcome.Retry retry) { + int nextFailures=Math.min(1000,failures+1); + long delay=Math.min(300,5L << Math.min(6,nextFailures-1)); + return new Settlement("retryable","retry",now.plusSeconds(delay),nextFailures, + retry.reason(),retry.category()); + } + if(outcome instanceof SegmentOutcome.Defer defer) { + return new Settlement("succeeded","queued",defer.nextRunAt(),failures,defer.reason(),null); + } + if(outcome instanceof SegmentOutcome.Blocked blocked) { + return new Settlement("blocked","blocked",now,Math.min(1000,failures+1), + blocked.reason(),blocked.category()); + } + if(outcome instanceof SegmentOutcome.Cancelled || !eligible(fresh)) { + boolean waiting=fresh!=null && fresh.getProgressSummary()!=null + && fresh.getProgressSummary().startsWith("Waiting for input:"); + return new Settlement("cancelled",waiting ? "waiting_input" : "paused",now,0, + waiting ? fresh.getProgressSummary() : outcome.reason(),null); + } + int cooldown=fresh==null || fresh.getFollowupCooldownSeconds()==null ? 0 : fresh.getFollowupCooldownSeconds(); + int delay=Math.max(properties.getMinimumContinuationIntervalSeconds(),cooldown); + return new Settlement("succeeded","queued",now.plusSeconds(delay),0, + outcome.reason(),null); + } + + private static boolean eligible(GoalEntity goal) { + return goal!=null && goal.getStatus()==GoalStatus.ACTIVE + && Boolean.TRUE.equals(goal.getPersistentExecution()) + && Boolean.TRUE.equals(goal.getAutoFollowupEnabled()); + } + + private record Settlement(String attemptState,String projectionState,LocalDateTime nextRunAt, + int failures,String reason,String errorCategory) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalSegmentRunner.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalSegmentRunner.java new file mode 100644 index 00000000..a87f0561 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalSegmentRunner.java @@ -0,0 +1,295 @@ +package vip.mate.goal.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import reactor.core.Disposable; +import vip.mate.agent.AgentService; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.agent.context.GoalContinuationContext; +import vip.mate.agent.runtime.ConversationTurnGate; +import vip.mate.approval.ApprovalWorkflowService; +import vip.mate.channel.web.AgentStreamAccumulator; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.channel.web.ConversationInputQueueStore; +import vip.mate.exception.MateClawException; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.SegmentOutcome; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageEntity; + +import java.util.Map; +import java.util.Objects; +import java.time.LocalDateTime; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.ConcurrentHashMap; + +/** Runs and persists one ordinary graph segment without an HTTP subscriber. */ +@Component +public class GoalSegmentRunner { + private final AgentService agents; + private final ConversationService conversations; + private final ApprovalWorkflowService approvals; + private final ChatStreamTracker streams; + private final ObjectMapper mapper; + private final ConversationTurnGate gate; + private final ConversationInputQueueStore inputQueue; + private final ConcurrentHashMap workers=new ConcurrentHashMap<>(); + private volatile boolean closing; + private static final class Worker { + final String conversationId; + final AtomicBoolean cancelled=new AtomicBoolean(); + final AtomicBoolean interrupted=new AtomicBoolean(); + final AtomicReference handle=new AtomicReference<>(); + volatile boolean interactive; + Worker(String conversationId) { this.conversationId=conversationId; } + } + @org.springframework.beans.factory.annotation.Autowired + private GoalService goals; + @org.springframework.beans.factory.annotation.Autowired + private GoalRunCoordinator coordinator; + + public GoalSegmentRunner(AgentService agents, ConversationService conversations, + ApprovalWorkflowService approvals, ChatStreamTracker streams, ObjectMapper mapper, + ConversationTurnGate gate, ConversationInputQueueStore inputQueue) { + this.agents=agents;this.conversations=conversations;this.approvals=approvals; + this.streams=streams;this.mapper=mapper;this.gate=gate;this.inputQueue=inputQueue; + } + + private record SegmentResult(String finishReason, boolean awaitingApproval, boolean evaluationUnavailable) {} + + /** Cancel this worker only, never a newer conversation generation or a user turn. */ + public void cancel(Long goalId) { + Worker worker=workers.get(goalId); + if (worker!=null && !worker.interactive) { + cancelWorker(worker); + } + } + + /** User Stop applies even after the goal completed and queued interactive work took over. */ + public void stopConversation(String conversationId) { + workers.values().stream().filter(w -> Objects.equals(w.conversationId,conversationId)).forEach(this::cancelWorker); + } + + public void cancelAll() { + closing=true; + workers.values().forEach(this::cancelWorker); + } + + private void cancelWorker(Worker worker) { + worker.cancelled.set(true); + streams.cancelRun(worker.handle.get()); + // Stream disposal releases the completion latch. Never interrupt this + // worker: it also performs JDBC I/O on shared embedded database channels. + } + + public SegmentOutcome run(GoalEntity goal, String prompt, boolean recovered) { + return run(goal,prompt,recovered,null); + } + + public SegmentOutcome run(GoalRunCoordinator.ClaimedRun claimed,String prompt,boolean recovered) { + return run(claimed.goal(),prompt,recovered,claimed); + } + + private SegmentOutcome run(GoalEntity goal,String prompt,boolean recovered, + GoalRunCoordinator.ClaimedRun claimedRun) { + String convId=goal.getConversationId(); + var permit=gate.tryAcquire(convId); + if (permit==null) throw new MateClawException("err.agent.conversation_busy",409,"Conversation is busy"); + Worker worker=new Worker(convId); + AtomicReference claimedInput=new AtomicReference<>(); + try { + workers.put(goal.getId(),worker); + // Register before checking the shutdown fence so cancellation cannot miss us. + if (closing) { + worker.cancelled.set(true); + return new SegmentOutcome.Cancelled("stopped"); + } + var conv=conversations.findByConversationId(convId); + if (conv==null || !Objects.equals(conv.getWorkspaceId(),goal.getWorkspaceId()) + || !Objects.equals(conv.getAgentId(),goal.getAgentId()) + || !Objects.equals(conv.getUsername(),goal.getCreatedBy()) + || Integer.valueOf(1).equals(conv.getDeleted()) || Integer.valueOf(1).equals(conv.getArchived())) { + throw new IllegalStateException("Goal conversation identity changed or conversation unavailable"); + } + var agent=agents.getAgent(goal.getAgentId()); + if (agent==null || Boolean.FALSE.equals(agent.getEnabled()) + || (agent.getRuntimeType()!=null && !"native".equals(agent.getRuntimeType()))) { + throw new IllegalStateException("Goal requires an enabled native runtime with goal evaluation"); + } + if (approvals.findPendingByConversation(convId)!=null) return new SegmentOutcome.AwaitApproval("approval_required"); + if (streams.isRunning(convId)) { + throw new MateClawException("err.agent.conversation_busy",409,"Conversation has pending input"); + } + String guidance=recovered ? "The previous execution was interrupted by a runtime restart. " + + "Inspect the workspace, progress ledger and existing async handles before acting. " + + "Do not replay side effects whose outcome is unknown; request review if their outcome cannot be verified.\n" : ""; + ChatOrigin origin=ChatOrigin.web(convId,goal.getCreatedBy(),goal.getWorkspaceId(),null).withAgent(goal.getAgentId()); + SegmentResult result; + ConversationInputQueueStore.QueuedInput queued=claimNextInput(convId,claimedRun); + do { + String input=guidance+prompt; + if (queued!=null) { + claimedInput.set(queued); + if (queued.agentId()!=null && !queued.agentId().equals(goal.getAgentId())) { + inputQueue.release(queued.id(),queued.claimedByAttemptId(),LocalDateTime.now()); + claimedInput.set(null); + throw new IllegalStateException("Queued input targets a different agent; user review required"); + } + Long originMessageId=queued.persistedMessageId(); + if (originMessageId==null) { + var saved=conversations.saveMessage(convId,"user",queued.message(),queued.contentParts(),"queued"); + originMessageId=saved==null ? null : saved.getId(); + if (originMessageId==null || !inputQueue.bindMessage(queued.id(),queued.claimedByAttemptId(), + originMessageId,LocalDateTime.now())) { + throw new IllegalStateException("Queued input could not be bound to its persisted message"); + } + } + if (!inputQueue.consume(queued.id(),queued.claimedByAttemptId(),LocalDateTime.now())) { + throw new IllegalStateException("Queued input claim was lost before execution"); + } + claimedInput.set(null); + worker.interactive=true; + origin=origin.withOriginMessageId(originMessageId); + input=queuedPrompt(queued); + streams.broadcastObject(convId,"queued_input_started",Map.of("conversationId",convId,"message",input)); + } else if (goals!=null && goals.getById(goal.getId()).getStatus()!=vip.mate.goal.model.GoalStatus.ACTIVE) { + return new SegmentOutcome.Cancelled("stopped"); + } + result=runSegment(goal,input,origin,permit,worker,claimedRun); + if (result.awaitingApproval()) return new SegmentOutcome.AwaitApproval("approval_required"); + if ("stopped".equals(result.finishReason())) return new SegmentOutcome.Cancelled("stopped"); + queued=claimNextInput(convId,claimedRun); + } while (queued!=null); + if(result.evaluationUnavailable()) return new SegmentOutcome.Retry("evaluation","evaluation_unavailable"); + if("error_fallback".equals(result.finishReason())) { + return new SegmentOutcome.Blocked("graph","graph_error_requires_review"); + } + return new SegmentOutcome.Continue(result.finishReason()==null ? "unfinished" : result.finishReason()); + } catch (RuntimeException error) { + if (Thread.interrupted()) worker.interrupted.set(true); + streams.broadcastObject(convId,"warning",Map.of("message", + "Goal execution interrupted. Durable queued input remains available for recovery.")); + throw error; + } finally { + try { + ConversationInputQueueStore.QueuedInput claimed=claimedInput.getAndSet(null); + if (claimed!=null) { + inputQueue.release(claimed.id(),claimed.claimedByAttemptId(),LocalDateTime.now()); + } + } finally { + workers.remove(goal.getId(),worker); + permit.close(); + if (worker.interrupted.get()) Thread.currentThread().interrupt(); + } + } + } + + private SegmentResult runSegment(GoalEntity goal, String input, ChatOrigin origin, + ConversationTurnGate.Permit permit, Worker worker, + GoalRunCoordinator.ClaimedRun claimedRun) { + String convId=goal.getConversationId(); + var handle=streams.register(convId); + worker.handle.set(handle); + streams.incrementFlux(convId); + AgentStreamAccumulator accumulator=new AgentStreamAccumulator(mapper,new AgentStreamAccumulator.Sink() { + @Override public void broadcast(String id,String name,Object payload) { streams.broadcastObject(id,name,payload); } + @Override public void updatePhase(String id,String phase) { streams.updatePhase(id,phase); } + }); + AtomicReference failure=new AtomicReference<>(); + AtomicBoolean evaluationUnavailable=new AtomicBoolean(); + AtomicBoolean persisted=new AtomicBoolean(); + CountDownLatch finished=new CountDownLatch(1); + Disposable subscription=null; + try { + if (worker.cancelled.get() || Thread.currentThread().isInterrupted()) throw new InterruptedException(); + if(claimedRun!=null && !checkpoint(claimedRun,"safe","provider_started",null)) { + throw new IllegalStateException("Goal attempt lost its execution fence"); + } + conversations.updateStreamStatus(convId,"running"); + streams.broadcastObject(convId,"message_start",Map.of("role","assistant","trigger","goal")); + subscription=gate.withPermit(permit,() -> GoalContinuationContext.call(!worker.interactive, () -> + reactor.core.publisher.Flux.defer(() -> { + if (worker.cancelled.get()) return reactor.core.publisher.Flux.empty(); + return agents.chatStructuredStream(goal.getAgentId(),input, + convId,goal.getCreatedBy(),null,origin) + .doOnNext(delta -> { + accumulator.accept(delta,convId); + if(claimedRun!=null && "tool_call_started".equals(delta.eventType())) { + checkpoint(claimedRun,"uncertain","tool_started",null); + } else if(claimedRun!=null && "tool_call_completed".equals(delta.eventType())) { + checkpoint(claimedRun,"resolved","tool_completed",null); + } + if ("goal_evaluated".equals(delta.eventType()) && delta.eventData()!=null + && (Boolean.TRUE.equals(delta.eventData().get("skipped")) + || "fallback".equals(delta.eventData().get("decision")))) evaluationUnavailable.set(true); + }); + }) + .doOnSubscribe(s -> streams.setDisposable(handle, s::cancel)) + .doFinally(signal -> finished.countDown()) + .subscribe(delta -> {},failure::set))); + streams.setDisposable(handle,subscription); + finished.await(); + String reason=streams.isStopRequested(convId) + ? streams.getInterruptType(convId)==ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP + ? "interrupted" : "stopped" + : accumulator.getFinishReason(); + String status="stopped".equals(reason) ? "stopped" : "interrupted".equals(reason) ? "interrupted" : accumulator.isAwaitingApproval() + ? "awaiting_approval" : failure.get()!=null || "error_fallback".equals(reason) ? "error" : "completed"; + MessageEntity saved=persist(convId,accumulator,status); + if(claimedRun!=null && !checkpoint(claimedRun,"resolved","message_saved", + saved==null ? null : saved.getId())) { + throw new IllegalStateException("Goal attempt lost its checkpoint fence"); + } + persisted.set(true); + streams.broadcastObject(convId,"message_complete",Map.of("status",status,"trigger","goal")); + if (failure.get()!=null && !"stopped".equals(reason)) { + throw failure.get() instanceof RuntimeException runtime ? runtime : new RuntimeException(failure.get()); + } + return new SegmentResult(reason,accumulator.isAwaitingApproval(),evaluationUnavailable.get()); + } catch (InterruptedException interrupted) { + worker.interrupted.set(true); + throw new IllegalStateException("Goal worker interrupted; recover from persisted evidence",interrupted); + } finally { + if (Thread.interrupted()) worker.interrupted.set(true); + if (worker.cancelled.get() || worker.interrupted.get()) streams.cancelRun(handle); + if (subscription!=null) subscription.dispose(); + try { + if (!persisted.get()) persist(convId,accumulator,"interrupted"); + } finally { + conversations.updateStreamStatus(convId,"idle"); + streams.broadcastObject(convId,"done",Map.of("status","segment_finished")); + streams.complete(handle); + } + } + } + + private MessageEntity persist(String convId,AgentStreamAccumulator accumulator,String status) { + return conversations.saveMessage(convId,"assistant",accumulator.getContent(),accumulator.toAssistantParts(),status, + accumulator.getPromptTokens(),accumulator.getCompletionTokens(),accumulator.getCacheReadTokens(), + accumulator.getCacheWriteTokens(),accumulator.getReasoningTokens(),accumulator.getRuntimeModelName(), + accumulator.getRuntimeProviderId(),accumulator.toMetadataJson()); + } + + private boolean checkpoint(GoalRunCoordinator.ClaimedRun run,String safety,String type,Long messageId) { + if(coordinator==null) return true; + return coordinator.checkpoint(run,safety,type,messageId,LocalDateTime.now()); + } + + private ConversationInputQueueStore.QueuedInput claimNextInput(String conversationId, + GoalRunCoordinator.ClaimedRun claimedRun) { + String claimant=claimedRun==null ? UUID.randomUUID().toString() : claimedRun.attempt().id(); + return inputQueue.claimNext(conversationId,claimant,LocalDateTime.now()).orElse(null); + } + + private String queuedPrompt(ConversationInputQueueStore.QueuedInput queued) { + if (queued.contentParts()==null || queued.contentParts().isEmpty()) return queued.message(); + var message=new vip.mate.workspace.conversation.model.MessageEntity(); + message.setContent(queued.message()); + try { message.setContentParts(mapper.writeValueAsString(queued.contentParts())); } + catch (com.fasterxml.jackson.core.JsonProcessingException error) { throw new IllegalArgumentException("Invalid queued input",error); } + return conversations.renderMessageContent(message,true); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java index a90d6ea6..6c7e46bb 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java @@ -47,6 +47,9 @@ public interface GoalService { // ==================== State machine ==================== GoalEntity pause(Long id, String username); + + /** Pause a persistent active goal until essential user input or permission is provided. */ + GoalEntity waitForInput(Long id, String reason, String username); GoalEntity resume(Long id, String username); GoalEntity abandon(Long id, String username); diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java index 3d989d11..e9b570ce 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DuplicateKeyException; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import vip.mate.audit.service.AuditEventService; @@ -54,6 +55,7 @@ public class GoalServiceImpl implements GoalService { private final GoalProperties properties; private final AuditEventService auditEventService; private final ObjectMapper objectMapper; + private ApplicationEventPublisher applicationEventPublisher; /** * Optional — only set when the memory subsystem is wired. On goal @@ -81,6 +83,11 @@ public class GoalServiceImpl implements GoalService { this.memoryManager = memoryManager; } + @Autowired(required = false) + public void setApplicationEventPublisher(ApplicationEventPublisher publisher) { + this.applicationEventPublisher = publisher; + } + // ==================== CRUD ==================== @Override @@ -108,11 +115,13 @@ public class GoalServiceImpl implements GoalService { entity.setExitCriteria(req.getExitCriteria()); entity.setSuccessCheckPrompt(req.getSuccessCheckPrompt()); entity.setStatus(GoalStatus.ACTIVE); + boolean persistent = persistentOnCreate(req); + entity.setPersistentExecution(persistent); entity.setTurnBudget(req.getTurnBudget() != null - ? req.getTurnBudget() : properties.getDefaultTurnBudget()); + ? req.getTurnBudget() : persistent ? 0 : properties.getDefaultTurnBudget()); entity.setTurnsUsed(0); entity.setLlmCallBudget(req.getLlmCallBudget() != null - ? req.getLlmCallBudget() : properties.getDefaultLlmCallBudget()); + ? req.getLlmCallBudget() : persistent ? 0 : properties.getDefaultLlmCallBudget()); entity.setAgentLlmCallsUsed(0); entity.setEvalLlmCallsUsed(0); // Three-state default: explicit true/false is honored; null falls @@ -143,6 +152,7 @@ public class GoalServiceImpl implements GoalService { writeEvent(entity.getId(), GoalEventType.CREATED, null, Map.of( "title", entity.getTitle(), + "persistentExecution", persistent, "turnBudget", entity.getTurnBudget(), "llmCallBudget", entity.getLlmCallBudget(), "by", username)); @@ -193,16 +203,22 @@ public class GoalServiceImpl implements GoalService { @Override @Transactional public GoalEntity update(Long id, GoalUpdateRequest req, String username) { - // Pre-validate constant fields once; the actual not-terminal check - // happens inside the builder against the fresh entity so a status - // flip between this method's entry and a CAS retry is honoured. - if (req.getTurnBudget() != null) validateBudget(req.getTurnBudget(), "turnBudget"); - if (req.getLlmCallBudget() != null) validateBudget(req.getLlmCallBudget(), "llmCallBudget"); - + // Validate mode and budgets together against each freshly read CAS row. GoalEntity updated = retryOptimistic(id, "update", fresh -> { ensureNotTerminal(fresh, "update"); + boolean persistent = req.getPersistentExecution() != null + ? req.getPersistentExecution() : Boolean.TRUE.equals(fresh.getPersistentExecution()); + Integer turns = req.getTurnBudget() != null ? req.getTurnBudget() : fresh.getTurnBudget(); + Integer calls = req.getLlmCallBudget() != null ? req.getLlmCallBudget() : fresh.getLlmCallBudget(); + if (turns != null && (req.getTurnBudget() != null || req.getPersistentExecution() != null)) + validateBudget(turns, "turnBudget", persistent); + if (calls != null && (req.getLlmCallBudget() != null || req.getPersistentExecution() != null)) + validateBudget(calls, "llmCallBudget", persistent); LambdaUpdateWrapper w = baseLockedUpdate(fresh); boolean changed = false; + if (req.getPersistentExecution() != null) { + w.set(GoalEntity::getPersistentExecution, req.getPersistentExecution()); changed = true; + } if (req.getTitle() != null && !req.getTitle().isBlank()) { w.set(GoalEntity::getTitle, req.getTitle().trim()); changed = true; } @@ -255,11 +271,42 @@ public class GoalServiceImpl implements GoalService { GoalEventType.PAUSED, "goal.paused", username); } + @Override + @Transactional + public GoalEntity waitForInput(Long id, String reason, String username) { + if (reason == null || reason.isBlank()) { + throw new MateClawException("err.goal.wait_reason_required", 400, + "A precise reason describing the missing input or permission is required"); + } + String trimmed = reason.trim(); + String boundedReason = trimmed.length() <= 1000 ? trimmed : trimmed.substring(0, 997) + "..."; + GoalEntity paused = retryOptimistic(id, "waitForInput", fresh -> { + if (fresh.getStatus() != GoalStatus.ACTIVE || !Boolean.TRUE.equals(fresh.getPersistentExecution())) { + throw new MateClawException("err.goal.wait_requires_active_persistent", 409, + "Waiting for input requires an active persistent goal"); + } + LambdaUpdateWrapper update = baseLockedUpdate(fresh) + .set(GoalEntity::getStatus, GoalStatus.PAUSED) + .set(GoalEntity::getProgressSummary, "Waiting for input: " + boundedReason); + bumpVersionAndTime(update); + return update; + }); + Map detail = Map.of("by", username, "reason", boundedReason, + "state", "waiting_input", "from", "active", "to", "paused"); + writeEvent(id, GoalEventType.PAUSED, null, detail); + recordAudit("goal.waiting_input", paused, detail); + return paused; + } + @Override @Transactional public GoalEntity resume(Long id, String username) { - return flipStatus(id, GoalStatus.PAUSED, GoalStatus.ACTIVE, + GoalEntity resumed = flipStatus(id, GoalStatus.PAUSED, GoalStatus.ACTIVE, GoalEventType.RESUMED, "goal.resumed", username); + if (Boolean.TRUE.equals(resumed.getPersistentExecution()) && applicationEventPublisher != null) { + applicationEventPublisher.publishEvent(new GoalExecutionSignal.Resume(id)); + } + return resumed; } @Override @@ -283,17 +330,23 @@ public class GoalServiceImpl implements GoalService { public GoalEntity markCompleted(Long id, GoalEvaluationResult result) { GoalEntity g = retryOptimistic(id, "markCompleted", fresh -> { if (fresh.getStatus().isTerminal()) return null; // idempotent + boolean persistent = Boolean.TRUE.equals(fresh.getPersistentExecution()); + List existing = GoalCriteriaCodec.parse(fresh.getCriteria(), objectMapper); + if (persistent && (fresh.getStatus() != GoalStatus.ACTIVE || existing.isEmpty() + || existing.stream().anyMatch(c -> c == null || !c.passed() + || c.evidence() == null || c.evidence().isBlank()))) { + throw new MateClawException("err.goal.completion_not_verified", 409, + "Persistent completion requires an active goal and evidence for every current criterion"); + } LambdaUpdateWrapper w = baseLockedUpdate(fresh) .set(GoalEntity::getStatus, GoalStatus.COMPLETED); if (result != null) { w.set(GoalEntity::getCompletionScore, result.score()) .set(GoalEntity::getProgressSummary, result.gap()); } - // Snapshot the checklist as fully satisfied. Idempotent for the - // auto path (recordEvaluation already merged all-passed); required - // for manual completion, which has no preceding verdict. - List existing = GoalCriteriaCodec.parse(fresh.getCriteria(), objectMapper); - if (!existing.isEmpty()) { + // Preserve verified persistent evidence verbatim. Legacy manual + // completion retains its historical force-passed checklist snapshot. + if (!persistent && !existing.isEmpty()) { List allPassed = existing.stream() .map(c -> c.passed() ? c : new GoalCriterion(c.id(), c.text(), true, c.evidence() == null || c.evidence().isBlank() @@ -337,8 +390,14 @@ public class GoalServiceImpl implements GoalService { public GoalEntity markExhausted(Long id, String reason) { GoalEntity g = retryOptimistic(id, "markExhausted", fresh -> { if (fresh.getStatus().isTerminal()) return null; + boolean persistent = Boolean.TRUE.equals(fresh.getPersistentExecution()); LambdaUpdateWrapper w = baseLockedUpdate(fresh) - .set(GoalEntity::getStatus, GoalStatus.EXHAUSTED); + .set(GoalEntity::getStatus, persistent ? GoalStatus.PAUSED : GoalStatus.EXHAUSTED); + if (persistent) { + w.set(GoalEntity::getProgressSummary, "Paused: " + + (reason != null ? reason : "budget limit") + + ". Increase the budget and resume to continue."); + } bumpVersionAndTime(w); return w; }); @@ -347,8 +406,9 @@ public class GoalServiceImpl implements GoalService { detail.put("turnsUsed", g.getTurnsUsed()); detail.put("agentLlmCallsUsed", g.getAgentLlmCallsUsed()); detail.put("evalLlmCallsUsed", g.getEvalLlmCallsUsed()); - writeEvent(id, GoalEventType.EXHAUSTED, null, detail); - recordAudit("goal.exhausted", g, detail); + boolean persistent = Boolean.TRUE.equals(g.getPersistentExecution()); + writeEvent(id, persistent ? GoalEventType.PAUSED : GoalEventType.EXHAUSTED, null, detail); + recordAudit(persistent ? "goal.paused" : "goal.exhausted", g, detail); return g; } @@ -375,7 +435,10 @@ public class GoalServiceImpl implements GoalService { .setSql("agent_llm_calls_used = agent_llm_calls_used + " + agentDelta) .setSql("eval_llm_calls_used = eval_llm_calls_used + " + evalDelta) .set(GoalEntity::getLastEvaluationAt, LocalDateTime.now()); - if (result != null) { + // Late model results still consume usage, but cannot overwrite a + // persistent pause/input boundary established while the call ran. + if (result != null && (!Boolean.TRUE.equals(fresh.getPersistentExecution()) + || fresh.getStatus() == GoalStatus.ACTIVE)) { w.set(GoalEntity::getCompletionScore, result.score()) .set(GoalEntity::getProgressSummary, result.gap()); // Persist the checklist by carrier: bootstrap writes the fresh @@ -431,16 +494,18 @@ public class GoalServiceImpl implements GoalService { public boolean isBudgetExhausted(GoalEntity goal) { int turns = goal.getTurnsUsed() != null ? goal.getTurnsUsed() : 0; int turnBudget = goal.getTurnBudget() != null ? goal.getTurnBudget() : Integer.MAX_VALUE; - if (turns >= turnBudget) return true; + boolean persistent = Boolean.TRUE.equals(goal.getPersistentExecution()); + if ((!persistent || turnBudget != 0) && turns >= turnBudget) return true; int callBudget = goal.getLlmCallBudget() != null ? goal.getLlmCallBudget() : Integer.MAX_VALUE; - return goal.totalLlmCallsUsed() >= callBudget; + return (!persistent || callBudget != 0) && goal.totalLlmCallsUsed() >= callBudget; } @Override public String exhaustionReason(GoalEntity goal) { int turns = goal.getTurnsUsed() != null ? goal.getTurnsUsed() : 0; int turnBudget = goal.getTurnBudget() != null ? goal.getTurnBudget() : Integer.MAX_VALUE; - if (turns >= turnBudget) return "turn_budget"; + if ((!Boolean.TRUE.equals(goal.getPersistentExecution()) || turnBudget != 0) + && turns >= turnBudget) return "turn_budget"; return "llm_call_budget"; } @@ -544,6 +609,7 @@ public class GoalServiceImpl implements GoalService { r.setExitCriteria(e.getExitCriteria()); r.setSuccessCheckPrompt(e.getSuccessCheckPrompt()); r.setStatus(e.getStatus()); + r.setPersistentExecution(Boolean.TRUE.equals(e.getPersistentExecution())); r.setTurnBudget(e.getTurnBudget()); r.setTurnsUsed(e.getTurnsUsed()); r.setLlmCallBudget(e.getLlmCallBudget()); @@ -591,14 +657,20 @@ public class GoalServiceImpl implements GoalService { if (req.getTitle().length() > 255) { throw new MateClawException("err.goal.bad_request", 400, "title too long (>255)"); } - if (req.getTurnBudget() != null) validateBudget(req.getTurnBudget(), "turnBudget"); - if (req.getLlmCallBudget() != null) validateBudget(req.getLlmCallBudget(), "llmCallBudget"); + boolean persistent = persistentOnCreate(req); + if (req.getTurnBudget() != null) validateBudget(req.getTurnBudget(), "turnBudget", persistent); + if (req.getLlmCallBudget() != null) validateBudget(req.getLlmCallBudget(), "llmCallBudget", persistent); } - private static void validateBudget(int v, String name) { - if (v <= 0) { + private boolean persistentOnCreate(GoalCreateRequest req) { + return req.getPersistentExecution() != null + ? req.getPersistentExecution() : properties.isDefaultPersistentExecution(); + } + + private static void validateBudget(int v, String name, boolean persistent) { + if (v < 0 || (!persistent && v == 0)) { throw new MateClawException("err.goal.invalid_budget", 400, - name + " must be > 0, got " + v); + name + (persistent ? " must be >= 0, got " : " must be > 0, got ") + v); } } @@ -680,6 +752,11 @@ public class GoalServiceImpl implements GoalService { throw new MateClawException("err.goal.bad_transition", 409, "Cannot transition " + fresh.getStatus().getValue() + " -> " + to.getValue()); } + if (to == GoalStatus.ACTIVE && Boolean.TRUE.equals(fresh.getPersistentExecution()) + && isBudgetExhausted(fresh)) { + throw new MateClawException("err.goal.budget_exhausted", 409, + "Increase the exhausted budget before resuming: " + exhaustionReason(fresh)); + } LambdaUpdateWrapper w = baseLockedUpdate(fresh) .set(GoalEntity::getStatus, to); bumpVersionAndTime(w); diff --git a/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aAgentCardController.java b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aAgentCardController.java new file mode 100644 index 00000000..5e17681a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aAgentCardController.java @@ -0,0 +1,32 @@ +package vip.mate.interop.a2a; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequiredArgsConstructor +public class A2aAgentCardController { + + private final A2aAgentCardService cardService; + + @GetMapping("/api/a2a/card") + public Map card(HttpServletRequest request, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + Authentication authentication) { + if (authentication == null) { + return cardService.publicCard(request); + } + return cardService.authenticatedCard(request, workspaceId); + } + + @GetMapping("/.well-known/agent-card.json") + public Map wellKnown(HttpServletRequest request) { + return cardService.publicCard(request); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aAgentCardService.java b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aAgentCardService.java new file mode 100644 index 00000000..a959cd6b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aAgentCardService.java @@ -0,0 +1,93 @@ +package vip.mate.interop.a2a; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import vip.mate.agent.AgentService; +import vip.mate.agent.model.AgentEntity; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class A2aAgentCardService { + + private final A2aProperties properties; + private final AgentService agentService; + + public Map publicCard(HttpServletRequest request) { + Map card = baseCard(request); + card.put("supportsAuthenticatedExtendedCard", true); + card.remove("skills"); + return card; + } + + public Map authenticatedCard(HttpServletRequest request, Long workspaceId) { + long wsId = workspaceId == null ? 1L : workspaceId; + Map card = baseCard(request); + List> skills = new ArrayList<>(); + for (AgentEntity agent : agentService.listAgentsByWorkspace(wsId, true)) { + Map skill = new LinkedHashMap<>(); + skill.put("id", String.valueOf(agent.getId())); + skill.put("name", agent.getName()); + skill.put("description", agent.getDescription() == null ? "" : agent.getDescription()); + skill.put("tags", tags(agent.getTags())); + skills.add(skill); + } + card.put("skills", skills); + return card; + } + + private Map baseCard(HttpServletRequest request) { + String rpcUrl = externalBaseUrl(request).replaceAll("/+$", "") + "/api/a2a"; + Map card = new LinkedHashMap<>(); + card.put("name", "MateClaw"); + card.put("description", "A multi-agent runtime exposed through A2A JSON-RPC."); + card.put("url", rpcUrl); + card.put("version", "1.0.0"); + card.put("protocolVersion", "1.0"); + card.put("supportedInterfaces", List.of(Map.of( + "url", rpcUrl, + "protocolBinding", "JSONRPC", + "protocolVersion", "1.0" + ))); + card.put("capabilities", Map.of( + "streaming", true, + "pushNotifications", false, + "stateTransitionHistory", false + )); + card.put("defaultInputModes", List.of("text/plain")); + card.put("defaultOutputModes", List.of("text/plain")); + card.put("skills", List.of()); + return card; + } + + private String externalBaseUrl(HttpServletRequest request) { + if (properties.getBaseUrl() != null && !properties.getBaseUrl().isBlank()) { + return properties.getBaseUrl().trim(); + } + return ServletUriComponentsBuilder.fromRequestUri(request) + .replacePath(null) + .replaceQuery(null) + .build() + .toUriString(); + } + + private static List tags(String tags) { + if (tags == null || tags.isBlank()) { + return List.of(); + } + List out = new ArrayList<>(); + for (String tag : tags.split(",")) { + String trimmed = tag.trim(); + if (!trimmed.isBlank()) { + out.add(trimmed); + } + } + return out; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aAutoConfiguration.java b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aAutoConfiguration.java new file mode 100644 index 00000000..fedc909c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aAutoConfiguration.java @@ -0,0 +1,9 @@ +package vip.mate.interop.a2a; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(A2aProperties.class) +public class A2aAutoConfiguration { +} diff --git a/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aCallTool.java b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aCallTool.java new file mode 100644 index 00000000..68f7b752 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aCallTool.java @@ -0,0 +1,67 @@ +package vip.mate.interop.a2a; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class A2aCallTool { + + private final A2aPeerAdapter peerAdapter; + private final ObjectMapper objectMapper; + + @Tool(name = "call_a2a_agent", description = "Call another A2A-compatible agent. The config JSON must include url and may include headers.") + public String callA2aAgent( + @ToolParam(description = "Message to send to the peer agent") String message, + @ToolParam(description = "Optional peer conversation context id", required = false) String contextId, + @ToolParam(description = "Optional peer skill id", required = false) String skillId, + @ToolParam(description = "JSON object: {\"url\":\"https://peer/api/a2a\",\"headers\":{\"Authorization\":\"Bearer ...\"},\"stream\":false}") String config + ) { + try { + Map cfg = parseConfig(config); + String url = String.valueOf(cfg.getOrDefault("url", "")).trim(); + if (url.isBlank()) { + return "Error: config.url is required."; + } + Map headers = headers(cfg.get("headers")); + boolean stream = Boolean.TRUE.equals(cfg.get("stream")); + A2aPeerAdapter.PeerResult result = stream + ? peerAdapter.stream(url, message, contextId, skillId, headers) + : peerAdapter.sendBlocking(url, message, contextId, skillId, headers); + if (stream && !result.frames().isEmpty()) { + return objectMapper.writeValueAsString(result.frames()); + } + return result.body(); + } catch (Exception e) { + return "Error: " + e.getMessage(); + } + } + + private Map parseConfig(String config) throws Exception { + if (config == null || config.isBlank()) { + return Map.of(); + } + return objectMapper.readValue(config, new TypeReference>() { + }); + } + + private static Map headers(Object value) { + if (!(value instanceof Map raw)) { + return Map.of(); + } + Map out = new LinkedHashMap<>(); + for (Map.Entry entry : raw.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + out.put(String.valueOf(entry.getKey()), String.valueOf(entry.getValue())); + } + } + return out; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aExecutionBridge.java b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aExecutionBridge.java new file mode 100644 index 00000000..7f3e8f24 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aExecutionBridge.java @@ -0,0 +1,9 @@ +package vip.mate.interop.a2a; + +public interface A2aExecutionBridge { + + ExecutionResult executeBlocking(A2aExecutionRequest request); + + record ExecutionResult(String text, boolean terminal) { + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aExecutionRequest.java b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aExecutionRequest.java new file mode 100644 index 00000000..ebcd53fd --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aExecutionRequest.java @@ -0,0 +1,12 @@ +package vip.mate.interop.a2a; + +public record A2aExecutionRequest( + String taskId, + String contextId, + String message, + Long agentId, + Long workspaceId, + String username, + Long userId +) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aJsonRpcController.java b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aJsonRpcController.java new file mode 100644 index 00000000..1ccfa7ac --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aJsonRpcController.java @@ -0,0 +1,321 @@ +package vip.mate.interop.a2a; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +@RestController +@RequestMapping("/api/a2a") +@RequiredArgsConstructor +public class A2aJsonRpcController { + + private static final int ERR_INVALID_REQUEST = -32600; + private static final int ERR_METHOD_NOT_FOUND = -32601; + private static final int ERR_INVALID_PARAMS = -32602; + private static final int ERR_TASK_NOT_FOUND = -32001; + private static final int ERR_DUPLICATE_TASK = -32009; + + private final ObjectMapper objectMapper; + private final A2aProperties properties; + private final A2aTaskStore store; + private final A2aExecutionBridge bridge; + private final ExecutorService streamExecutor = Executors.newVirtualThreadPerTaskExecutor(); + + @PostMapping + public ResponseEntity handle(@RequestBody JsonNode body, Authentication authentication) { + if (!properties.isEnabled()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "A2A is disabled")); + } + if (authentication == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error(null, -32050, "unauthorized")); + } + Object rpcId = rpcId(body == null ? null : body.get("id")); + if (rpcId == InvalidRpcId.INSTANCE) { + return ResponseEntity.ok(error(null, ERR_INVALID_REQUEST, "JSON-RPC id must be a string, number, or null")); + } + if (body == null || !body.isObject() || !"2.0".equals(text(body.get("jsonrpc")))) { + return ResponseEntity.ok(error(rpcId, ERR_INVALID_REQUEST, "invalid JSON-RPC request")); + } + String method = text(body.get("method")); + JsonNode params = body.get("params"); + String tenant = tenant(params); + String rpcKey = rpcId == null ? null : String.valueOf(rpcId); + if (rpcKey != null) { + var existing = store.rpcSnapshot(tenant, rpcKey); + if (existing.isPresent()) { + return ResponseEntity.ok(result(rpcId, existing.get())); + } + } + + try { + return switch (method) { + case "message/send" -> ResponseEntity.ok(handleSend(rpcId, tenant, params, authentication)); + case "message/stream" -> stream(rpcId, tenant, params, authentication); + case "tasks/get" -> ResponseEntity.ok(handleGet(rpcId, tenant, params)); + case "tasks/cancel" -> ResponseEntity.ok(handleCancel(rpcId, tenant, params)); + default -> ResponseEntity.ok(error(rpcId, ERR_METHOD_NOT_FOUND, "method not found")); + }; + } catch (IllegalStateException e) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body(error(rpcId, -32051, e.getMessage())); + } + } + + private Map handleSend(Object rpcId, String tenant, JsonNode params, Authentication auth) { + try { + A2aExecutionRequest request = executionRequest(tenant, params, auth); + A2aTask submitted = A2aTask.submitted(request.taskId(), request.contextId(), tenant); + if (!store.putIfAbsent(tenant, submitted)) { + return error(rpcId, ERR_DUPLICATE_TASK, "task id already exists"); + } + A2aTask working = store.update(tenant, request.taskId(), + task -> task.withStatus("working", null, false)).orElse(submitted); + boolean blocking = !params.has("configuration") + || !params.get("configuration").has("blocking") + || params.get("configuration").get("blocking").asBoolean(true); + A2aTask responseTask = blocking ? executeWithTimeout(tenant, request, working) : working; + Map snapshot = responseTask.toMap(); + store.rememberRpcSnapshot(tenant, rpcId == null ? null : String.valueOf(rpcId), snapshot); + return result(rpcId, snapshot); + } catch (IllegalArgumentException e) { + return error(rpcId, ERR_INVALID_PARAMS, e.getMessage()); + } + } + + private ResponseEntity stream(Object rpcId, String tenant, JsonNode params, Authentication auth) { + SseEmitter emitter = new SseEmitter(0L); + AtomicBoolean done = new AtomicBoolean(false); + streamExecutor.execute(() -> heartbeat(emitter, done)); + streamExecutor.execute(() -> { + try { + A2aExecutionRequest request = executionRequest(tenant, params, auth); + A2aTask submitted = A2aTask.submitted(request.taskId(), request.contextId(), tenant); + if (!store.putIfAbsent(tenant, submitted)) { + send(emitter, "error", error(rpcId, ERR_DUPLICATE_TASK, "task id already exists")); + emitter.complete(); + return; + } + send(emitter, "task", submitted.toMap()); + A2aTask working = store.update(tenant, request.taskId(), + task -> task.withStatus("working", null, false)).orElse(submitted); + send(emitter, "status-update", working.toMap()); + A2aExecutionBridge.ExecutionResult out = bridge.executeBlocking(request); + A2aTask withArtifact = store.update(tenant, request.taskId(), + task -> task.withArtifact(out.text(), true)).orElse(working); + send(emitter, "artifact-update", withArtifact.artifacts().getLast()); + A2aTask completed = store.update(tenant, request.taskId(), + task -> task.withStatus(out.terminal() ? "completed" : "working", out.text(), out.terminal())) + .orElse(withArtifact); + send(emitter, "status-update", completed.toMap()); + emitter.complete(); + } catch (Exception e) { + try { + send(emitter, "error", error(rpcId, ERR_INVALID_PARAMS, e.getMessage())); + } catch (IOException ignored) { + // The client may already have disconnected. + } + emitter.completeWithError(e); + } finally { + done.set(true); + } + }); + return ResponseEntity.ok() + .contentType(MediaType.TEXT_EVENT_STREAM) + .body(emitter); + } + + private Map handleGet(Object rpcId, String tenant, JsonNode params) { + String taskId = taskId(params); + return store.get(tenant, taskId) + .>map(task -> result(rpcId, task.toMap())) + .orElseGet(() -> error(rpcId, ERR_TASK_NOT_FOUND, "task not found")); + } + + private Map handleCancel(Object rpcId, String tenant, JsonNode params) { + String taskId = taskId(params); + return store.update(tenant, taskId, task -> task.terminal() + ? task + : task.withStatus("canceled", "Task canceled by caller.", true)) + .>map(task -> result(rpcId, task.toMap())) + .orElseGet(() -> error(rpcId, ERR_TASK_NOT_FOUND, "task not found")); + } + + private A2aTask executeWithTimeout(String tenant, A2aExecutionRequest request, A2aTask current) { + CompletableFuture future = + CompletableFuture.supplyAsync(() -> bridge.executeBlocking(request)); + try { + A2aExecutionBridge.ExecutionResult out = future.get(properties.getCallTimeoutMs(), TimeUnit.MILLISECONDS); + A2aTask withArtifact = store.update(tenant, request.taskId(), + task -> task.withArtifact(out.text(), true)).orElse(current); + return store.update(tenant, request.taskId(), + task -> task.withStatus(out.terminal() ? "completed" : "working", out.text(), out.terminal())) + .orElse(withArtifact); + } catch (TimeoutException e) { + return current; + } catch (Exception e) { + return store.update(tenant, request.taskId(), + task -> task.withStatus("failed", e.getMessage(), true)).orElse(current); + } + } + + private A2aExecutionRequest executionRequest(String tenant, JsonNode params, Authentication auth) { + if (params == null || !params.isObject()) { + throw new IllegalArgumentException("params object is required"); + } + JsonNode message = params.get("message"); + if (message == null || !message.isObject()) { + throw new IllegalArgumentException("message object is required"); + } + String taskId = firstText(message.get("taskId"), params.get("id")); + if (taskId.isBlank()) { + taskId = "task-" + UUID.randomUUID(); + } + String contextId = firstText(message.get("contextId"), params.get("contextId")); + if (contextId.isBlank()) { + contextId = taskId; + } + String text = extractText(message.get("parts")); + if (text.isBlank()) { + throw new IllegalArgumentException("message text is required"); + } + Long agentId = agentId(message.get("metadata")); + Long workspaceId = longOrDefault(params.get("workspaceId"), 1L); + Long userId = auth.getDetails() instanceof Number n ? n.longValue() : null; + return new A2aExecutionRequest(taskId, contextId, text, agentId, workspaceId, auth.getName(), userId); + } + + private static Long agentId(JsonNode metadata) { + String skillId = metadata == null ? "" : text(metadata.get("skillId")); + if (skillId.isBlank()) { + throw new IllegalArgumentException("message.metadata.skillId is required"); + } + try { + return Long.parseLong(skillId); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("message.metadata.skillId must be a numeric agent id"); + } + } + + private static String extractText(JsonNode parts) { + if (parts == null || !parts.isArray()) { + return ""; + } + List texts = new ArrayList<>(); + for (JsonNode part : parts) { + String text = text(part.get("text")); + if (!text.isBlank()) { + texts.add(text); + } + } + return String.join("\n", texts); + } + + private static String taskId(JsonNode params) { + String id = firstText(params == null ? null : params.get("id"), + params == null ? null : params.get("taskId")); + if (id.isBlank()) { + throw new IllegalArgumentException("task id is required"); + } + return id; + } + + private static String tenant(JsonNode params) { + return text(params == null ? null : params.get("tenant")); + } + + private static Long longOrDefault(JsonNode node, Long fallback) { + if (node == null || node.isNull()) { + return fallback; + } + if (node.isNumber()) { + return node.longValue(); + } + if (node.isTextual() && !node.asText().isBlank()) { + return Long.parseLong(node.asText()); + } + return fallback; + } + + private static Object rpcId(JsonNode id) { + if (id == null || id.isNull()) { + return null; + } + if (id.isTextual()) { + return id.asText(); + } + if (id.isNumber()) { + return id.numberValue(); + } + return InvalidRpcId.INSTANCE; + } + + private static String firstText(JsonNode first, JsonNode second) { + String value = text(first); + return value.isBlank() ? text(second) : value; + } + + private static String text(JsonNode node) { + return node == null || node.isNull() ? "" : node.asText(""); + } + + private static Map result(Object id, Object result) { + Map out = new LinkedHashMap<>(); + out.put("jsonrpc", "2.0"); + out.put("id", id); + out.put("result", result); + return out; + } + + private static Map error(Object id, int code, String message) { + Map out = new LinkedHashMap<>(); + out.put("jsonrpc", "2.0"); + out.put("id", id); + out.put("error", Map.of("code", code, "message", message == null ? "" : message)); + return out; + } + + private void send(SseEmitter emitter, String event, Object data) throws IOException { + emitter.send(SseEmitter.event() + .name(event) + .data(objectMapper.writeValueAsString(data))); + } + + private void heartbeat(SseEmitter emitter, AtomicBoolean done) { + while (!done.get()) { + try { + Thread.sleep(15_000L); + if (!done.get()) { + emitter.send(SseEmitter.event().comment("heartbeat")); + } + } catch (Exception e) { + done.set(true); + } + } + } + + private enum InvalidRpcId { + INSTANCE + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aPeerAdapter.java b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aPeerAdapter.java new file mode 100644 index 00000000..37b35227 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aPeerAdapter.java @@ -0,0 +1,298 @@ +package vip.mate.interop.a2a; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public class A2aPeerAdapter { + + private final ObjectMapper objectMapper; + private final Policy policy; + private final HttpClient httpClient; + + public A2aPeerAdapter(ObjectMapper objectMapper, Policy policy) { + this.objectMapper = objectMapper; + this.policy = policy == null ? Policy.defaults() : policy; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(this.policy.timeout()) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + } + + public PeerResult sendBlocking(String url, String message, String contextId, String skillId, + Map headers) throws IOException, InterruptedException { + URI uri = resolveRpcUri(url, headers == null ? Map.of() : headers); + Map body = rpcBody("message/send", message, contextId, skillId); + CappedBody response = post(uri, body, headers == null ? Map.of() : headers); + CappedBody finalBody = pollIfRunning(uri, response, headers == null ? Map.of() : headers); + return new PeerResult(finalBody.body(), List.of(), response.truncated() || finalBody.truncated()); + } + + public PeerResult stream(String url, String message, String contextId, String skillId, + Map headers) throws IOException, InterruptedException { + URI uri = resolveRpcUri(url, headers == null ? Map.of() : headers); + Map body = rpcBody("message/stream", message, contextId, skillId); + CappedBody response = post(uri, body, headers == null ? Map.of() : headers); + return new PeerResult(response.body(), SseFrames.parse(response.body()), response.truncated()); + } + + private CappedBody pollIfRunning(URI rpcUri, CappedBody initial, Map headers) + throws IOException, InterruptedException { + JsonNode task = taskNode(initial.body()); + String taskId = task == null ? "" : text(task.get("id")); + if (taskId.isBlank() || isTerminalState(task)) { + return initial; + } + long deadline = System.nanoTime() + policy.timeout().toNanos(); + CappedBody latest = initial; + while (System.nanoTime() < deadline) { + Thread.sleep(Math.min(1_000L, Math.max(100L, policy.timeout().toMillis()))); + latest = post(rpcUri, taskGetBody(taskId), headers); + task = taskNode(latest.body()); + if (task == null || isTerminalState(task)) { + return latest; + } + } + return latest; + } + + private Map taskGetBody(String taskId) { + return Map.of( + "jsonrpc", "2.0", + "id", "rpc-" + UUID.randomUUID(), + "method", "tasks/get", + "params", Map.of("id", taskId) + ); + } + + private JsonNode taskNode(String body) { + try { + JsonNode root = objectMapper.readTree(body); + JsonNode result = root.get("result"); + if (result == null || result.isNull()) { + return null; + } + if (result.has("task")) { + return result.get("task"); + } + return result; + } catch (Exception e) { + return null; + } + } + + private static boolean isTerminalState(JsonNode task) { + JsonNode status = task.get("status"); + String state = status == null ? "" : text(status.get("state")); + return "completed".equalsIgnoreCase(state) + || "canceled".equalsIgnoreCase(state) + || "cancelled".equalsIgnoreCase(state) + || "failed".equalsIgnoreCase(state) + || "TASK_STATE_COMPLETED".equals(state) + || "TASK_STATE_CANCELED".equals(state) + || "TASK_STATE_FAILED".equals(state); + } + + private URI resolveRpcUri(String endpoint, Map headers) { + URI configured = safeUri(endpoint); + for (URI candidate : cardCandidates(configured)) { + try { + CappedBody card = get(candidate, headers); + String url = rpcUrlFromCard(card.body()); + if (!url.isBlank()) { + return safeUri(url); + } + } catch (Exception ignored) { + // Discovery is best-effort; the configured endpoint remains valid. + } + } + return configured; + } + + private List cardCandidates(URI endpoint) { + List out = new ArrayList<>(); + String raw = endpoint.toString(); + if (raw.endsWith(".json")) { + out.add(endpoint); + } + out.add(endpoint.resolve(trimTrailingSlash(endpoint.getPath()) + "/card")); + out.add(endpoint.resolve("/.well-known/agent-card.json")); + return out; + } + + private String rpcUrlFromCard(String body) throws IOException { + JsonNode root = objectMapper.readTree(body); + JsonNode interfaces = root.get("supportedInterfaces"); + if (interfaces != null && interfaces.isArray()) { + for (JsonNode iface : interfaces) { + if ("JSONRPC".equalsIgnoreCase(text(iface.get("protocolBinding")))) { + String url = text(iface.get("url")); + if (!url.isBlank()) { + return url; + } + } + } + } + return text(root.get("url")); + } + + private CappedBody get(URI uri, Map headers) throws IOException, InterruptedException { + HttpRequest.Builder builder = HttpRequest.newBuilder(safeUri(uri.toString())) + .timeout(policy.timeout()) + .GET(); + for (Map.Entry header : headers.entrySet()) { + if (header.getKey() != null && header.getValue() != null) { + builder.header(header.getKey(), header.getValue()); + } + } + HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray()); + if (response.statusCode() >= 300 && response.statusCode() < 400) { + throw new IOException("redirects are not allowed"); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("peer returned HTTP " + response.statusCode()); + } + return cap(response.body()); + } + + private Map rpcBody(String method, String message, String contextId, String skillId) { + Map metadata = new LinkedHashMap<>(); + if (skillId != null && !skillId.isBlank()) { + metadata.put("skillId", skillId); + } + Map msg = new LinkedHashMap<>(); + msg.put("messageId", "msg-" + UUID.randomUUID()); + if (contextId != null && !contextId.isBlank()) { + msg.put("contextId", contextId); + } + msg.put("parts", List.of(Map.of("kind", "text", "text", message == null ? "" : message))); + msg.put("metadata", metadata); + return Map.of( + "jsonrpc", "2.0", + "id", "rpc-" + UUID.randomUUID(), + "method", method, + "params", Map.of("message", msg, "configuration", Map.of("blocking", true)) + ); + } + + private CappedBody post(URI uri, Map body, Map headers) + throws IOException, InterruptedException { + HttpRequest.Builder builder = HttpRequest.newBuilder(uri) + .timeout(policy.timeout()) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))); + for (Map.Entry header : headers.entrySet()) { + if (header.getKey() != null && header.getValue() != null) { + builder.header(header.getKey(), header.getValue()); + } + } + HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray()); + if (response.statusCode() >= 300 && response.statusCode() < 400) { + throw new IOException("redirects are not allowed"); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("peer returned HTTP " + response.statusCode()); + } + return cap(response.body()); + } + + private CappedBody cap(byte[] body) { + byte[] bytes = body == null ? new byte[0] : body; + boolean truncated = bytes.length > policy.maxResponseBytes(); + int length = truncated ? policy.maxResponseBytes() : bytes.length; + return new CappedBody(new String(bytes, 0, length, StandardCharsets.UTF_8), truncated); + } + + private URI safeUri(String url) { + URI uri = URI.create(url); + String scheme = uri.getScheme(); + if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) { + throw new IllegalArgumentException("Only HTTP and HTTPS A2A URLs are supported"); + } + if (uri.getHost() == null || uri.getHost().isBlank()) { + throw new IllegalArgumentException("A2A URL host is required"); + } + if (!policy.allowPrivateNetwork()) { + rejectPrivateAddress(uri.getHost()); + } + return uri; + } + + private static void rejectPrivateAddress(String host) { + try { + for (InetAddress address : InetAddress.getAllByName(host)) { + byte[] raw = address.getAddress(); + if (address.isAnyLocalAddress() + || address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || isReserved(raw)) { + throw new IllegalArgumentException("A2A URL resolves to a private or reserved address"); + } + } + } catch (IOException e) { + throw new IllegalArgumentException("A2A URL host could not be resolved", e); + } + } + + private static boolean isReserved(byte[] raw) { + if (raw.length == 4) { + int first = raw[0] & 0xff; + int second = raw[1] & 0xff; + return first == 0 + || first == 10 + || first == 127 + || first == 169 && second == 254 + || first == 172 && second >= 16 && second <= 31 + || first == 192 && second == 168 + || first >= 224; + } + if (raw.length == 16) { + int first = raw[0] & 0xff; + return first == 0 + || first == 0xfc + || first == 0xfd + || first == 0xfe; + } + return true; + } + + private static String trimTrailingSlash(String path) { + if (path == null || path.isBlank()) { + return ""; + } + return path.replaceAll("/+$", ""); + } + + private static String text(JsonNode node) { + return node == null || node.isNull() ? "" : node.asText(""); + } + + public record Policy(Duration timeout, int maxResponseBytes, boolean allowPrivateNetwork) { + public static Policy defaults() { + return new Policy(Duration.ofSeconds(120), 1_048_576, false); + } + } + + public record PeerResult(String body, List frames, boolean truncated) { + public PeerResult { + frames = frames == null ? List.of() : List.copyOf(frames); + } + } + + private record CappedBody(String body, boolean truncated) { + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aPeerAdapterConfiguration.java b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aPeerAdapterConfiguration.java new file mode 100644 index 00000000..ad310ebd --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aPeerAdapterConfiguration.java @@ -0,0 +1,21 @@ +package vip.mate.interop.a2a; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +@Configuration +public class A2aPeerAdapterConfiguration { + + @Bean + public A2aPeerAdapter a2aPeerAdapter(ObjectMapper objectMapper, A2aProperties properties) { + A2aPeerAdapter.Policy policy = new A2aPeerAdapter.Policy( + Duration.ofMillis(properties.getOutboundTimeoutMs()), + properties.getMaxResponseBytes(), + properties.isAllowPrivateOutbound() + ); + return new A2aPeerAdapter(objectMapper, policy); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aProperties.java b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aProperties.java new file mode 100644 index 00000000..4f4c3e04 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aProperties.java @@ -0,0 +1,27 @@ +package vip.mate.interop.a2a; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Data +@ConfigurationProperties(prefix = "mateclaw.a2a") +public class A2aProperties { + + private boolean enabled = false; + + private String baseUrl; + + private long callTimeoutMs = 120_000L; + + private int maxTasks = 1_000; + + private long taskTtlSeconds = 3_600L; + + private int maxResponseBytes = 1_048_576; + + private long outboundTimeoutMs = 120_000L; + + private boolean allowPrivateOutbound = false; + + private long sweepIntervalMs = 60_000L; +} diff --git a/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aTask.java b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aTask.java new file mode 100644 index 00000000..1a61e5d7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aTask.java @@ -0,0 +1,75 @@ +package vip.mate.interop.a2a; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public record A2aTask( + String id, + String contextId, + String tenant, + String state, + String message, + List> artifacts, + boolean terminal, + Instant createdAt, + Instant updatedAt +) { + + private static final List TERMINAL_STATES = List.of("completed", "canceled", "failed"); + + public A2aTask { + artifacts = artifacts == null ? List.of() : List.copyOf(artifacts); + createdAt = createdAt == null ? Instant.now() : createdAt; + updatedAt = updatedAt == null ? createdAt : updatedAt; + } + + public static A2aTask submitted(String id, String contextId, String tenant) { + Instant now = Instant.now(); + return new A2aTask(id, contextId, tenant, "submitted", null, List.of(), false, now, now); + } + + public A2aTask withStatus(String state, String message, boolean terminal) { + boolean finalState = terminal || TERMINAL_STATES.contains(state); + return new A2aTask(id, contextId, tenant, state, message, artifacts, finalState, createdAt, Instant.now()); + } + + public A2aTask withUpdatedAt(Instant updatedAt) { + return new A2aTask(id, contextId, tenant, state, message, artifacts, terminal, createdAt, updatedAt); + } + + public A2aTask withArtifact(String text, boolean append) { + Map artifact = Map.of( + "artifactId", "artifact-" + (artifacts.size() + 1), + "parts", List.of(Map.of("kind", "text", "text", text != null ? text : "")) + ); + List> next = append + ? new ArrayList<>(artifacts) + : new ArrayList<>(); + next.add(artifact); + return new A2aTask(id, contextId, tenant, state, message, next, terminal, createdAt, Instant.now()); + } + + public Map toMap() { + return Map.of( + "id", id, + "contextId", contextId, + "status", Map.of( + "state", state, + "message", messageAsA2aMessage(message) + ), + "artifacts", artifacts + ); + } + + private static Object messageAsA2aMessage(String text) { + if (text == null || text.isBlank()) { + return Map.of(); + } + return Map.of( + "role", "agent", + "parts", List.of(Map.of("kind", "text", "text", text)) + ); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aTaskStore.java b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aTaskStore.java new file mode 100644 index 00000000..d2236a33 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aTaskStore.java @@ -0,0 +1,98 @@ +package vip.mate.interop.a2a; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Iterator; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.UnaryOperator; + +public class A2aTaskStore { + + private final int maxTasks; + private final Duration ttl; + private final Clock clock; + private final ConcurrentMap tasks = new ConcurrentHashMap<>(); + private final ConcurrentMap> rpcSnapshots = new ConcurrentHashMap<>(); + + public A2aTaskStore(int maxTasks, Duration ttl) { + this(maxTasks, ttl, Clock.systemUTC()); + } + + public A2aTaskStore(int maxTasks, Duration ttl, Clock clock) { + this.maxTasks = Math.max(1, maxTasks); + this.ttl = ttl == null ? Duration.ofHours(1) : ttl; + this.clock = clock == null ? Clock.systemUTC() : clock; + } + + public boolean putIfAbsent(String tenant, A2aTask task) { + if (task == null || task.id() == null || task.id().isBlank()) { + return false; + } + if (tasks.size() >= maxTasks) { + sweepExpired(); + if (tasks.size() >= maxTasks) { + throw new IllegalStateException("too many A2A tasks"); + } + } + return tasks.putIfAbsent(taskKey(tenant, task.id()), task.withUpdatedAt(clock.instant())) == null; + } + + public Optional get(String tenant, String taskId) { + if (taskId == null || taskId.isBlank()) { + return Optional.empty(); + } + return Optional.ofNullable(tasks.get(taskKey(tenant, taskId))); + } + + public Optional update(String tenant, String taskId, UnaryOperator updater) { + String key = taskKey(tenant, taskId); + A2aTask updated = tasks.computeIfPresent(key, + (ignored, current) -> updater.apply(current).withUpdatedAt(clock.instant())); + return Optional.ofNullable(updated); + } + + public boolean rememberRpcSnapshot(String tenant, String rpcId, Map snapshot) { + if (rpcId == null || snapshot == null) { + return true; + } + return rpcSnapshots.putIfAbsent(rpcKey(tenant, rpcId), Map.copyOf(snapshot)) == null; + } + + public Optional> rpcSnapshot(String tenant, String rpcId) { + if (rpcId == null) { + return Optional.empty(); + } + return Optional.ofNullable(rpcSnapshots.get(rpcKey(tenant, rpcId))); + } + + public int sweepExpired() { + Instant cutoff = clock.instant().minus(ttl); + int removed = 0; + Iterator> it = tasks.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + A2aTask task = entry.getValue(); + if (task.terminal() && task.updatedAt().isBefore(cutoff)) { + it.remove(); + removed++; + } + } + return removed; + } + + private static String taskKey(String tenant, String taskId) { + return normalizeTenant(tenant) + "|" + taskId; + } + + private static String rpcKey(String tenant, String rpcId) { + return normalizeTenant(tenant) + "|rpc|" + rpcId; + } + + private static String normalizeTenant(String tenant) { + return tenant == null || tenant.isBlank() ? "default" : tenant; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aTaskStoreConfiguration.java b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aTaskStoreConfiguration.java new file mode 100644 index 00000000..d97ed6fd --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aTaskStoreConfiguration.java @@ -0,0 +1,15 @@ +package vip.mate.interop.a2a; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +@Configuration +public class A2aTaskStoreConfiguration { + + @Bean + public A2aTaskStore a2aTaskStore(A2aProperties properties) { + return new A2aTaskStore(properties.getMaxTasks(), Duration.ofSeconds(properties.getTaskTtlSeconds())); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aTaskSweeper.java b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aTaskSweeper.java new file mode 100644 index 00000000..8eb635e6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/interop/a2a/A2aTaskSweeper.java @@ -0,0 +1,26 @@ +package vip.mate.interop.a2a; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class A2aTaskSweeper { + + private final A2aProperties properties; + private final A2aTaskStore store; + + @Scheduled(fixedDelayString = "${mateclaw.a2a.sweep-interval-ms:60000}") + public void sweep() { + if (!properties.isEnabled()) { + return; + } + int removed = store.sweepExpired(); + if (removed > 0) { + log.debug("A2A task sweep removed {} expired task(s)", removed); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/interop/a2a/DefaultA2aExecutionBridge.java b/mateclaw-server/src/main/java/vip/mate/interop/a2a/DefaultA2aExecutionBridge.java new file mode 100644 index 00000000..35c7c299 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/interop/a2a/DefaultA2aExecutionBridge.java @@ -0,0 +1,32 @@ +package vip.mate.interop.a2a; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import vip.mate.agent.AgentService; +import vip.mate.agent.context.ChatOrigin; + +@Service +@RequiredArgsConstructor +public class DefaultA2aExecutionBridge implements A2aExecutionBridge { + + private final AgentService agentService; + + @Override + public ExecutionResult executeBlocking(A2aExecutionRequest request) { + ChatOrigin origin = ChatOrigin.web( + request.contextId(), + request.username(), + request.workspaceId(), + null, + null, + request.userId() + ); + AgentService.ChatResult result = agentService.chatWithUsage( + request.agentId(), + request.message(), + request.contextId(), + origin + ); + return new ExecutionResult(result.content(), true); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/interop/a2a/SseFrames.java b/mateclaw-server/src/main/java/vip/mate/interop/a2a/SseFrames.java new file mode 100644 index 00000000..4985c3b6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/interop/a2a/SseFrames.java @@ -0,0 +1,51 @@ +package vip.mate.interop.a2a; + +import java.util.ArrayList; +import java.util.List; + +public final class SseFrames { + + private SseFrames() { + } + + public record Frame(String event, String data) { + } + + public static List parse(String input) { + List frames = new ArrayList<>(); + if (input == null || input.isEmpty()) { + return frames; + } + String event = "message"; + List dataLines = new ArrayList<>(); + String[] lines = input.split("\\R", -1); + for (String line : lines) { + if (line.isEmpty()) { + flush(frames, event, dataLines); + event = "message"; + dataLines = new ArrayList<>(); + continue; + } + if (line.startsWith(":")) { + continue; + } + if (line.startsWith("event:")) { + event = line.substring("event:".length()).trim(); + continue; + } + if (line.startsWith("data:")) { + String value = line.substring("data:".length()); + dataLines.add(value.startsWith(" ") ? value.substring(1) : value); + } + } + flush(frames, event, dataLines); + return frames; + } + + private static void flush(List frames, String event, List dataLines) { + if (!dataLines.isEmpty()) { + frames.add(new Frame(event == null || event.isBlank() ? "message" : event, + String.join("\n", dataLines))); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java index 9718ec38..900b03f9 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java @@ -175,7 +175,11 @@ public class ModelProviderService { throw new MateClawException("err.llm.provider_id_invalid", "Provider id 仅允许字母/数字及 . _ -(不允许斜杠或空格),首字符必须是字母或数字,长度 1-64: " + request.getId()); } - if (modelProviderMapper.selectById(request.getId()) != null) { + ModelProviderEntity existing = modelProviderMapper.selectById(request.getId()); + if (existing != null && canRestoreCustomProvider(existing)) { + return restoreCustomProvider(existing, request); + } + if (existing != null) { throw new MateClawException("err.llm.provider_exists", "Provider 已存在: " + request.getId()); } ModelProtocol protocol = ModelProtocol.resolve(request.getProtocol(), request.getChatModel()); @@ -211,6 +215,32 @@ public class ModelProviderService { return toProviderInfo(provider, modelConfigService.listModelsByProvider(request.getId())); } + private boolean canRestoreCustomProvider(ModelProviderEntity provider) { + return Boolean.TRUE.equals(provider.getIsCustom()) && !Boolean.TRUE.equals(provider.getEnabled()); + } + + private ProviderInfoDTO restoreCustomProvider(ModelProviderEntity provider, CreateCustomProviderRequest request) { + ModelProtocol protocol = ModelProtocol.resolve(request.getProtocol(), request.getChatModel()); + provider.setName(request.getName()); + provider.setApiKeyPrefix(request.getApiKeyPrefix()); + provider.setChatModel(protocol.getChatModelClass()); + provider.setBaseUrl(request.getDefaultBaseUrl()); + provider.setIsCustom(true); + provider.setIsLocal(false); + provider.setEnabled(true); + provider.setSupportModelDiscovery(protocol.supportsSelfConfiguredDiscovery()); + provider.setRequireApiKey(request.getRequireApiKey() == null || Boolean.TRUE.equals(request.getRequireApiKey())); + modelProviderMapper.updateById(provider); + if (request.getModels() != null) { + for (ModelInfoDTO model : request.getModels()) { + modelConfigService.addModelToProvider(request.getId(), model.getId(), model.getName(), false); + } + } + tryAutoActivateModel(request.getId(), provider); + eventPublisher.publishEvent(new ModelConfigChangedEvent("provider-enabled")); + return toProviderInfo(provider, modelConfigService.listModelsByProvider(request.getId())); + } + public void deleteCustomProvider(String providerId) { ModelProviderEntity provider = getProvider(providerId); if (!Boolean.TRUE.equals(provider.getIsCustom())) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageService.java b/mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageService.java index 27438823..3b3020a9 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageService.java @@ -1,8 +1,10 @@ package vip.mate.skill.usage; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import vip.mate.skill.lifecycle.SkillLifecycleService; import vip.mate.skill.repository.SkillUsageStatMapper; @@ -28,14 +30,11 @@ public class SkillUsageService { try { Long scopedAgentId = agentId != null ? agentId : 0L; String scopedConversationId = blankToEmpty(conversationId); - SkillUsageStatEntity row = mapper.selectOne(new LambdaQueryWrapper() - .eq(SkillUsageStatEntity::getSkillName, skill.getName()) - .eq(SkillUsageStatEntity::getAgentId, scopedAgentId) - .eq(SkillUsageStatEntity::getConversationId, scopedConversationId) - .last("LIMIT 1")); LocalDateTime now = LocalDateTime.now(); - if (row == null) { - row = new SkillUsageStatEntity(); + int updated = incrementExisting(skill, scopedAgentId, scopedConversationId, + filePath, tokenEstimate, now); + if (updated == 0) { + SkillUsageStatEntity row = new SkillUsageStatEntity(); row.setSkillName(skill.getName()); row.setSkillId(skill.getId()); row.setAgentId(scopedAgentId); @@ -45,14 +44,14 @@ public class SkillUsageService { row.setLastFilePath(filePath); row.setLastTokenEstimate(tokenEstimate); row.setDeleted(0); - mapper.insert(row); - } else { - row.setSkillId(skill.getId()); - row.setLoadCount((row.getLoadCount() == null ? 0L : row.getLoadCount()) + 1); - row.setLastLoadedAt(now); - row.setLastFilePath(filePath); - row.setLastTokenEstimate(tokenEstimate); - mapper.updateById(row); + try { + mapper.insert(row); + } catch (DuplicateKeyException race) { + // Another parallel invocation inserted the same scoped row + // after our update missed it. Retry as one atomic update. + incrementExisting(skill, scopedAgentId, scopedConversationId, + filePath, tokenEstimate, now); + } } // Mirror the activity anchor onto mate_skill so the lifecycle // curator's daily scan stays a single indexed select. @@ -62,6 +61,19 @@ public class SkillUsageService { } } + private int incrementExisting(ResolvedSkill skill, Long agentId, String conversationId, + String filePath, int tokenEstimate, LocalDateTime now) { + return mapper.update(null, new LambdaUpdateWrapper() + .set(SkillUsageStatEntity::getSkillId, skill.getId()) + .set(SkillUsageStatEntity::getLastLoadedAt, now) + .set(SkillUsageStatEntity::getLastFilePath, filePath) + .set(SkillUsageStatEntity::getLastTokenEstimate, tokenEstimate) + .setSql("load_count = COALESCE(load_count, 0) + 1") + .eq(SkillUsageStatEntity::getSkillName, skill.getName()) + .eq(SkillUsageStatEntity::getAgentId, agentId) + .eq(SkillUsageStatEntity::getConversationId, conversationId)); + } + public Set recentLoadedSkillNames(Long agentId, int limit) { if (agentId == null || limit <= 0) return Set.of(); try { diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java index 4d87f387..c1624da0 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java +++ b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java @@ -99,6 +99,9 @@ public class SystemSettingService { */ private static final String WORKSPACE_STORAGE_ROOT_KEY = "workspace.storage_root"; + /** Managed DeepSeek Harness runtime configuration. */ + public static final String DSH_API_KEY_KEY = "dsh.api_key"; + private static final String ZHIPU_API_KEY_KEY = "zhipuApiKey"; private static final String ZHIPU_BASE_URL_KEY = "zhipuBaseUrl"; private static final String FAL_API_KEY_KEY = "falApiKey"; @@ -116,7 +119,7 @@ public class SystemSettingService { private static final Set SENSITIVE_KEYS = Set.of( SERPER_API_KEY_KEY, TAVILY_API_KEY_KEY, WEIXINOA_APP_SECRET_KEY, ZHIPU_API_KEY_KEY, FAL_API_KEY_KEY, KLING_ACCESS_KEY_KEY, KLING_SECRET_KEY_KEY, - RUNWAY_API_KEY_KEY, MINIMAX_API_KEY_KEY); + RUNWAY_API_KEY_KEY, MINIMAX_API_KEY_KEY, DSH_API_KEY_KEY); private final SystemSettingMapper systemSettingMapper; private final SearchProviderRegistry searchProviderRegistry; @@ -656,6 +659,11 @@ public class SystemSettingService { saveValue(key, value, description); } + /** Return a masked representation suitable for an admin status response. */ + public String maskSecret(String value) { + return maskApiKey(value); + } + private String getValue(String key, String defaultValue) { SystemSettingEntity entity = systemSettingMapper.selectOne(new LambdaQueryWrapper() .eq(SystemSettingEntity::getSettingKey, key) diff --git a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamWorkerConversationController.java b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamWorkerConversationController.java index 7a9a2b57..ae167ffd 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamWorkerConversationController.java +++ b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamWorkerConversationController.java @@ -27,7 +27,8 @@ public class TeamWorkerConversationController { @RequestParam(required = false) Long taskId, Authentication authentication) { String username = authentication == null ? "anonymous" : authentication.getName(); - if (!conversationService.isConversationOwner(conversationId, username)) { + if (!conversationService.isConversationOwner(conversationId, username) + && !governanceService.canReadTranscript(conversationId, runId, taskId, username)) { return R.fail(403, "无权访问该会话"); } return governanceService.resolve(conversationId, runId, taskId) diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java index c6dd34f4..92da9640 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java @@ -15,7 +15,9 @@ import vip.mate.team.model.AgentTeamEntity; import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskEventEntity; import vip.mate.team.model.TeamTaskStatus; +import vip.mate.tool.document.GeneratedFileCache; import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageEntity; import java.util.HashMap; import java.util.HashSet; @@ -28,6 +30,8 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Dispatches board tasks to their assigned member agents and closes the @@ -51,6 +55,32 @@ public class TeamDispatchService { /** Result summaries are capped before persisting to keep the board readable. */ static final int MAX_RESULT_CHARS = 8000; + /** Empty/fallback member runs get one recovery attempt, not three long identical runs. */ + static final int MAX_RESPONSE_FAILURE_DISPATCHES = 2; + + private static final Pattern GENERATED_FILE_MARKDOWN_LINK = Pattern.compile( + "\\[([^\\]\\r\\n]{1,200})]\\(((?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/[A-Za-z0-9-]+)\\)"); + + private static final Set CLARIFICATION_CUES = Set.of( + "您希望", + "你希望", + "是否继续", + "请问", + "能否", + "可以提供", + "请提供", + "需要您", + "需要你", + "我应该", + "如何处理", + "what would you like", + "how should i", + "could you provide", + "please provide", + "do you want me", + "should i", + "would you like"); + /** One JDK 21 virtual thread per member-agent run. */ private static final ExecutorService DISPATCH_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor(); @@ -151,6 +181,13 @@ public class TeamDispatchService { } dispatchedThisRound.add(assignee); TeamTaskEntity assigned = taskService.getTask(task.getId()); + // assignTask clears the persisted reason for a clean running state, + // but the worker needs the previous failure feedback or an + // automatic retry is just the same prompt sent to the same agent. + if (assigned != null && (assigned.getReason() == null || assigned.getReason().isBlank()) + && task.getReason() != null && !task.getReason().isBlank()) { + assigned.setReason(task.getReason()); + } DISPATCH_EXECUTOR.submit(() -> runTask(teamId, assigned)); } } @@ -248,13 +285,17 @@ public class TeamDispatchService { return; } if (TeamTaskStatus.IN_PROGRESS.equals(current.getStatus())) { - String invalidReason = invalidResultReason(current, reply); + boolean attachedGeneratedFile = attachGeneratedFileDeliverable(current, reply); + String invalidReason = invalidResultReason(current, reply, attachedGeneratedFile); if (invalidReason != null) { int attempts = current.getDispatchCount() == null ? 0 : current.getDispatchCount(); - if (attempts < TeamTaskService.MAX_DISPATCHES + int maxAttempts = isResponseGenerationFailure(invalidReason) + ? MAX_RESPONSE_FAILURE_DISPATCHES + : TeamTaskService.MAX_DISPATCHES; + if (attempts < maxAttempts && taskService.requeueUnusableResult(task.getId(), invalidReason)) { log.warn("Team task #{} produced an unusable result on attempt {}/{}; requeued: {}", - task.getTaskNumber(), attempts, TeamTaskService.MAX_DISPATCHES, + task.getTaskNumber(), attempts, maxAttempts, invalidReason); broadcast(task, "team_task_retrying", Map.of("reason", invalidReason)); return; @@ -316,7 +357,8 @@ public class TeamDispatchService { announceService.announceTaskSettled(current); } - private String invalidResultReason(TeamTaskEntity task, String reply) { + private String invalidResultReason(TeamTaskEntity task, String reply, + boolean attachedGeneratedFile) { if (reply == null || reply.isBlank()) { return "member produced no result"; } @@ -325,12 +367,73 @@ public class TeamDispatchService { || normalized.equals("(no output)")) { return "member response generation failed"; } - if (requiresDeliverable(task) && taskService.listDeliverables(task).isEmpty()) { + if (requiresDeliverable(task) && !attachedGeneratedFile + && taskService.listDeliverables(task).isEmpty()) { return "required deliverable was not attached"; } + if (looksLikeClarificationQuestion(reply)) { + return "member asked for clarification instead of producing a result"; + } return null; } + private boolean isResponseGenerationFailure(String reason) { + return "member produced no result".equals(reason) + || "member response generation failed".equals(reason); + } + + private boolean looksLikeClarificationQuestion(String reply) { + if (reply == null) { + return false; + } + String normalized = reply.strip().replaceAll("\\s+", " "); + if (normalized.isBlank() || normalized.length() > 800) { + return false; + } + String lower = normalized.toLowerCase(); + boolean hasCue = CLARIFICATION_CUES.stream().anyMatch(lower::contains); + if (!hasCue) { + return false; + } + return normalized.endsWith("?") + || normalized.endsWith("?") + || lower.contains("what would you like") + || lower.contains("how should i") + || lower.contains("could you provide") + || lower.contains("please provide") + || normalized.contains("请问") + || normalized.contains("是否继续") + || normalized.contains("如何处理") + || normalized.contains("请提供"); + } + + private boolean attachGeneratedFileDeliverable(TeamTaskEntity task, String reply) { + // A render link is a useful task artifact regardless of how the task + // was created. The metadata flag controls validation/retry semantics, + // not whether an otherwise valid generated file is discoverable in UI. + if (reply == null || reply.isBlank()) { + return false; + } + Matcher link = GENERATED_FILE_MARKDOWN_LINK.matcher(reply); + while (link.find()) { + String name = link.group(1).trim(); + String url = link.group(2).trim(); + if (!GeneratedFileCache.GENERATED_URL_PATTERN.matcher(url).matches()) { + continue; + } + try { + taskService.addDeliverable(task.getId(), task.getAssigneeAgentId(), name, url); + log.info("Team task #{} auto-attached generated deliverable from member reply: {}", + task.getTaskNumber(), name); + return true; + } catch (Exception e) { + log.warn("Team task #{} generated deliverable auto-attach failed for {}: {}", + task.getTaskNumber(), name, e.getMessage()); + } + } + return false; + } + private boolean requiresDeliverable(TeamTaskEntity task) { try { return task.getMetadata() != null @@ -343,9 +446,12 @@ public class TeamDispatchService { /** Per-prerequisite and whole-section caps keeping the envelope bounded. */ static final int MAX_PREREQ_RESULT_CHARS = 1500; static final int MAX_PREREQ_SECTION_CHARS = 6000; + static final int LEAD_ATTACHMENT_CONTEXT_MESSAGES = 12; + static final int MAX_LEAD_ATTACHMENT_ITEM_CHARS = 1200; + static final int MAX_LEAD_ATTACHMENT_SECTION_CHARS = 6000; - /** The full instruction envelope the member receives; it cannot see the lead's conversation. */ - private String buildDispatchContent(TeamTaskEntity task) { + /** The full instruction envelope the member receives. */ + String buildDispatchContent(TeamTaskEntity task) { StringBuilder sb = new StringBuilder(1024); sb.append("[Assigned team task #").append(task.getTaskNumber()) .append(" (taskId: ").append(task.getId()).append(")]\n") @@ -353,6 +459,14 @@ public class TeamDispatchService { if (task.getDescription() != null && !task.getDescription().isBlank()) { sb.append("\n").append(task.getDescription()).append('\n'); } + if (task.getDispatchCount() != null && task.getDispatchCount() > 1 + && task.getReason() != null && !task.getReason().isBlank()) { + sb.append("\n[Retry feedback]\n") + .append("The previous attempt was rejected: ") + .append(truncate(task.getReason().strip(), 500)) + .append(". Correct that failure in this attempt; do not repeat the same empty or fallback response.\n"); + } + appendLeadAttachmentContext(sb, task); appendPrerequisiteResults(sb, task); sb.append(""" @@ -360,11 +474,63 @@ public class TeamDispatchService { - Execute this task now. Your final reply becomes the task result reported to the team lead, so end with a complete, self-contained summary of what you produced. - Report milestones with team_tasks(action="progress", taskId=%s, percent=..., step=...). - If the output is a document, spreadsheet or presentation, generate a real file (renderDocx / renderXlsx / renderPptx or the docx/pptx/xlsx skills) and register it with team_tasks(action="attach", taskId=%s, name="", url=). Keep the result a summary — do not paste file contents. - - If you are missing an input you cannot obtain yourself, call team_tasks(action="comment", taskId=%s, type="blocker", text="what you need") and stop. + - If scope is ambiguous but you can make a reasonable assumption, state the assumption and continue. + - If you are missing an input you cannot obtain yourself, call team_tasks(action="comment", taskId=%s, type="blocker", text="what you need") and stop. Do not ask the lead or user for clarification in your final reply. """.formatted(task.getId(), task.getId(), task.getId())); return sb.toString(); } + /** + * Child worker conversations are isolated from the lead transcript, so + * upload paths from the lead turn must be copied into the dispatch + * envelope explicitly. Only rendered attachment/media rows with local + * paths are included; ordinary lead chat text stays out of the member + * prompt. + */ + void appendLeadAttachmentContext(StringBuilder sb, TeamTaskEntity task) { + String leadConversationId = task.getLeadConversationId(); + if (leadConversationId == null || leadConversationId.isBlank()) { + return; + } + List messages = conversationService.listRecentMessages( + leadConversationId, LEAD_ATTACHMENT_CONTEXT_MESSAGES); + if (messages == null || messages.isEmpty()) { + return; + } + StringBuilder section = new StringBuilder(); + for (MessageEntity message : messages) { + if (message == null || message.getContentParts() == null + || message.getContentParts().isBlank()) { + continue; + } + String rendered = conversationService.renderMessageContent(message, true); + if (rendered == null || rendered.isBlank() || !hasRenderedAttachmentPath(rendered)) { + continue; + } + section.append("- ") + .append(truncate(rendered.strip(), MAX_LEAD_ATTACHMENT_ITEM_CHARS) + .replace("\n", "\n ")) + .append('\n'); + } + if (section.isEmpty()) { + return; + } + sb.append("\n[Lead conversation attachments]\n") + .append(truncate(section.toString(), MAX_LEAD_ATTACHMENT_SECTION_CHARS)) + .append("Use these paths when this task refers to files uploaded in the lead conversation.\n"); + } + + private static boolean hasRenderedAttachmentPath(String rendered) { + if (!rendered.contains("路径:")) { + return false; + } + return rendered.contains("[附件]") + || rendered.contains("[图片]") + || rendered.contains("[视频]") + || rendered.contains("[音频]") + || rendered.contains("[3D 模型]"); + } + /** * Hand the member everything its prerequisites produced: result summaries * and deliverable links, so upstream output flows downstream without the diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunViewFactory.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunViewFactory.java index f4a9fdae..720fedb9 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunViewFactory.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunViewFactory.java @@ -191,11 +191,9 @@ final class TeamRunViewFactory { task.getId(), message == null ? task.getSubject() : message, task.getUpdateTime())); } } - String quality = outcomeQuality(run, tasks); - if ("fallback".equals(quality) || "partial".equals(quality)) { - items.add(new TeamRunView.AttentionItem("run:" + run.getId() + ":synthesis", "synthesis", - "warning", 10, null, "Final synthesis used a degraded outcome", run.getUpdateTime())); - } + // Synthesis quality is already exposed as outcomeQuality. A fallback + // summary is informative but has no user action, so it must not inflate + // the board's "needs attention" count. if (text(run.getStopReason()) != null) { items.add(new TeamRunView.AttentionItem("run:" + run.getId() + ":stopped", "stopped", "warning", 10, null, run.getStopReason(), run.getUpdateTime())); diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java index 5397d1f0..e18846c9 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java @@ -559,6 +559,13 @@ public class TeamTaskService { if (deliverables == null) { deliverables = new JSONArray(); } + for (Object item : deliverables) { + if (item instanceof JSONObject existing + && trimmedUrl.equals(existing.getStr("url"))) { + log.debug("Team task {} deliverable already attached: {}", taskId, trimmedUrl); + return; + } + } if (deliverables.size() >= MAX_DELIVERABLES) { throw new IllegalStateException("task #" + task.getTaskNumber() + " already has " + MAX_DELIVERABLES + " deliverables; consolidate outputs instead of adding more"); diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamWorkerConversationGovernanceService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamWorkerConversationGovernanceService.java index 1e0c308a..23ef8c98 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamWorkerConversationGovernanceService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamWorkerConversationGovernanceService.java @@ -3,10 +3,13 @@ package vip.mate.team.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.service.AuthService; import vip.mate.team.model.TeamRunEntity; import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.repository.TeamRunMapper; import vip.mate.team.repository.TeamTaskMapper; +import vip.mate.workspace.core.service.WorkspaceService; import vip.mate.workspace.conversation.model.ConversationEntity; import vip.mate.workspace.conversation.repository.ConversationMapper; import vip.mate.workspace.conversation.ConversationService; @@ -21,6 +24,8 @@ public class TeamWorkerConversationGovernanceService { private final TeamTaskMapper taskMapper; private final TeamRunMapper runMapper; private final ConversationMapper conversationMapper; + private final AuthService authService; + private final WorkspaceService workspaceService; public Optional resolve( String conversationId, Long requestedRunId, Long requestedTaskId) { @@ -58,4 +63,47 @@ public class TeamWorkerConversationGovernanceService { true, "team_worker", conversationId, run.getId(), task.getId(), run.getTeamId(), run.getLeadConversationId(), task.getAssigneeAgentId())); } + + /** + * Team worker transcripts are read-only evidence for a team run. The worker + * conversation is owned by the executing agent/user, so workspace admins and + * reviewers are not direct conversation owners. Allow them to read only when + * the persisted task/run/conversation linkage is canonical and they belong + * to that run's workspace. + */ + public boolean canReadTranscript(String conversationId, Long requestedRunId, Long requestedTaskId, + String username) { + TeamWorkerAccess access = resolveAccess(conversationId, requestedRunId, requestedTaskId); + if (access == null) { + return false; + } + UserEntity requester = authService.findByUsername(username); + if (requester == null) { + return false; + } + if ("admin".equalsIgnoreCase(requester.getRole())) { + return true; + } + return workspaceService.hasPermissionCached(access.workspaceId(), requester.getId(), "viewer"); + } + + private TeamWorkerAccess resolveAccess(String conversationId, Long requestedRunId, Long requestedTaskId) { + if (resolve(conversationId, requestedRunId, requestedTaskId).isEmpty()) { + return null; + } + TeamTaskEntity task = taskMapper.selectOne(new LambdaQueryWrapper() + .eq(TeamTaskEntity::getConversationId, conversationId) + .last("LIMIT 1")); + if (task == null || task.getRunId() == null) { + return null; + } + TeamRunEntity run = runMapper.selectById(task.getRunId()); + if (run == null || run.getWorkspaceId() == null) { + return null; + } + return new TeamWorkerAccess(run.getWorkspaceId()); + } + + private record TeamWorkerAccess(Long workspaceId) { + } } diff --git a/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java b/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java index 2d08a920..83a2cdeb 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java +++ b/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java @@ -31,6 +31,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.regex.Pattern; /** * Shared team task board exposed to the LLM. One multi-action tool (rather @@ -48,6 +49,10 @@ import java.util.Optional; @Slf4j public class TeamTasksTool { + private static final Pattern DELIVERABLE_REQUEST = Pattern.compile( + "(?i)(交付物|生成.{0,8}(文件|文档)|文档成稿|报告成稿|" + + "docx|xlsx|pptx|pdf|deliverable|document|spreadsheet|presentation)"); + private final TeamService teamService; private final TeamTaskService taskService; private final TeamRunService runService; @@ -201,6 +206,7 @@ public class TeamTasksTool { .blockedBy(parseIdList(blockedBy)) .requireApproval(Boolean.TRUE.equals(requireApproval)) .leadConversationId(conversationId) + .metadata(deliverableMetadata(subject, description)) .build()); eventChannel.publishTaskEvent(task, "team_task_created", Map.of()); return "✓ Created task #" + task.getTaskNumber() + " (id: " + task.getId() @@ -211,6 +217,17 @@ public class TeamTasksTool { + " Seal the run after all tasks are staged."; } + private String deliverableMetadata(String subject, String description) { + String taskText = (subject == null ? "" : subject) + "\n" + + (description == null ? "" : description); + if (!DELIVERABLE_REQUEST.matcher(taskText).find()) { + return null; + } + return new cn.hutool.json.JSONObject() + .set("deliverableRequired", true) + .toString(); + } + private String sealRun(AgentTeamEntity team, boolean isLead, Long workspaceId, String conversationId, String runId) { if (!isLead) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/ToolInputValidationException.java b/mateclaw-server/src/main/java/vip/mate/tool/ToolInputValidationException.java new file mode 100644 index 00000000..84feb8b2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/ToolInputValidationException.java @@ -0,0 +1,19 @@ +package vip.mate.tool; + +/** + * Signals an LLM-correctable tool argument error whose message is safe to + * return to the model, including for {@code returnDirect} tools. + * + *

    Ordinary exceptions from direct tools remain redacted because they can + * contain credentials, connection strings, or other sensitive internals. + */ +public class ToolInputValidationException extends RuntimeException { + + public ToolInputValidationException(String message) { + super(message); + } + + public ToolInputValidationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java index de320a1c..5362a982 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/ToolRegistry.java @@ -77,6 +77,28 @@ public class ToolRegistry { log.info("Plugin tool unregistered: {}", toolName); } + /** + * Snapshot plugin callbacks that are currently available to the runtime. + * Used by the agent tool picker so plugin tools can be bound per agent + * through the same {@code mate_agent_tool.tool_name} path as other tools. + */ + public List listAvailablePluginTools() { + List out = new ArrayList<>(); + for (PluginToolEntry entry : pluginTools) { + try { + if (entry.callback() != null && Boolean.TRUE.equals(entry.availabilityCheck().get())) { + out.add(entry.callback()); + } + } catch (Exception e) { + String name = entry.callback() != null && entry.callback().getToolDefinition() != null + ? entry.callback().getToolDefinition().name() + : ""; + log.warn("Plugin tool availability check failed for {}: {}", name, e.getMessage()); + } + } + return List.copyOf(out); + } + public void invalidateEnabledToolSetCache(String reason) { enabledToolSetCache = null; log.debug("Enabled AgentToolSet cache invalidated: {}", reason); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java index e3b56a82..9136b428 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java @@ -134,6 +134,7 @@ public class DelegateAgentTool { "addGoalCriterion", "completeGoal", "getGoalStatus", + "waitForGoalInput", // Employee authoring spawns persistent agents; a delegated child // doing so risks recursive team creation and privilege creep, so // it stays with the parent (same stance as delegate* recursion diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java index 36c4aa72..3174ea7c 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java @@ -7,6 +7,7 @@ import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.ai.chat.model.ToolContext; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; +import vip.mate.tool.ToolInputValidationException; import vip.mate.tool.document.FilenameSanitizer; import vip.mate.tool.document.GeneratedFileCache; import vip.mate.tool.document.GeneratedFileLink; @@ -40,7 +41,7 @@ public class DocxRenderTool { private final MarkdownDocxRenderer renderer; private final GeneratedFileCache cache; - @Tool(description = """ + @Tool(returnDirect = true, description = """ Render a new .docx (Microsoft Word) file from Markdown text and return a one-time download URL. Use for creating EDITABLE Word documents the user will continue to revise — reports, memos, contracts, letters, resumes. @@ -76,7 +77,9 @@ public class DocxRenderTool { @Nullable ToolContext ctx) { if (markdown == null || markdown.isBlank()) { - return "错误:markdown 参数为空,无法生成文档。"; + throw new ToolInputValidationException( + "markdown must not be blank; provide the document content, " + + "or write it to a .md file and call renderDocxFromFile"); } String displayName = FilenameSanitizer.sanitize(filename, "document", ".docx") + ".docx"; @@ -90,7 +93,7 @@ public class DocxRenderTool { return GeneratedFileLink.resultZh(bytes, displayName, DOCX_MIME, cache, "文档", ctx); } catch (Exception e) { log.error("[DocxRender] render failed for {}: {}", displayName, e.getMessage(), e); - return "渲染失败:" + e.getMessage(); + throw new IllegalStateException("DOCX rendering failed", e); } } @@ -105,7 +108,7 @@ public class DocxRenderTool { * the markdown locally → calls this tool with the file path → docx is * rendered from disk in one IO call. Token cost ≈ 50 (just the path). */ - @Tool(description = """ + @Tool(returnDirect = true, description = """ Render a .docx (Microsoft Word) file from a markdown FILE on disk and return a one-time download URL. Use this for EDITABLE Word documents only. @@ -142,7 +145,7 @@ public class DocxRenderTool { try { input = MarkdownInputResolver.readSingle(filePath); } catch (ResolveException e) { - return "Error: " + e.getMessage(); + throw new ToolInputValidationException(e.getMessage(), e); } String displayName = FilenameSanitizer.sanitize(filename, "document", ".docx") + ".docx"; @@ -157,7 +160,7 @@ public class DocxRenderTool { } catch (Exception e) { log.error("[DocxRender] render failed for {} (source: {}): {}", displayName, input.sources().get(0), e.getMessage(), e); - return "Render failed: " + e.getMessage(); + throw new IllegalStateException("DOCX rendering failed", e); } } @@ -172,7 +175,7 @@ public class DocxRenderTool { * Empty / missing files abort the render with a clear error so the agent * can fix its file list before retrying. */ - @Tool(description = """ + @Tool(returnDirect = true, description = """ Render a .docx by concatenating MULTIPLE markdown files in order and return a download URL. Use when a report is split into chapters / sections, or when the agent assembled the document piece by piece (cover, table of contents, body, @@ -202,7 +205,7 @@ public class DocxRenderTool { try { input = MarkdownInputResolver.readManyJoined(filePaths); } catch (ResolveException e) { - return "Error: " + e.getMessage(); + throw new ToolInputValidationException(e.getMessage(), e); } String displayName = FilenameSanitizer.sanitize(filename, "document", ".docx") + ".docx"; @@ -219,7 +222,7 @@ public class DocxRenderTool { } catch (Exception e) { log.error("[DocxRender] render failed for {} (sources: {}): {}", displayName, input.sources(), e.getMessage(), e); - return "Render failed: " + e.getMessage(); + throw new IllegalStateException("DOCX rendering failed", e); } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java index 8eed7643..c73fadd7 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/EditFileTool.java @@ -3,14 +3,18 @@ package vip.mate.tool.builtin; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; +import vip.mate.i18n.I18nService; +import vip.mate.tool.ConcurrencyUnsafe; +import vip.mate.tool.guard.WorkspacePathGuard; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; /** * 内置工具:编辑文件(查找替换) @@ -31,9 +35,9 @@ import java.nio.file.Paths; @lombok.RequiredArgsConstructor public class EditFileTool { - private final vip.mate.i18n.I18nService i18n; + private final I18nService i18n; - @vip.mate.tool.ConcurrencyUnsafe("in-place file edit — must not race with reads/writes on the same path") + @ConcurrencyUnsafe("in-place file edit — must not race with reads/writes on the same path") @Tool(description = "Edit file content via find-and-replace. Finds exact match of old_text and replaces with new_text. " + "Returns structured JSON with filePath, replacements count. " + "May require user approval when security rules flag the edit. " @@ -42,7 +46,8 @@ public class EditFileTool { @ToolParam(description = "Absolute or relative file path") String filePath, @ToolParam(description = "Original text to find (exact match)") String oldText, @ToolParam(description = "Replacement text") String newText, - @ToolParam(description = "Replace all occurrences, default false (first only)", required = false) Boolean replaceAll) { + @ToolParam(description = "Replace all occurrences, default false (first only)", required = false) Boolean replaceAll, + @Nullable ToolContext ctx) { JSONObject result = new JSONObject(); result.set("filePath", filePath); @@ -63,7 +68,7 @@ public class EditFileTool { Path path; try { - path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath); + path = WorkspacePathGuard.validatePath(filePath, ctx); } catch (IllegalArgumentException e) { return errorResult(filePath, e.getMessage()); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java index 94195a1c..63f1784a 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java @@ -28,7 +28,7 @@ import java.util.Map; * an objective that spans multiple turns — the runtime then tracks * progress across the entire conversation. * - *

    All four tool names are added to + *

    All goal tool names are added to * {@code DelegateAgentTool.DEFAULT_CHILD_DENIED_TOOLS} so a child agent * cannot mutate the parent conversation's goal. Goal ownership is bound * to the parent conversation, period. @@ -56,7 +56,7 @@ public class GoalManagementTool { required = false) String description, @ToolParam(description = "Exit criteria the evaluator scores against (e.g. 'tests pass + deployed').", required = false) String exitCriteria, - @ToolParam(description = "Max evaluation turns before exhaustion. Default 20.", + @ToolParam(description = "Optional evaluation-turn cap. Persistent goals default to unlimited (0); positive values pause execution at the cap. Legacy goals default to 20.", required = false) Integer turnBudget, @ToolParam(description = "If true, the agent may auto-followup when progress is incomplete. " + "Omit to use the system default.", @@ -211,6 +211,44 @@ public class GoalManagementTool { return successJson(out); } + @Tool(description = """ + Pause the current persistent goal only when essential user input or permission \ + is missing after inspecting available state, files, and existing async handles. \ + Give the precise missing information or authorization and what was checked; \ + ask the user once, then wait for explicit resume. Do not use this because of \ + difficulty, elapsed time, incomplete work, or a transient error. This pauses \ + the goal without marking it complete.""") + public String waitForGoalInput( + @ToolParam(description = "Precise essential input or permission missing, why it is necessary, " + + "and which available state was checked first.") String reason, + @Nullable ToolContext ctx) { + if (!properties.isEnabled()) return errorJson("Goal subsystem is disabled"); + if (reason == null || reason.isBlank()) { + return errorJson("A precise reason describing the missing input or permission is required"); + } + ChatOrigin origin = ChatOrigin.from(ctx); + if (origin == null || origin.conversationId() == null || origin.conversationId().isBlank()) { + return errorJson("waitForGoalInput requires a bound conversation context"); + } + GoalEntity goal = resolveActive(ctx); + if (goal == null || goal.getStatus() != GoalStatus.ACTIVE + || !origin.conversationId().equals(goal.getConversationId())) { + return errorJson("No active goal on this conversation"); + } + if (!Boolean.TRUE.equals(goal.getPersistentExecution())) { + return errorJson("Waiting for input requires a persistent goal"); + } + try { + GoalEntity paused = goalService.waitForInput(goal.getId(), reason.trim(), resolveUsername(ctx)); + broadcastGoalEvent(paused.getConversationId(), "goal_updated", paused); + return successJson(Map.of("goalId", String.valueOf(paused.getId()), + "status", paused.getStatus().getValue(), "waitingForInput", true, + "reason", paused.getProgressSummary() == null ? "" : paused.getProgressSummary())); + } catch (MateClawException error) { + return errorJson(error.getMessage()); + } + } + // ==================== Internals ==================== private GoalEntity resolveActive(ToolContext ctx) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPackageTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPackageTool.java index c59c8873..b9faa3bb 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPackageTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPackageTool.java @@ -295,7 +295,7 @@ public class GzhPackageTool { // 1. Explicit generated-file id — trust only a live image entry. Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(r); if (m.find()) { - Optional e = cache.get(m.group(1)); + Optional e = cache.getForWorkspace(m.group(1), workspaceFromContext(ctx)); if (e.isPresent() && isImage(e.get()) && hasBytes(e.get())) { return new ResolvedCover(e.get().bytes(), cache.downloadUrl(m.group(1), ctx)); } @@ -306,7 +306,7 @@ public class GzhPackageTool { if (name != null && !name.isBlank()) { Optional healed = cache.findIdByFilename(name, "image/"); if (healed.isPresent()) { - Optional e = cache.get(healed.get()); + Optional e = cache.getForWorkspace(healed.get(), workspaceFromContext(ctx)); if (e.isPresent() && hasBytes(e.get())) { log.info("[GzhPackage] cover ref '{}' healed to generated id {} by filename", r, healed.get()); return new ResolvedCover(e.get().bytes(), cache.downloadUrl(healed.get(), ctx)); @@ -379,7 +379,7 @@ public class GzhPackageTool { } private String store(byte[] bytes, String name, String mime, @Nullable ToolContext ctx) { - String id = cache.put(bytes, name, mime); + String id = cache.put(bytes, name, mime, ctx); return cache.downloadUrl(id, ctx); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java index 9c2d437a..0bba3157 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java @@ -139,7 +139,7 @@ public class SendFileTool { } private String stash(byte[] bytes, String displayName, String mimeType, @Nullable ToolContext ctx) { - String id = cache.put(bytes, displayName, mimeType); + String id = cache.put(bytes, displayName, mimeType, ctx); return cache.downloadUrl(id, ctx); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java index 8bd6ce5b..b7edc4e1 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WriteFileTool.java @@ -3,14 +3,18 @@ package vip.mate.tool.builtin; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; +import vip.mate.i18n.I18nService; +import vip.mate.tool.ConcurrencyUnsafe; +import vip.mate.tool.guard.WorkspacePathGuard; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; /** * 内置工具:写入文件 @@ -31,15 +35,16 @@ import java.nio.file.Paths; @lombok.RequiredArgsConstructor public class WriteFileTool { - private final vip.mate.i18n.I18nService i18n; + private final I18nService i18n; - @vip.mate.tool.ConcurrencyUnsafe("file write — must serialize with reads/writes on overlapping paths") + @ConcurrencyUnsafe("file write — must serialize with reads/writes on overlapping paths") @Tool(description = "Write content to a file. Overwrites if exists, creates if not (auto-creates parent directories). " + "Returns structured JSON with filePath, bytesWritten. " + "May require user approval when security rules flag the write.") public String write_file( @ToolParam(description = "Absolute or relative file path") String filePath, - @ToolParam(description = "Content to write to the file") String content) { + @ToolParam(description = "Content to write to the file") String content, + @Nullable ToolContext ctx) { JSONObject result = new JSONObject(); result.set("filePath", filePath); @@ -54,7 +59,7 @@ public class WriteFileTool { Path path; try { - path = vip.mate.tool.guard.WorkspacePathGuard.validatePath(filePath); + path = WorkspacePathGuard.validatePath(filePath, ctx); } catch (IllegalArgumentException e) { return errorResult(filePath, e.getMessage()); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/XhsPackageTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/XhsPackageTool.java index 31bde690..079859b0 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/XhsPackageTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/XhsPackageTool.java @@ -281,13 +281,14 @@ public class XhsPackageTool { Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(ref); if (m.find()) { String id = m.group(1); - Optional entry = cache.get(id); + Long workspaceId = workspaceFromContext(ctx); + Optional entry = cache.getForWorkspace(id, workspaceId); if (entry.isEmpty() || entry.get().bytes() == null || entry.get().bytes().length == 0) { // Self-heal: the ref may point at the file's name, not its id. Optional healed = cache.findIdByFilename(lastSegment(ref), "image/"); if (healed.isPresent()) { id = healed.get(); - entry = cache.get(id); + entry = cache.getForWorkspace(id, workspaceId); } } if (entry.isEmpty() || entry.get().bytes() == null || entry.get().bytes().length == 0) { @@ -311,12 +312,12 @@ public class XhsPackageTool { } byte[] bytes = Files.readAllBytes(path); String ext = extFromUrl(ref); - String id = cache.put(bytes, path.getFileName().toString(), mimeFromExt(ext)); + String id = cache.put(bytes, path.getFileName().toString(), mimeFromExt(ext), ctx); return new ResolvedImg(bytes, ext, cache.downloadUrl(id, ctx)); } private String store(byte[] bytes, String name, String mime, @Nullable ToolContext ctx) { - return cache.downloadUrl(cache.put(bytes, name, mime), ctx); + return cache.downloadUrl(cache.put(bytes, name, mime, ctx), ctx); } private String guideText() { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java index e5fa9c2b..7f5ea3b5 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java @@ -37,7 +37,8 @@ import java.util.stream.Stream; * cache eviction and a JVM restart, so a link a user clicks minutes — or days — * after generation still resolves instead of 404ing. Entries are retained for * {@link #TTL} and a scheduled sweep removes expired files. The download URL - * embeds a random {@link UUID}, which acts as the only access credential. + * embeds a random {@link UUID}; web downloads additionally verify the stored + * workspace owner so links cannot cross workspace boundaries. */ @Slf4j @Component @@ -132,7 +133,20 @@ public class GeneratedFileCache { } } - public record Entry(byte[] bytes, String filename, String mimeType, long expireAt) { + public record Owner(@Nullable Long workspaceId, + @Nullable Long ownerUserId, + @Nullable String conversationId) { + + public static Owner from(@Nullable ToolContext ctx) { + ChatOrigin origin = ChatOrigin.from(ctx); + return new Owner(origin.workspaceId(), origin.requesterUserId(), origin.conversationId()); + } + } + + public record Entry(byte[] bytes, String filename, String mimeType, long expireAt, + @Nullable Long workspaceId, + @Nullable Long ownerUserId, + @Nullable String conversationId) { public boolean expired() { return System.currentTimeMillis() > expireAt; @@ -145,9 +159,20 @@ public class GeneratedFileCache { * {@code /api/v1/files/generated/{id}}. */ public String put(byte[] bytes, String filename, String mimeType) { + return put(bytes, filename, mimeType, (Owner) null); + } + + public String put(byte[] bytes, String filename, String mimeType, @Nullable ToolContext ctx) { + return put(bytes, filename, mimeType, Owner.from(ctx)); + } + + public String put(byte[] bytes, String filename, String mimeType, @Nullable Owner owner) { String id = UUID.randomUUID().toString(); long expireAt = System.currentTimeMillis() + TTL.toMillis(); - Entry entry = new Entry(bytes, filename, mimeType, expireAt); + Entry entry = new Entry(bytes, filename, mimeType, expireAt, + owner != null ? owner.workspaceId() : null, + owner != null ? owner.ownerUserId() : null, + owner != null ? owner.conversationId() : null); entries.put(id, entry); persist(id, entry); log.debug("Cached generated file id={} filename={} bytes={}", id, filename, @@ -236,6 +261,21 @@ public class GeneratedFileCache { return Optional.of(entry); } + public Optional getForWorkspace(String id, @Nullable Long workspaceId) { + Optional entry = get(id); + if (entry.isEmpty()) { + return Optional.empty(); + } + Long ownerWorkspaceId = entry.get().workspaceId(); + if (ownerWorkspaceId == null) { + return entry; + } + if (workspaceId == null || !ownerWorkspaceId.equals(workspaceId)) { + return Optional.empty(); + } + return entry; + } + /** * Best-effort lookup of a live entry's id by its logical filename, optionally * constrained to a mime-type prefix (e.g. {@code "image/"}). Scans the @@ -279,20 +319,17 @@ public class GeneratedFileCache { long now = System.currentTimeMillis(); for (Path metaPath : metas) { try { - String[] parts = Files.readString(metaPath).split("\t", 3); - if (Long.parseLong(parts[0].trim()) <= now) { + Metadata meta = parseMeta(Files.readString(metaPath), idFromMetaPath(metaPath)); + if (meta.expireAt() <= now) { continue; } - String mime = parts.length > 1 && !parts[1].isEmpty() ? parts[1] : null; + String mime = meta.mimeType(); if (mimePrefix != null && (mime == null || !mime.startsWith(mimePrefix))) { continue; } - String fn = parts.length > 2 && !parts[2].isEmpty() - ? new String(Base64.getDecoder().decode(parts[2]), StandardCharsets.UTF_8) - : null; + String fn = meta.filename(); if (fn != null && target.equalsIgnoreCase(fn)) { - String name = metaPath.getFileName().toString(); - return Optional.of(name.substring(0, name.length() - META_SUFFIX.length())); + return Optional.of(idFromMetaPath(metaPath)); } } catch (Exception ignore) { // Skip unreadable / malformed meta. @@ -307,12 +344,15 @@ public class GeneratedFileCache { } try { Files.write(storageDir.resolve(id), entry.bytes()); - // expireAt \t mimeType \t base64(filename) — filename is base64-encoded - // so arbitrary unicode / separators round-trip without escaping. + // expireAt \t mimeType \t base64(filename) \t workspaceId + // \t ownerUserId \t base64(conversationId). Base64 keeps unicode and + // separators round-trippable without custom escaping. String meta = entry.expireAt() + "\t" + (entry.mimeType() == null ? "" : entry.mimeType()) - + "\t" + Base64.getEncoder().encodeToString( - (entry.filename() == null ? "" : entry.filename()).getBytes(StandardCharsets.UTF_8)); + + "\t" + b64(entry.filename()) + + "\t" + (entry.workspaceId() == null ? "" : entry.workspaceId()) + + "\t" + (entry.ownerUserId() == null ? "" : entry.ownerUserId()) + + "\t" + b64(entry.conversationId()); Files.writeString(storageDir.resolve(id + META_SUFFIX), meta); } catch (IOException e) { // Best-effort: an in-memory entry still serves the current process. @@ -328,20 +368,54 @@ public class GeneratedFileCache { return null; } try { - String[] parts = Files.readString(meta).split("\t", 3); - long expireAt = Long.parseLong(parts[0].trim()); - String mimeType = parts.length > 1 && !parts[1].isEmpty() ? parts[1] : null; - String filename = parts.length > 2 && !parts[2].isEmpty() - ? new String(Base64.getDecoder().decode(parts[2]), StandardCharsets.UTF_8) - : id; + Metadata parsed = parseMeta(Files.readString(meta), id); byte[] bytes = Files.readAllBytes(bin); - return new Entry(bytes, filename, mimeType, expireAt); + return new Entry(bytes, parsed.filename(), parsed.mimeType(), parsed.expireAt(), + parsed.workspaceId(), parsed.ownerUserId(), parsed.conversationId()); } catch (Exception e) { log.warn("Could not load generated file id={}: {}", id, e.toString()); return null; } } + private record Metadata(long expireAt, @Nullable String mimeType, String filename, + @Nullable Long workspaceId, @Nullable Long ownerUserId, + @Nullable String conversationId) {} + + private static Metadata parseMeta(String raw, String fallbackFilename) { + String[] parts = raw.split("\t", -1); + long expireAt = Long.parseLong(parts[0].trim()); + String mimeType = parts.length > 1 && !parts[1].isEmpty() ? parts[1] : null; + String filename = parts.length > 2 && !parts[2].isEmpty() ? fromB64(parts[2]) : fallbackFilename; + Long workspaceId = parts.length > 3 ? parseLongOrNull(parts[3]) : null; + Long ownerUserId = parts.length > 4 ? parseLongOrNull(parts[4]) : null; + String conversationId = parts.length > 5 && !parts[5].isEmpty() ? fromB64(parts[5]) : null; + return new Metadata(expireAt, mimeType, filename, workspaceId, ownerUserId, conversationId); + } + + private static Long parseLongOrNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return Long.parseLong(value.trim()); + } + + private static String b64(@Nullable String value) { + if (value == null || value.isEmpty()) { + return ""; + } + return Base64.getEncoder().encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private static String fromB64(String value) { + return new String(Base64.getDecoder().decode(value), StandardCharsets.UTF_8); + } + + private static String idFromMetaPath(Path metaPath) { + String name = metaPath.getFileName().toString(); + return name.substring(0, name.length() - META_SUFFIX.length()); + } + private void evict(String id) { entries.remove(id); try { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java index 9b1608cc..f7546eef 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java @@ -6,10 +6,15 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.service.AuthService; +import vip.mate.workspace.core.service.WorkspaceService; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -18,8 +23,8 @@ import java.util.Map; /** * Serves bytes produced by tools and stashed in {@link GeneratedFileCache}. * - *

    Endpoint is intentionally unauthenticated; the UUID in the URL is the only - * access credential. Entries expire after {@link GeneratedFileCache#TTL}. + *

    Entries expire after {@link GeneratedFileCache#TTL}. Web downloads require + * an authenticated caller in the same current workspace as the generated file. */ @Tag(name = "Generated Files") @RestController @@ -28,16 +33,27 @@ import java.util.Map; public class GeneratedFileController { private final GeneratedFileCache cache; + private final AuthService authService; + private final WorkspaceService workspaceService; @Operation(summary = "Download a tool-generated file by its one-time id") @GetMapping("/{id}") - public ResponseEntity download(@PathVariable String id) { + public ResponseEntity download(@PathVariable String id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + Authentication authentication) { + UserEntity user = resolveUser(authentication); + if (user == null) { + return ResponseEntity.status(401).body(Map.of("error", "Unauthorized")); + } return cache.get(id) + .filter(entry -> canDownload(entry, workspaceId, user)) .>map(entry -> { String encodedName = URLEncoder.encode(entry.filename(), StandardCharsets.UTF_8) .replace("+", "%20"); HttpHeaders headers = new HttpHeaders(); - String mime = entry.mimeType(); + String mime = entry.mimeType() == null || entry.mimeType().isBlank() + ? "application/octet-stream" + : entry.mimeType(); headers.setContentType(MediaType.parseMediaType(mime)); // RFC 5987 filename* lets non-ASCII names round-trip in browsers. // Images and HTML previews render inline; everything else downloads. @@ -60,8 +76,30 @@ public class GeneratedFileController { headers.setContentLength(entry.bytes().length); return ResponseEntity.ok().headers(headers).body(entry.bytes()); }) - .orElseGet(() -> ResponseEntity.status(404) - .body(Map.of("error", "File not found or expired"))); + .orElseGet(() -> cache.get(id).isPresent() + ? ResponseEntity.status(403).body(Map.of("error", "Workspace permission denied")) + : ResponseEntity.status(404).body(Map.of("error", "File not found or expired"))); + } + + private UserEntity resolveUser(Authentication authentication) { + if (authentication == null || authentication.getName() == null) { + return null; + } + return authService.findByUsername(authentication.getName()); + } + + private boolean canDownload(GeneratedFileCache.Entry entry, Long currentWorkspaceId, UserEntity user) { + Long ownerWorkspaceId = entry.workspaceId(); + if (ownerWorkspaceId == null) { + return true; + } + if (currentWorkspaceId == null || !ownerWorkspaceId.equals(currentWorkspaceId)) { + return false; + } + if ("admin".equalsIgnoreCase(user.getRole())) { + return true; + } + return workspaceService.hasPermissionCached(ownerWorkspaceId, user.getId(), "viewer"); } private String sanitizeAscii(String name) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java index a57eec2f..90ae770e 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java @@ -59,7 +59,7 @@ public final class GeneratedFileLink { private static String stash(byte[] bytes, String displayName, String mimeType, GeneratedFileCache cache, @Nullable ToolContext ctx) { - String id = cache.put(bytes, displayName, mimeType); + String id = cache.put(bytes, displayName, mimeType, ctx); return cache.downloadUrl(id, ctx); } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/WorkspaceArtifactSurfacer.java b/mateclaw-server/src/main/java/vip/mate/tool/document/WorkspaceArtifactSurfacer.java index 9e679aa1..453dfa49 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/WorkspaceArtifactSurfacer.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/WorkspaceArtifactSurfacer.java @@ -67,7 +67,7 @@ public final class WorkspaceArtifactSurfacer { byte[] bytes = Files.readAllBytes(p); totalBytes += size; String name = p.getFileName().toString(); - String id = cache.put(bytes, name, probeMime(p, name)); + String id = cache.put(bytes, name, probeMime(p, name), ctx); links.add("[" + name + "](" + cache.downloadUrl(id, ctx) + ")"); } catch (Exception perFile) { log.debug("[ArtifactSurfacer] skip {}: {}", p, perFile.getMessage()); @@ -81,8 +81,7 @@ public final class WorkspaceArtifactSurfacer { private static boolean modifiedSince(Path p, long sinceMillis) { try { - // 1s slack absorbs filesystem mtime granularity. - return Files.getLastModifiedTime(p).toMillis() >= sinceMillis - 1000L; + return Files.getLastModifiedTime(p).toMillis() >= sinceMillis; } catch (Exception e) { return false; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java index 615207bd..971f0f40 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java @@ -9,6 +9,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.event.EventListener; +import org.springframework.context.event.ContextClosedEvent; import org.springframework.stereotype.Service; import vip.mate.exception.MateClawException; import vip.mate.tool.mcp.event.McpConnectionLostEvent; @@ -26,6 +27,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.regex.Pattern; /** @@ -60,9 +62,24 @@ public class McpServerService { return t; }); + /** Set before bean destruction so transport exit callbacks cannot heal a shutting-down app. */ + private final AtomicBoolean shuttingDown = new AtomicBoolean(false); + + @EventListener + public void onContextClosed(ContextClosedEvent ignored) { + beginShutdown(); + } + @PreDestroy public void shutdownConnectExecutor() { - connectExecutor.shutdownNow(); + beginShutdown(); + } + + private void beginShutdown() { + if (shuttingDown.compareAndSet(false, true)) { + log.info("MCP reconnect service stopping; new connect/reconnect requests are disabled"); + connectExecutor.shutdownNow(); + } } /** @@ -87,6 +104,11 @@ public class McpServerService { */ @EventListener public void onConnectionLost(McpConnectionLostEvent event) { + if (shuttingDown.get()) { + log.debug("Ignoring MCP connection-lost event during application shutdown: serverId={}, reason={}", + event.serverId(), event.reason()); + return; + } Long serverId = event.serverId(); if (serverId == null) { return; @@ -407,12 +429,18 @@ public class McpServerService { * caller's request thread returns at once. */ private void connectAsync(McpServerEntity server) { + if (shuttingDown.get()) { + return; + } updateStatus(server.getId(), "connecting", null, 0); connectExecutor.submit(() -> connectSync(server)); } /** Async counterpart of {@link #reconnectSync}. See {@link #connectAsync}. */ private void reconnectAsync(McpServerEntity server) { + if (shuttingDown.get()) { + return; + } updateStatus(server.getId(), "connecting", null, 0); connectExecutor.submit(() -> reconnectSync(server)); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model/AvailableToolDTO.java b/mateclaw-server/src/main/java/vip/mate/tool/model/AvailableToolDTO.java index 91eaa6c2..bf90d238 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/model/AvailableToolDTO.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/model/AvailableToolDTO.java @@ -9,7 +9,8 @@ import lombok.NoArgsConstructor; * Picker DTO for the unified agent tool selector. * *

    One row per atomic tool the agent can be bound to — built-in tools - * appear under {@code source="builtin"}, MCP tools appear under + * appear under {@code source="builtin"}, plugin callbacks appear under + * {@code source="plugin"}, MCP tools appear under * {@code source="mcp"} and are grouped by their server. The {@link #name} * field is the value the UI saves into {@code mate_agent_tool.tool_name}; * for MCP tools it is the prefixed callback name returned by the resolver @@ -29,7 +30,7 @@ public class AvailableToolDTO { */ private String rowId; - /** {@code "builtin"} or {@code "mcp"}. */ + /** {@code "builtin"}, {@code "channel"}, {@code "plugin"}, or {@code "mcp"}. */ private String source; /** MCP server id when {@code source == "mcp"}; null otherwise. */ @@ -123,6 +124,23 @@ public class AvailableToolDTO { .build(); } + public static AvailableToolDTO fromPlugin(String name, String description) { + return AvailableToolDTO.builder() + .rowId("plugin#" + name) + .source("plugin") + .providerId(null) + .providerName(null) + .name(name) + .rawName(name) + .description(description != null ? description : "") + .group("Plugin tools") + .groupId("plugin") + .stale(false) + .available(true) + .unavailableReason(null) + .build(); + } + private static String extractChannelName(String displayName) { if (displayName == null) return ""; int open = displayName.lastIndexOf('('); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/service/AvailableToolService.java b/mateclaw-server/src/main/java/vip/mate/tool/service/AvailableToolService.java index d21cbd74..7438dd60 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/service/AvailableToolService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/service/AvailableToolService.java @@ -5,7 +5,9 @@ import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.ToolCallback; import org.springframework.stereotype.Service; +import vip.mate.tool.ToolRegistry; import vip.mate.tool.mcp.model.McpServerEntity; import vip.mate.tool.mcp.runtime.McpHashCollisionDetector; import vip.mate.tool.mcp.service.McpServerService; @@ -19,8 +21,9 @@ import java.util.List; * Aggregator behind {@code GET /api/v1/tools/available}. * *

    Returns one DTO per atomic tool the agent edit picker can offer: - * built-in tools (from {@link ToolService#listEnabledTools()}) plus every - * MCP tool persisted in {@link McpServerEntity#getToolsCacheJson()}. + * built-in tools (from {@link ToolService#listEnabledTools()}), plugin + * callbacks registered in {@link ToolRegistry}, plus every MCP tool + * persisted in {@link McpServerEntity#getToolsCacheJson()}. * *

    Reads the cache rather than making a live MCP {@code listTools()} * roundtrip so the picker stays fast and stable through brief upstream @@ -33,17 +36,11 @@ import java.util.List; * this, the user could save a {@code mate_agent_tool.tool_name} that * resolves to nothing at chat time. * - *

    Scope: this aggregator covers the two tool sources users can - * bind from the agent edit screen — built-in {@code @Tool} beans - * (persisted in {@code mate_tool}) and MCP-discovered tools (cached on - * the server row). Plugin-registered {@code ToolCallback} beans surfaced - * by other parts of the runtime are intentionally NOT listed here: those - * are not user-bindable from the agent picker today, and the picker's - * "saved name == runtime callback key" contract only needs to hold for - * the rows the picker actually emits. If plugin tools later become - * user-bindable, extend this aggregator (or accept that they go through - * a separate config path) — see {@code AgentBindingService}'s - * {@code SYSTEM_LEVEL_TOOLS} carve-out for the same reasoning. + *

    Scope: this aggregator covers the tool sources users can bind + * from the agent edit screen. Every emitted {@code name} must match the + * runtime callback key accepted by {@code AgentToolSet}; otherwise the UI + * could persist a {@code mate_agent_tool.tool_name} row that resolves to + * nothing at chat time. */ @Slf4j @Service @@ -52,10 +49,12 @@ public class AvailableToolService { private final ToolService toolService; private final McpServerService mcpServerService; + private final ToolRegistry toolRegistry; public List listAvailable() { List out = new ArrayList<>(); appendBuiltinTools(out); + appendPluginTools(out); appendMcpTools(out); return out; } @@ -74,6 +73,30 @@ public class AvailableToolService { } } + private void appendPluginTools(List out) { + List callbacks; + try { + callbacks = toolRegistry.listAvailablePluginTools(); + } catch (Exception e) { + log.warn("AvailableToolService: listAvailable plugin tools failed: {}", e.getMessage()); + return; + } + for (ToolCallback callback : callbacks) { + try { + if (callback == null || callback.getToolDefinition() == null) { + continue; + } + String name = callback.getToolDefinition().name(); + if (name == null || name.isBlank()) { + continue; + } + out.add(AvailableToolDTO.fromPlugin(name, callback.getToolDefinition().description())); + } catch (Exception e) { + log.warn("AvailableToolService: skipping plugin tool due to: {}", e.getMessage()); + } + } + } + private void appendMcpTools(List out) { List servers; try { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfileService.java b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfileService.java index a0592eaa..f9c4f550 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfileService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfileService.java @@ -33,6 +33,13 @@ public class WikiPageTypeProfileService { private final WikiPageTypeProfileMapper profileMapper; private final ObjectMapper objectMapper; + /** + * Reserved page types are internal control-plane types. User profiles must + * never route business pages into them, because downstream list/search and + * cleanup paths intentionally treat them specially. + */ + private static final Set RESERVED_PAGE_TYPES = Set.of("system", "synthesis"); + /** Parsed once at startup; immutable thereafter. */ private WikiPageTypeProfile defaultProfile; @@ -209,10 +216,9 @@ public class WikiPageTypeProfileService { * @throws IllegalArgumentException when {@code configJson} does not parse */ public void saveProfile(Long kbId, String name, String configJson) { - try { - objectMapper.readValue(configJson, WikiPageTypeProfile.class); - } catch (Exception e) { - throw new IllegalArgumentException("Invalid profile config JSON: " + e.getMessage()); + java.util.List issues = validateProfileJson(configJson); + if (!issues.isEmpty()) { + throw new IllegalArgumentException(String.join("; ", issues)); } WikiPageTypeProfileEntity existing = findEnabledRow(kbId); if (existing != null) { @@ -262,6 +268,16 @@ public class WikiPageTypeProfileService { issues.add("Profile declares no pageTypes"); return issues; } + profile.getPageTypes().keySet().forEach(typeName -> { + String normalized = normalizeTypeName(typeName); + if (RESERVED_PAGE_TYPES.contains(normalized)) { + issues.add(typeName + ": reserved pageType is not allowed in user profiles"); + } + }); + String fallback = normalizeTypeName(profile.getFallbackType()); + if (RESERVED_PAGE_TYPES.contains(fallback)) { + issues.add("reserved fallbackType is not allowed: " + fallback); + } java.util.Set validTypes = java.util.Set.of( "string", "number", "boolean", "date", "enum", "string_array"); profile.getPageTypes().forEach((typeName, def) -> { @@ -283,15 +299,40 @@ public class WikiPageTypeProfileService { /** * Normalise a routed/created pageType against the KB profile: a declared - * type is returned as-is (lowercase); an unknown type is downgraded to the - * profile's {@code fallbackType}. Never returns null. + * type is returned as-is (lowercase); an unknown or reserved type is + * downgraded to a safe fallback. Never returns null. */ public String normalizePageType(Long kbId, String pageType) { WikiPageTypeProfile profile = resolveProfile(kbId); - if (pageType != null && profile.hasPageType(pageType)) { - return pageType.trim().toLowerCase(); + String normalized = normalizeTypeName(pageType); + if (normalized != null && !RESERVED_PAGE_TYPES.contains(normalized) + && profile.hasPageType(normalized)) { + return normalized; } - String fallback = profile.getFallbackType(); - return fallback == null ? "concept" : fallback.trim().toLowerCase(); + return safeFallbackType(profile); + } + + private static String safeFallbackType(WikiPageTypeProfile profile) { + String fallback = profile == null ? null : normalizeTypeName(profile.getFallbackType()); + if (profile != null && fallback != null && !RESERVED_PAGE_TYPES.contains(fallback) + && profile.hasPageType(fallback)) { + return fallback; + } + if (profile != null && profile.hasPageType("concept")) { + return "concept"; + } + return profile == null ? "concept" : profile.getPageTypes().keySet().stream() + .map(WikiPageTypeProfileService::normalizeTypeName) + .filter(type -> type != null && !RESERVED_PAGE_TYPES.contains(type)) + .findFirst() + .orElse("concept"); + } + + private static String normalizeTypeName(String type) { + if (type == null) { + return null; + } + String normalized = type.trim().toLowerCase(); + return normalized.isEmpty() ? null : normalized; } } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 0961110f..5e7171e2 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -120,7 +120,7 @@ public class ConversationService { /** * Workspace-scoped variant of {@link #listConversations(String)}. * - *

    Strict ownership: only the user's own + {@code system} rows. Used by + *

    Strict ownership: only the user's own rows. Used by * callers that must not see other principals' conversations — notably the * webchat visitor self-service path, which scopes to one visitor. * @@ -131,32 +131,25 @@ public class ConversationService { } /** - * Admin-console variant. When {@code includeChannelPrincipals} is true, also - * returns conversations owned by external channel principals - * ({@code webchat:}) so the console surfaces webchat threads - * alongside the user's own + {@code system} rows — the same way IM-channel - * ({@code system}-owned) conversations already appear. The visitor-facing - * webchat endpoints keep using the strict overload, so this does not widen a - * visitor's own access. + * Admin-console variant. Ordinary users only see their own rows. When + * {@code includeChannelPrincipals} is true, global admins additionally see + * shared system/channel-principal conversations for inspection. The + * visitor-facing webchat endpoints keep using the strict overload, so this + * does not widen a visitor's own access. * *

    控制台变体:includeChannelPrincipals 为 true 时额外纳入 webchat 访客会话。 */ public List listConversations(String username, Long workspaceId, boolean includeChannelPrincipals) { - // Return both the current user's conversations AND those created by - // scheduled jobs (owner=system). Child conversations spawned by - // delegation are excluded — they don't belong in the sidebar. + // Return the current user's conversations. Shared system/channel + // principals are only surfaced to global admins; otherwise members in + // the same workspace can see each other's IM/cron conversations (#616). // - // 同时返回当前用户的会话和定时任务(system)产生的会话; - // 排除子会话(委派产生的子会话不在侧边栏显示)。 - // - // External channel principals (webchat) are only surfaced to global - // admins: per isConversationOwner they are the only ones who can open a - // webchat-owned conversation, so listing them to anyone else would show - // rows the caller would then 403 on (issue #344 alignment). - boolean includeWebchat = includeChannelPrincipals && isGlobalAdmin(username); + // 返回当前用户自己的会话。system/webchat 等共享主体仅对全局管理员展示, + // 避免同工作区成员互相看到 IM/定时任务会话(#616)。 + boolean includeSharedPrincipals = includeChannelPrincipals && isGlobalAdmin(username); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .and(w -> applyOwnerScope(w, username, includeWebchat)) + .and(w -> applyOwnerScope(w, username, includeSharedPrincipals)) .and(this::applyMalformedIdGuard) .and(this::applyOrdinaryConversationGuard) .isNull(ConversationEntity::getParentConversationId) @@ -200,15 +193,16 @@ public class ConversationService { /** * Apply the owner-scope predicate onto a (nested) wrapper: always the user's - * own + {@link #SYSTEM_USER} rows; when {@code includeChannelPrincipals} is - * true, also external channel-principal rows ({@code webchat:%}). Kept as one - * helper so the list and page queries stay in lockstep. + * own rows; when {@code includeSharedPrincipals} is true, also shared + * {@link #SYSTEM_USER} and external channel-principal rows ({@code webchat:%}). + * Kept as one helper so the list and page queries stay in lockstep. */ private void applyOwnerScope(LambdaQueryWrapper w, - String username, boolean includeChannelPrincipals) { - w.in(ConversationEntity::getUsername, username, SYSTEM_USER); - if (includeChannelPrincipals) { - w.or().likeRight(ConversationEntity::getUsername, WEBCHAT_OWNER_PREFIX); + String username, boolean includeSharedPrincipals) { + w.eq(ConversationEntity::getUsername, username); + if (includeSharedPrincipals) { + w.or().eq(ConversationEntity::getUsername, SYSTEM_USER) + .or().likeRight(ConversationEntity::getUsername, WEBCHAT_OWNER_PREFIX); } } @@ -257,7 +251,7 @@ public class ConversationService { * Paginated variant used by the Sessions admin page. * *

    Mirrors {@link #listConversations(String, Long)}'s filtering (current - * user + system rows, top-level only, optional workspace) and adds a + * user rows, top-level only, optional workspace) and adds a * {@code keyword} match against title / conversationId. The keyword is * case-insensitive and treated as a substring. * @@ -272,9 +266,9 @@ public class ConversationService { com.baomidou.mybatisplus.extension.plugins.pagination.Page pager = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size); - // Admin Sessions page surfaces channel conversations too, but only to - // global admins — they are the only ones who can open a webchat-owned - // conversation (issue #344), so non-admins must not see those rows. + // Admin Sessions page surfaces shared system/channel conversations too, + // but only to global admins. Non-admins are isolated to their own rows + // so workspace peers cannot see each other's shared-channel threads (#616). LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .and(w -> applyOwnerScope(w, username, isGlobalAdmin(username))) .and(this::applyMalformedIdGuard) @@ -475,17 +469,17 @@ public class ConversationService { /** * Get-or-create a shared channel conversation. * - *

    IM-channel (Feishu / DingTalk / WeCom / …) conversations must be - * visible to every logged-in user in the admin console, so the owner is - * uniformly set to {@code system}. For legacy rows whose owner was + *

    IM-channel (Feishu / DingTalk / WeCom / …) conversations use the + * shared {@code system} owner and are only surfaced to global admins in the + * admin console. For legacy rows whose owner was * historically written as a sender nickname / {@code open_id}, this * method silently rewrites it to {@code system} on read — otherwise the - * console list and message endpoints would 403 those rows. + * admin console list and message endpoints would 403 those rows. * - *

    获取或创建共享渠道会话。IM 渠道(飞书 / 钉钉 / 企微等)的会话需要在控制台中 - * 对登录用户可见,因此统一使用 {@code system} 作为 owner。对于历史上已写成发送者 - * 昵称 / open_id 的会话,这里会自动修正为 {@code system},避免控制台列表和 - * 消息接口因权限校验而不可见。 + *

    获取或创建共享渠道会话。IM 渠道(飞书 / 钉钉 / 企微等)的会话统一使用 + * {@code system} 作为 owner,并仅在全局管理员控制台中展示。对于历史上已写成 + * 发送者昵称 / open_id 的会话,这里会自动修正为 {@code system},避免管理员 + * 控制台列表和消息接口因权限校验而不可见。 */ @Transactional public ConversationEntity getOrCreateSharedConversation(String conversationId, Long agentId) { @@ -1740,10 +1734,10 @@ public class ConversationService { } /** - * Check whether a user owns the conversation. Direct owners always pass; - * shared rows (system / IM / {@code webchat:} principals) are - * additionally gated by the requester's membership in the conversation's - * workspace, so they are not reachable cross-workspace by id. + * Check whether a user owns the conversation. Direct owners always pass. + * Shared rows (system / IM / {@code webchat:} principals) are + * restricted to global admins, with legacy system-owner fallbacks preserved + * for rows/endpoints that cannot resolve an authenticated user. * *

    Cross-workspace guard (issue #344). The legacy contract let any * logged-in user reach a system / IM / webchat-owned conversation by id — @@ -1752,20 +1746,22 @@ public class ConversationService { * untrusted isolation boundaries, that asymmetry is a cross-workspace * authorization gap. This method now also requires, for shared (non-direct) * conversations, that the requester actually be a member of the - * conversation's workspace. + * conversation's workspace. Issue #616 tightened this further: workspace + * membership alone is not enough to read a shared system conversation, + * because that lets peers in the same workspace see each other's channel + * or scheduled-job conversations. * *

    校验用户是否拥有该会话。直属会话直接放行;共享会话(system / IM / webchat) - * 额外要求请求者是该会话所属 workspace 的成员。 + * 仅允许全局管理员查看,避免同 workspace 成员互相看到对话。 * *

    分支: *

      *
    • 会话不存在 → false
    • *
    • 请求者是该会话的直属 owner → true(自己的会话,workspace 隐式一致)
    • + *
    • 请求者是全局 admin(user.role=admin)→ true(横切覆盖,与具体 workspace 无关)
    • *
    • 会话无 workspace_id(老数据)→ 仅看是否 system owner(维持旧行为,避免回归)
    • *
    • 请求者用户记录不存在(permitAll 端点的匿名重连)→ 仅看是否 system owner(维持旧行为)
    • - *
    • 请求者是全局 admin(user.role=admin)→ true(横切覆盖,与具体 workspace 无关)
    • - *
    • 请求者非该会话 workspace 的成员 → false
    • - *
    • 否则 → system owner 检查(共享会话对本 workspace 成员可见)
    • + *
    • 否则 → false
    • *
    * *

    调用方签名不变;调用方若需在不查 DB 的情况下做 admin 例外,可在外层先短路, @@ -1786,21 +1782,17 @@ public class ConversationService { // 共享会话(system / IM / webchat owner)以下收紧。 Long convWorkspaceId = conv.getWorkspaceId(); UserEntity requester = authService.findByUsername(username); + // 全局 admin 横切放行,覆盖所有 workspace。 + if (requester != null && "admin".equalsIgnoreCase(requester.getRole())) { + return true; + } // 老数据无 workspace_id,或请求者为匿名(permitAll 端点重连场景):维持旧行为, // 仅 system owner 可见。避免数据迁移未完成或匿名流式场景下回归。 if (convWorkspaceId == null || requester == null) { return SYSTEM_USER.equals(conv.getUsername()); } - // 全局 admin 横切放行,覆盖所有 workspace。 - if ("admin".equalsIgnoreCase(requester.getRole())) { - return true; - } - // #344 的核心守卫:必须是该会话所属 workspace 的成员(viewer 或更高)。 - // 不读 X-Workspace-Id header —— 客户端可伪造;以 DB 成员关系为准。 - if (!workspaceService.hasPermissionCached(convWorkspaceId, requester.getId(), "viewer")) { - return false; - } - return SYSTEM_USER.equals(conv.getUsername()); + // #616: workspace membership alone is not ownership. + return false; } /** diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java index 297e3722..2f2ea7f6 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java @@ -9,6 +9,7 @@ import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.team.service.TeamWorkerConversationGovernanceService; import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.vo.ConversationVO; import vip.mate.workspace.conversation.vo.MessageVO; @@ -32,6 +33,7 @@ public class ConversationController { private final ConversationService conversationService; private final ChatStreamTracker streamTracker; + private final TeamWorkerConversationGovernanceService teamWorkerGovernanceService; /** * 获取当前用户的会话列表 @@ -94,9 +96,12 @@ public class ConversationController { public R listMessages(@PathVariable String conversationId, @RequestParam(required = false) Long beforeId, @RequestParam(required = false) Integer limit, + @RequestParam(required = false) Long runId, + @RequestParam(required = false) Long taskId, Authentication auth) { String username = auth != null ? auth.getName() : "anonymous"; - if (!conversationService.isConversationOwner(conversationId, username)) { + if (!conversationService.isConversationOwner(conversationId, username) + && !teamWorkerGovernanceService.canReadTranscript(conversationId, runId, taskId, username)) { return R.fail(403, "无权访问该会话"); } diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index e5fe0b8e..ea168761 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -131,6 +131,33 @@ springdoc: # MateClaw 自定义配置 mateclaw: + a2a: + enabled: ${MATECLAW_A2A_ENABLED:false} + # Public base URL for Agent Cards. Production deployments should set this + # explicitly so peers do not depend on proxy-derived request headers. + base-url: ${MATECLAW_A2A_BASE_URL:} + call-timeout-ms: ${MATECLAW_A2A_CALL_TIMEOUT_MS:120000} + max-tasks: ${MATECLAW_A2A_MAX_TASKS:1000} + task-ttl-seconds: ${MATECLAW_A2A_TASK_TTL_SECONDS:3600} + max-response-bytes: ${MATECLAW_A2A_MAX_RESPONSE_BYTES:1048576} + outbound-timeout-ms: ${MATECLAW_A2A_OUTBOUND_TIMEOUT_MS:120000} + allow-private-outbound: ${MATECLAW_A2A_ALLOW_PRIVATE_OUTBOUND:false} + sweep-interval-ms: ${MATECLAW_A2A_SWEEP_INTERVAL_MS:60000} + + # DeepSeek Harness runtime. The executable and Cordis composition are kept + # outside the Spring classpath and can be supplied by environment variables. + agent: + runtime: + dsh: + command: ${DSH_JSONRPC_AGENT:} + cordis-config: ${DSH_CORDIS_CONFIG:} + working-directory: ${DSH_CWD:} + base-url: ${DEEPSEEK_BASE_URL:} + model-name: ${DEEPSEEK_MODEL:} + api-key: ${DEEPSEEK_API_KEY:} + manifest-url: ${DSH_MANIFEST_URL:} + github-release-url: ${DSH_GITHUB_RELEASE_URL:https://api.github.com/repos/deepseek-ai/deepseek-harness/releases/latest} + install-root: ${DSH_INSTALL_ROOT:${user.home}/.mateclaw/runtimes/deepseek-harness} server: # Public base URL used to build absolute download links for tool-generated # files (e.g. https://mateclaw.example.com). Leave empty to fall back to the diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V186__agent_runtime_provider.sql b/mateclaw-server/src/main/resources/db/migration/h2/V186__agent_runtime_provider.sql new file mode 100644 index 00000000..17b7c987 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V186__agent_runtime_provider.sql @@ -0,0 +1,10 @@ +-- Add a runtime provider selector without changing the legacy agent_type. +ALTER TABLE mate_agent + ADD COLUMN IF NOT EXISTS runtime_type VARCHAR(32) NOT NULL DEFAULT 'native'; + +ALTER TABLE mate_agent + ADD COLUMN IF NOT EXISTS runtime_config CLOB DEFAULT NULL; + +UPDATE mate_agent +SET runtime_type = 'native' +WHERE runtime_type IS NULL OR TRIM(runtime_type) = ''; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V187__acp_prompt_timeout.sql b/mateclaw-server/src/main/resources/db/migration/h2/V187__acp_prompt_timeout.sql new file mode 100644 index 00000000..8e1364b4 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V187__acp_prompt_timeout.sql @@ -0,0 +1,7 @@ +-- Issue #608: make ACP session/prompt timeout configurable per endpoint. +ALTER TABLE mate_acp_endpoint + ADD COLUMN IF NOT EXISTS prompt_timeout_seconds INT NOT NULL DEFAULT 300; + +UPDATE mate_acp_endpoint +SET prompt_timeout_seconds = 300 +WHERE prompt_timeout_seconds IS NULL OR prompt_timeout_seconds <= 0; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V188__goal_continuation.sql b/mateclaw-server/src/main/resources/db/migration/h2/V188__goal_continuation.sql new file mode 100644 index 00000000..baa088e9 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V188__goal_continuation.sql @@ -0,0 +1,14 @@ +-- Existing goals retain legacy behavior; new goals opt in through GoalService. +ALTER TABLE mate_agent_goal ADD COLUMN persistent_execution BOOLEAN NOT NULL DEFAULT FALSE; +CREATE TABLE mate_goal_continuation ( + goal_id BIGINT PRIMARY KEY, + state VARCHAR(32) NOT NULL, + next_run_at TIMESTAMP NOT NULL, + lease_owner VARCHAR(64), + lease_until TIMESTAMP, + failures INT NOT NULL DEFAULT 0, + wake_requested BOOLEAN NOT NULL DEFAULT FALSE, + reason VARCHAR(1000) NOT NULL DEFAULT '', + updated_at TIMESTAMP NOT NULL +); +CREATE INDEX idx_goal_continuation_due ON mate_goal_continuation(state,next_run_at); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V189__goal_attempt_and_input_queue.sql b/mateclaw-server/src/main/resources/db/migration/h2/V189__goal_attempt_and_input_queue.sql new file mode 100644 index 00000000..6c7fbe9e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V189__goal_attempt_and_input_queue.sql @@ -0,0 +1,41 @@ +ALTER TABLE mate_goal_continuation ADD COLUMN current_attempt_id VARCHAR(36); +ALTER TABLE mate_goal_continuation ADD COLUMN revision BIGINT NOT NULL DEFAULT 0; + +CREATE TABLE mate_goal_attempt ( + attempt_id VARCHAR(36) PRIMARY KEY, + goal_id BIGINT NOT NULL, + conversation_id VARCHAR(160) NOT NULL, + parent_attempt_id VARCHAR(36), + trigger_type VARCHAR(32) NOT NULL, + state VARCHAR(32) NOT NULL, + lease_token VARCHAR(64) NOT NULL, + lease_until TIMESTAMP NOT NULL, + input_item_id BIGINT, + assistant_message_id BIGINT, + replay_safety VARCHAR(16) NOT NULL DEFAULT 'safe', + checkpoint_type VARCHAR(32) NOT NULL DEFAULT 'claimed', + finish_reason VARCHAR(128), + error_category VARCHAR(128), + started_at TIMESTAMP, + finished_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); +CREATE INDEX idx_goal_attempt_goal_created ON mate_goal_attempt(goal_id, created_at); +CREATE INDEX idx_goal_attempt_expired ON mate_goal_attempt(state, lease_until); + +CREATE TABLE mate_conversation_input_queue ( + id BIGINT PRIMARY KEY, + conversation_id VARCHAR(160) NOT NULL, + agent_id BIGINT, + created_by VARCHAR(100) NOT NULL, + message TEXT NOT NULL, + content_parts TEXT, + state VARCHAR(16) NOT NULL, + claimed_by_attempt_id VARCHAR(36), + persisted_message_id BIGINT, + cancel_reason VARCHAR(128), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); +CREATE INDEX idx_conversation_input_due ON mate_conversation_input_queue(conversation_id, state, id); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V186__agent_runtime_provider.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V186__agent_runtime_provider.sql new file mode 100644 index 00000000..058695e0 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V186__agent_runtime_provider.sql @@ -0,0 +1,13 @@ +-- Add a runtime provider selector without changing the legacy agent_type. +ALTER TABLE mate_agent + ADD COLUMN IF NOT EXISTS runtime_type VARCHAR(32) DEFAULT 'native'; + +ALTER TABLE mate_agent + ADD COLUMN IF NOT EXISTS runtime_config TEXT DEFAULT NULL; + +UPDATE mate_agent +SET runtime_type = 'native' +WHERE runtime_type IS NULL OR TRIM(runtime_type) = ''; + +ALTER TABLE mate_agent + ALTER COLUMN runtime_type SET DEFAULT 'native'; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V187__acp_prompt_timeout.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V187__acp_prompt_timeout.sql new file mode 100644 index 00000000..8e1364b4 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V187__acp_prompt_timeout.sql @@ -0,0 +1,7 @@ +-- Issue #608: make ACP session/prompt timeout configurable per endpoint. +ALTER TABLE mate_acp_endpoint + ADD COLUMN IF NOT EXISTS prompt_timeout_seconds INT NOT NULL DEFAULT 300; + +UPDATE mate_acp_endpoint +SET prompt_timeout_seconds = 300 +WHERE prompt_timeout_seconds IS NULL OR prompt_timeout_seconds <= 0; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V188__goal_continuation.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V188__goal_continuation.sql new file mode 100644 index 00000000..baa088e9 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V188__goal_continuation.sql @@ -0,0 +1,14 @@ +-- Existing goals retain legacy behavior; new goals opt in through GoalService. +ALTER TABLE mate_agent_goal ADD COLUMN persistent_execution BOOLEAN NOT NULL DEFAULT FALSE; +CREATE TABLE mate_goal_continuation ( + goal_id BIGINT PRIMARY KEY, + state VARCHAR(32) NOT NULL, + next_run_at TIMESTAMP NOT NULL, + lease_owner VARCHAR(64), + lease_until TIMESTAMP, + failures INT NOT NULL DEFAULT 0, + wake_requested BOOLEAN NOT NULL DEFAULT FALSE, + reason VARCHAR(1000) NOT NULL DEFAULT '', + updated_at TIMESTAMP NOT NULL +); +CREATE INDEX idx_goal_continuation_due ON mate_goal_continuation(state,next_run_at); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V189__goal_attempt_and_input_queue.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V189__goal_attempt_and_input_queue.sql new file mode 100644 index 00000000..6c7fbe9e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V189__goal_attempt_and_input_queue.sql @@ -0,0 +1,41 @@ +ALTER TABLE mate_goal_continuation ADD COLUMN current_attempt_id VARCHAR(36); +ALTER TABLE mate_goal_continuation ADD COLUMN revision BIGINT NOT NULL DEFAULT 0; + +CREATE TABLE mate_goal_attempt ( + attempt_id VARCHAR(36) PRIMARY KEY, + goal_id BIGINT NOT NULL, + conversation_id VARCHAR(160) NOT NULL, + parent_attempt_id VARCHAR(36), + trigger_type VARCHAR(32) NOT NULL, + state VARCHAR(32) NOT NULL, + lease_token VARCHAR(64) NOT NULL, + lease_until TIMESTAMP NOT NULL, + input_item_id BIGINT, + assistant_message_id BIGINT, + replay_safety VARCHAR(16) NOT NULL DEFAULT 'safe', + checkpoint_type VARCHAR(32) NOT NULL DEFAULT 'claimed', + finish_reason VARCHAR(128), + error_category VARCHAR(128), + started_at TIMESTAMP, + finished_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); +CREATE INDEX idx_goal_attempt_goal_created ON mate_goal_attempt(goal_id, created_at); +CREATE INDEX idx_goal_attempt_expired ON mate_goal_attempt(state, lease_until); + +CREATE TABLE mate_conversation_input_queue ( + id BIGINT PRIMARY KEY, + conversation_id VARCHAR(160) NOT NULL, + agent_id BIGINT, + created_by VARCHAR(100) NOT NULL, + message TEXT NOT NULL, + content_parts TEXT, + state VARCHAR(16) NOT NULL, + claimed_by_attempt_id VARCHAR(36), + persisted_message_id BIGINT, + cancel_reason VARCHAR(128), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); +CREATE INDEX idx_conversation_input_due ON mate_conversation_input_queue(conversation_id, state, id); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V186__agent_runtime_provider.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V186__agent_runtime_provider.sql new file mode 100644 index 00000000..a347e169 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V186__agent_runtime_provider.sql @@ -0,0 +1,22 @@ +-- Add a runtime provider selector without changing the legacy agent_type. +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_agent' + AND COLUMN_NAME = 'runtime_type'); +SET @s := IF(@c = 0, + 'ALTER TABLE mate_agent ADD COLUMN runtime_type VARCHAR(32) NOT NULL DEFAULT ''native'' AFTER agent_type', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_agent' + AND COLUMN_NAME = 'runtime_config'); +SET @s := IF(@c = 0, + 'ALTER TABLE mate_agent ADD COLUMN runtime_config TEXT NULL AFTER runtime_type', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +UPDATE mate_agent +SET runtime_type = 'native' +WHERE runtime_type IS NULL OR TRIM(runtime_type) = ''; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V187__acp_prompt_timeout.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V187__acp_prompt_timeout.sql new file mode 100644 index 00000000..9f6ddf80 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V187__acp_prompt_timeout.sql @@ -0,0 +1,7 @@ +-- Issue #608: make ACP session/prompt timeout configurable per endpoint. +ALTER TABLE mate_acp_endpoint + ADD COLUMN prompt_timeout_seconds INT NOT NULL DEFAULT 300; + +UPDATE mate_acp_endpoint +SET prompt_timeout_seconds = 300 +WHERE prompt_timeout_seconds IS NULL OR prompt_timeout_seconds <= 0; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V188__goal_continuation.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V188__goal_continuation.sql new file mode 100644 index 00000000..0fb0842e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V188__goal_continuation.sql @@ -0,0 +1,14 @@ +-- Existing goals retain legacy behavior; new goals opt in through GoalService. +ALTER TABLE mate_agent_goal ADD COLUMN persistent_execution BOOLEAN NOT NULL DEFAULT FALSE; +CREATE TABLE mate_goal_continuation ( + goal_id BIGINT PRIMARY KEY, + state VARCHAR(32) NOT NULL, + next_run_at DATETIME(6) NOT NULL, + lease_owner VARCHAR(64), + lease_until DATETIME(6), + failures INT NOT NULL DEFAULT 0, + wake_requested BOOLEAN NOT NULL DEFAULT FALSE, + reason VARCHAR(1000) NOT NULL DEFAULT '', + updated_at DATETIME(6) NOT NULL +); +CREATE INDEX idx_goal_continuation_due ON mate_goal_continuation(state,next_run_at); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V189__goal_attempt_and_input_queue.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V189__goal_attempt_and_input_queue.sql new file mode 100644 index 00000000..2103b46d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V189__goal_attempt_and_input_queue.sql @@ -0,0 +1,41 @@ +ALTER TABLE mate_goal_continuation ADD COLUMN current_attempt_id VARCHAR(36); +ALTER TABLE mate_goal_continuation ADD COLUMN revision BIGINT NOT NULL DEFAULT 0; + +CREATE TABLE mate_goal_attempt ( + attempt_id VARCHAR(36) PRIMARY KEY, + goal_id BIGINT NOT NULL, + conversation_id VARCHAR(160) NOT NULL, + parent_attempt_id VARCHAR(36), + trigger_type VARCHAR(32) NOT NULL, + state VARCHAR(32) NOT NULL, + lease_token VARCHAR(64) NOT NULL, + lease_until DATETIME(6) NOT NULL, + input_item_id BIGINT, + assistant_message_id BIGINT, + replay_safety VARCHAR(16) NOT NULL DEFAULT 'safe', + checkpoint_type VARCHAR(32) NOT NULL DEFAULT 'claimed', + finish_reason VARCHAR(128), + error_category VARCHAR(128), + started_at DATETIME(6), + finished_at DATETIME(6), + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + INDEX idx_goal_attempt_goal_created(goal_id, created_at), + INDEX idx_goal_attempt_expired(state, lease_until) +); + +CREATE TABLE mate_conversation_input_queue ( + id BIGINT PRIMARY KEY, + conversation_id VARCHAR(160) NOT NULL, + agent_id BIGINT, + created_by VARCHAR(100) NOT NULL, + message LONGTEXT NOT NULL, + content_parts LONGTEXT, + state VARCHAR(16) NOT NULL, + claimed_by_attempt_id VARCHAR(36), + persisted_message_id BIGINT, + cancel_reason VARCHAR(128), + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + INDEX idx_conversation_input_due(conversation_id, state, id) +); diff --git a/mateclaw-server/src/main/resources/docs/en/a2a.md b/mateclaw-server/src/main/resources/docs/en/a2a.md new file mode 100644 index 00000000..b3a32c0d --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/a2a.md @@ -0,0 +1,39 @@ +# A2A Protocol + +MateClaw can expose enabled agents through an A2A JSON-RPC endpoint and can call other A2A peers from agent tools. + +## Configuration + +```yaml +mateclaw: + a2a: + enabled: true + base-url: https://your-public-host + call-timeout-ms: 120000 + max-tasks: 1000 + task-ttl-seconds: 3600 +``` + +`base-url` is required for production deployments. If it is empty, MateClaw derives the Agent Card URL from the incoming request, which depends on proxy headers being correct. + +## Inbound + +- `GET /.well-known/agent-card.json` and anonymous `GET /api/a2a/card` return the public minimal card without `skills`. +- Authenticated `GET /api/a2a/card` returns enabled agents in `skills[]`; use the agent id as `message.metadata.skillId`. +- `POST /api/a2a` requires an existing MateClaw Bearer token and supports `message/send`, `message/stream`, `tasks/get`, and `tasks/cancel`. + +## Outbound Tool + +Agents can call `call_a2a_agent` with: + +```json +{ + "url": "https://peer.example.com/api/a2a", + "headers": { + "Authorization": "Bearer token" + }, + "stream": false +} +``` + +Outbound calls reject private or reserved network targets by default, do not follow redirects, cap response size, and poll `tasks/get` when a blocking send returns an in-progress task. diff --git a/mateclaw-server/src/main/resources/docs/en/acp.md b/mateclaw-server/src/main/resources/docs/en/acp.md index a0f925cf..fb5f77de 100644 --- a/mateclaw-server/src/main/resources/docs/en/acp.md +++ b/mateclaw-server/src/main/resources/docs/en/acp.md @@ -198,7 +198,7 @@ Hints surface in the test panel and in the streamed error message your agent rec - `initialize` handshake: 15s - `session/new`: 10s -- Whole `session/prompt` round-trip: 5 min +- Whole `session/prompt` round-trip: 300s by default, configurable per endpoint up to 3600s - Stdio buffer cap: 50 MiB per call (configurable on the row via `stdio_buffer_limit_bytes`) --- @@ -215,6 +215,7 @@ Hints surface in the test panel and in the streamed error message your agent rec | `args_json` | TEXT | NULL | CLI args (JSON array) | | `env_json` | TEXT | NULL | Env overrides (JSON object) | | `tool_parse_mode` | VARCHAR(32) | `call_title` | `call_title` / `call_detail` / `update_detail` | +| `prompt_timeout_seconds` | INT | 300 | `session/prompt` call timeout, capped at 3600s | | `builtin` | BOOLEAN | FALSE | Built-in rows are write-protected | | `trusted` | BOOLEAN | TRUE | Auto-allow permission requests | | `enabled` | BOOLEAN | FALSE | Off until you opt in | diff --git a/mateclaw-server/src/main/resources/docs/en/deepseek-harness.md b/mateclaw-server/src/main/resources/docs/en/deepseek-harness.md new file mode 100644 index 00000000..ebdf0dd9 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/deepseek-harness.md @@ -0,0 +1,202 @@ +--- +title: DeepSeek Harness Integration +description: Install DeepSeek Harness and configure it as a digital employee runtime in MateClaw. +head: + - - meta + - name: keywords + content: DeepSeek Harness,DSH,digital employee,Agent runtime,JSON-RPC,Cordis +--- + +# DeepSeek Harness Integration + +This guide connects the official DeepSeek Harness (DSH) to MateClaw and creates a digital employee powered by the DSH runtime. + +In MateClaw, DSH is an **employee runtime**, not an MCP tool and not a regular plugin. MCP supplies tools; DSH owns the external Agent process, the ReAct loop, and the event stream that MateClaw projects into the conversation UI. + +## Architecture + +```text +MateClaw Chat / SSE + | + v +DSH Runtime Provider + | + v JSON-RPC over stdin/stdout +dsh-jsonrpc-agent + | + v +DeepSeek API + Cordis composition +``` + +MateClaw remains responsible for employees, sessions, permissions, workspaces, message persistence, and UI projection. DSH runs the turn. MateClaw injects the API key from the DeepSeek provider configuration into the DSH child process. Never put secrets in `runtimeConfig`, employee prompts, or the repository. + +## Prerequisites + +- macOS, Linux, or Windows (commands below use macOS / Linux syntax) +- JDK 21 +- A running MateClaw backend and frontend +- A DeepSeek API key +- A built DSH JSON-RPC agent +- The Cordis configuration from the DSH checkout + +Check the runtime files: + +```bash +"$DSH_JSONRPC_AGENT" --help +test -x "$DSH_JSONRPC_AGENT" +test -f "$DSH_CORDIS_CONFIG" +``` + +## Install DSH + +Follow the [official DeepSeek Harness repository](https://github.com/deepseek-ai/deepseek-harness) for the current build instructions. The build must provide these two paths: + +```text +/dist-exe/dsh-jsonrpc-agent-pkg- +/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml +``` + +Keep the DSH binary outside the MateClaw source tree. Configure its location with environment variables. + +## Configure the IDEA backend + +Open **Run | Edit Configurations...** in IDEA, select the MateClaw Spring Boot configuration, and add these variables under **Environment variables**: + +```text +DSH_JSONRPC_AGENT=/absolute/path/to/dsh-jsonrpc-agent-pkg-macos-arm64 +DSH_CORDIS_CONFIG=/absolute/path/to/cordis.yml +DSH_CWD=/absolute/path/to/mateclaw-workspace +``` + +Example: + +```text +DSH_JSONRPC_AGENT=/opt/deepseek-harness/dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 +DSH_CORDIS_CONFIG=/opt/deepseek-harness/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml +DSH_CWD=/var/lib/mateclaw/workspace +``` + +`DSH_CWD` must be readable and writable by the backend process. Use absolute paths in the IDEA run configuration. Restart the backend after changing them; Spring Boot does not hot-reload process environment variables. + +## Configure the DeepSeek provider + +1. Sign in to MateClaw. +2. Open **Settings → Models**. +3. Configure and enable the **DeepSeek** provider. +4. Enter the DeepSeek API key and base URL. +5. Confirm that at least one enabled DeepSeek chat model exists. + +The default DSH model is `deepseek-v4-flash`. If the employee has no explicit model, MateClaw uses the global model name and injects credentials from the `deepseek` provider. A custom model must be usable by the DeepSeek provider route in DSH. + +## Create a DSH digital employee + +Open **Digital Employees → New**: + +1. Enter the employee name, role, and goal. +2. Select **DSH / DeepSeek Harness** as the runtime. +3. Set a workspace; when blank, `DSH_CWD` is used. +4. Use a JSON object for `runtimeConfig`, for example: + +```json +{ + "mode": "qa", + "workspace": "default", + "policy": "read-only" +} +``` + +5. Save the employee and open its chat. + +Runtime configuration describes employee policy only. Do not put `DEEPSEEK_API_KEY`, cookies, bearer tokens, or sensitive local paths in it. + +## Verification checklist + +Send this message in the DSH employee conversation: + +```text +Reply with exactly: DSH_RUNTIME_OK +``` + +Success means: + +- The employee header shows `DSH Harness`. +- Thinking state and text deltas appear in the chat. +- The log contains `provider=deepseek` and `apiKeyConfigured=true`. +- The log contains a `turn/end` event with `kind=completed`. +- The UI does not show “no output for this run”. +- The same conversation is not used to start two different DSH live sessions. + +Do not reuse a completed test `conversationId` for a new DSH live session. DSH detects a mismatch between the persisted session log and the new live session and reports `id collision`. Use **New conversation** for every fresh runtime test. + +## Logs and diagnostics + +The backend log is commonly located at: + +```text +logs/mateclaw.log +``` + +Search for the runtime signals: + +```bash +rg "\[DSH\]|MISSING_CREDENTIAL|EMPTY_RESPONSE|id collision" logs/mateclaw.log +``` + +The admin-only diagnostics endpoint is: + +```http +GET /api/v1/admin/agent-runtime/dsh/diagnostics +``` + +It returns command, executable, Cordis, and capability status without returning the API key. + +## Troubleshooting + +### `MISSING_CREDENTIAL` + +Check that: + +1. The DeepSeek provider is enabled under Settings → Models. +2. The API key was saved successfully. +3. The employee uses DSH rather than configuring the DSH binary as an MCP command. +4. The backend was restarted with the updated IDEA configuration. + +`apiKeyConfigured=false` in the log means the credential did not reach the DSH child process. + +### `EMPTY_RESPONSE` + +Check model availability and the base URL. Verify with a short fixed prompt before adding tools or skills. + +### `dsh.command_unavailable` + +`DSH_JSONRPC_AGENT` must point to an executable file: + +```bash +chmod +x /absolute/path/to/dsh-jsonrpc-agent-pkg-macos-arm64 +``` + +### `dsh.cordis_missing` + +`DSH_CORDIS_CONFIG` must point to the actual `cordis.yml`, not only the package directory. If a package directory is provided, MateClaw also checks its `runtime/cordis.yml` child path. + +### The answer appears twice + +Use the latest backend version. DSH emits text deltas followed by a final assistant snapshot. MateClaw must project the deltas only and must not append the snapshot again. + +## MCP, plugins, and DSH + +| Mechanism | Best for | Replaces DSH? | +|-----------|----------|--------------| +| MCP | File, GitHub, database, and other tools | No | +| Plugin | Extending MateClaw tools, models, channels, or memory | No | +| DSH employee runtime | Hosting the DeepSeek Harness Agent loop and process | It is the employee runtime, not a tool | + +The recommended composition is: **DSH as the employee runtime, MCP as the tool layer, and MateClaw as the governance and visualization layer**. + +## Security recommendations + +- Store API keys only in MateClaw provider configuration or a controlled environment. +- Give DSH a dedicated workspace instead of the whole user home directory. +- Start with a read-only policy and the smallest possible tool set. +- Never commit `.sessions/`, logs, or local credential configuration. +- In production, restrict the DSH child process filesystem, network, and credential access. diff --git a/mateclaw-server/src/main/resources/docs/en/goals.md b/mateclaw-server/src/main/resources/docs/en/goals.md index 56af4986..86bb5802 100644 --- a/mateclaw-server/src/main/resources/docs/en/goals.md +++ b/mateclaw-server/src/main/resources/docs/en/goals.md @@ -9,6 +9,14 @@ head: # Persistent Goals +## Continuous execution (v1) + +New goals default to `persistentExecution=true`; omitted `turnBudget` and `llmCallBudget` become `0` (no cumulative limit). Explicit positive budgets still apply. Existing goals retain legacy mode and do not start automatically after an upgrade. + +A durable queue and background supervisor schedule persistent goals across bounded graph segments. A graph limit ends a segment, not the goal. Queued work, cooldowns, retries and expired leases survive server restarts. Completion requires all persisted criteria to pass with nonblank evidence. Stop, essential missing input, approval denial and budget exhaustion pause execution until an explicit resume. + +`GET /api/v1/goals/{id}/execution` exposes the latest scheduling state, reason and due time; the goal API remains authoritative for current goal status. Streams broadcast scheduling changes through `goal_continuation`. V1 supports a single backend instance and the native runtime. External tool effects are not guaranteed exactly once; recovery must check existing artifacts and async handles. Budgets are checked at segment boundaries, not as per-request spending caps. + > **You used to repeat the context every turn. Now you set a goal once, the worker follows.** You say "deploy this blog to fly.io" in one turn, the worker answers, and stops. Next turn you have to remember to ask "is DNS set? cert signed? tests run?" — you're keeping the goal in your head, not the worker. @@ -109,6 +117,8 @@ After every turn, a backend evaluator node runs: ### Auto-followup +Persistent mode schedules a fresh graph segment through the durable supervisor. The graph-local injection below applies only to legacy goals (`persistentExecution=false`). + When `autoFollowupEnabled=true` and this turn's evaluator decision is "continue", the backend: 1. Writes a `followup_injected` event to the timeline @@ -198,9 +208,9 @@ The evaluation logic implements Spring AI's `Evaluator` interface: it does goal- --- -## Four built-in tools (worker-callable) +## Built-in goal tools (worker-callable) -These four ship as agent-wide system tools — no binding setup needed: +These tools ship as agent-wide system tools — no binding setup needed: | Tool | Purpose | Prompt example | |---|---|---| @@ -208,6 +218,7 @@ These four ship as agent-wide system tools — no binding setup needed: | **addGoalCriterion** | Append a sub-criterion to the active goal | "Add: must support IPv6" | | **completeGoal** | Explicitly mark done | "All items done — call completeGoal" | | **getGoalStatus** | Inspect current state | "How are we doing?" | +| **waitForGoalInput** | Pause a persistent goal for essential missing input | "Wait for the user to supply the deployment domain" | On completion (`completeGoal`, or the evaluator judging **every criterion passed**), the worker forwards a summary to its [long-term memory](./memory) so future conversations can recall it. @@ -215,7 +226,7 @@ On completion (`completeGoal`, or the evaluator judging **every criterion passed ## Sub-agents cannot mutate the parent's goal -In [multi-agent collaboration](./agents) a parent worker can delegate to a child worker. Children **don't see** the four goal tools — the goal is the parent conversation's state, the child is a stateless executor. +In [multi-agent collaboration](./agents) a parent worker can delegate to a child worker. Children **don't see** the goal tools — the goal is the parent conversation's state, the child is a stateless executor. > This is intentional. Children do work for the parent, but the goal stays owned by the parent. @@ -227,7 +238,7 @@ In [multi-agent collaboration](./agents) a parent worker can delegate to a child turnsUsed >= turnBudget OR (agentLlmCallsUsed + evalLlmCallsUsed) >= llmCallBudget ``` -Either one hit → goal status flips to **exhausted**, no more evaluations, no more follow-ups, ring turns red-orange. The last turn's assistant reply still goes through. +Persistent mode checks positive budgets only; `0` means unlimited. Reaching a budget pauses the goal with scheduling state **budget_limited**; increase the budget and resume. Legacy goals still enter terminal **exhausted** and require a new goal to continue. The current segment's reply is still saved. Your options: @@ -247,7 +258,8 @@ Your options: active ──all criteria passed / completeGoal──→ completed (terminal) ↓ - active ──turns_used / llm_calls exhausted ────→ exhausted (terminal) + active ──positive budget reached (persistent) → paused + active ──budget reached (legacy) ────────────→ exhausted (terminal) ↓ active ──user abandon ────────────────────────→ abandoned (terminal) ``` @@ -300,9 +312,18 @@ mateclaw: default-auto-followup: true # Runtime master switch; when off, no goal injects a followup regardless of its per-goal flag. allow-auto-followup: true - # Default turn budget when the user doesn't override. + # Persistent goal segments that may execute concurrently in one backend instance. + max-concurrent-segments: 4 + # Global minimum delay in seconds before an ordinary segment continues. + minimum-continuation-interval-seconds: 1 + # Pause new claims after a retryable provider/evaluator failure (seconds; 0 disables it). + provider-failure-global-backoff-seconds: 30 + # New goals run persistently; omitted budgets mean unlimited (0). + default-persistent-execution: true + supervisor-poll-ms: 5000 + # Legacy default turn budget. default-turn-budget: 20 - # Default combined (agent + evaluator) LLM call budget. + # Legacy combined (agent + evaluator) LLM call budget. default-llm-call-budget: 200 # Minimum seconds between two consecutive auto-followups. auto-followup-cooldown-seconds: 0 @@ -319,6 +340,13 @@ mateclaw: evaluator-context-messages: 8 ``` +The effective continuation delay is the larger of +`minimum-continuation-interval-seconds` and the goal's `followupCooldownSeconds`. +The provider-wide backoff is instance-local; restart recovery continues to use the durable +continuation `next_run_at` and per-goal failure count. For soak tests or constrained model +capacity, start with concurrency `1`, a `300` second minimum interval, and a `300` second +provider backoff, then increase load only after reviewing logs. + --- ## Database @@ -330,7 +358,7 @@ Two tables, all `mate_`-prefixed: | `mate_agent_goal` | Goal itself; status / budgets / dual LLM counters / auto-followup config | | `mate_agent_goal_event` | Append-only event log; powers the timeline view | -Flyway migration `V120__agent_goal.sql` (H2 / MySQL / KingbaseES dialects). +`mate_goal_continuation` stores durable scheduling, due times and leases. Flyway migrations `V120__agent_goal.sql` and `V188__goal_continuation.sql` (H2 / MySQL / KingbaseES dialects). --- diff --git a/mateclaw-server/src/main/resources/docs/en/index.md b/mateclaw-server/src/main/resources/docs/en/index.md index 5035c29a..11a618b5 100644 --- a/mateclaw-server/src/main/resources/docs/en/index.md +++ b/mateclaw-server/src/main/resources/docs/en/index.md @@ -20,12 +20,15 @@ hero: link: https://github.com/mateaix/mateclaw features: + - icon: ⚙️ + title: Employee runtimes, not one Agent loop + details: 2.2.0 uses a shared Runtime Contract to decouple employee identity from execution. Native and DeepSeek Harness share conversations, workspaces, tool governance, and event projection; durable Goals recover across requests and backend restarts, while A2A connects external agents. - icon: 🧑‍💼 title: Digital employees, not chatbots details: You hire coworkers, not a chat box. Each one has a role, a goal, a backstory, a pixel-art avatar, and a color of their own — six built-in templates ship ready to use. ReAct + Plan-and-Execute, parallel delegation between employees. - icon: 🤝 title: Teams, not lone wolves - details: One team request becomes one Team Run — objective, task DAG, worker execution, final synthesis, and deliverables stay together. Chat delivers outcomes, Agents observes live work, and Teams governs history and approvals. In 2.1.0, one round of collaboration is one complete work record. + details: One team request becomes one Team Run — objective, task DAG, worker execution, final synthesis, and deliverables stay together. Chat delivers, Agents observes, and Teams governs; 2.2.0 further hardens checkpoint recovery and deliverable completion gates. - icon: 🧩 title: Skills are the skeleton, not a plugin details: One SKILL.md plus one LESSONS.md that grows with use. Eight starter templates, a five-step creation wizard, pre-flight checks before install. MCP and ACP bridges — even Claude Code and Codex show up as employees. diff --git a/mateclaw-server/src/main/resources/docs/en/releases.md b/mateclaw-server/src/main/resources/docs/en/releases.md index 4400d587..96d410de 100644 --- a/mateclaw-server/src/main/resources/docs/en/releases.md +++ b/mateclaw-server/src/main/resources/docs/en/releases.md @@ -10,6 +10,7 @@ For historical diffs, check the corresponding git tag. For the "why" behind a fe | Version | Date | Highlights | |---------|------|------------| +| [v2.2.0](./releases/2.2.0) | 2026-08-29 | **Runtime mainline** — employee runtime contract + provider registry + session lifecycle decouple employees from the built-in reasoning loop; **DeepSeek Harness** joins through an authenticated JSON-RPC child process while tools remain governed by host workspace policy and Tool Guard, with install/configure/verify/enable management; **durable Goals recover across bounded segments and backend restarts** (database queue · supervisor · leases · attempts · accepted-input queue · provider backoff · evidence-gated checklist completion); **bidirectional A2A** (Agent Cards · message send/stream · task get/cancel · `call_a2a_agent` · SSRF/timeout/response caps); hardened Team checkpoints, board recovery, deliverable gates, worker attachments, and long-form output; optional OfficeCLI + ACP prompt timeout + tighter workspace boundaries | | [v2.1.0](./releases/2.1.0) | 2026-08-15 | **Unified Team Runs** — one request, task DAG, worker execution, final synthesis, and deliverables share a `runId`; Chat delivery / Agents observation / Teams governance consume one projection, while worker conversations stay out of the normal sidebar · **closed skill-evolution loop** (cross-session recurring-request mining · reflection · constrained auto-binding · curator governance handover · origin policy · snapshots and restore points, workspace-scoped and conservative by default) · **replayable reasoning** (live `` extraction · UI wall-clock duration · all/terminal display controls · plain-text trajectory export) · proactive channel push + targeted Cron delivery · context-window override/probe/catalog budgeting · progressive tool bridge + action completion policy · hardened browser refs/navigation/waits · WebChat/SSE/LLM stream cleanup and timeouts · Feishu execution progress · Qwen3-ASR HTTP · batch session deletion · date-partitioned files · 64-bit id and numeric tool-schema precision fixes | | [v2.0.0](./releases/2.0.0) | 2026-07-31 | **Agent Teams with a shared task board** — the lead decomposes, members execute in parallel (teams/roles · eight-status kanban · `blockedBy` dependency orchestration · automatic prerequisite hand-off · settled results wake the lead · deliverable registration & download · task timeline + team SSE live board · execution lease heartbeat + cancel-interrupt + `in_review` approval gates) · **Plan-Execute plans hand over to the board** (steps→tasks · dependencies→parallelism · parked-plan resume gate for deterministic synthesis) · Workspace isolation fully sealed (channel-scoped conversation ids · same-named skills coexist per workspace with conversation-scoped runtime resolution) · Channel magic commands (`/new` `/clear` `/status` `/stop` `/model` `/help`) + WeCom event-driven progress bubble (live tool trace · per-stage rolling narration) · Server-side rewind/regenerate semantics · Explainable auto-approval misses (reason codes on audit rows + one-click grant creation + anti-footgun forms) · Policy-driven LLM error recovery (overload vs rate-limit split · `Retry-After`-aware backoff · provider TTL readmission · jitter against retry storms) · In-chat attachment preview (pdf/docx/xlsx/html/text) · Single-source SKILL.md + console bundle-file management · Optional Mem0 plugin memory provider · Knowledge-graph relation schema whitelist | | [v1.8.0](./releases/1.8.0) | 2026-07-12 | Content Studio — one sentence to a publishable post (seeded "Content Studio" employee runs pick-topic → research → draft → illustrate → de-AI → layout → deliver) · **WeChat Official Account (公众号)** image-text articles (`gzh_article` · inline-style HTML · draft-box publish via `gzh_publish`) + **Xiaohongshu (小红书)** image-first notes (`xhs_note` · ≥3 vertical 3:4 cards · online preview) · Measurable **de-AI-ification** (heuristic AI-trace score → detect/rewrite/re-check loop, max 3 rounds) · Publish chain hardened (body images uploaded into WeChat · AES-GCM secret encryption · WeChat service+token reuse · retry + Chinese error hints · fallback cover) · **Content Calendar** (deliver = compliance-scan + auto-record · topic-fingerprint dedup · read-only page) · Browser agent **accessibility-tree ref interaction** + real-browser privacy guardrails + controlled CDP hatch · Attention anchoring + tool-call loop guard + post-mutation verify reminder · Fast-load (~78% smaller initial bundle) · Context-occupancy panel · Cross-KB wikilinks · MCP progress notifications · Volcano Engine provider · PostgreSQL 16 | diff --git a/mateclaw-server/src/main/resources/docs/en/roadmap.md b/mateclaw-server/src/main/resources/docs/en/roadmap.md index 11e81415..fe6935f3 100644 --- a/mateclaw-server/src/main/resources/docs/en/roadmap.md +++ b/mateclaw-server/src/main/resources/docs/en/roadmap.md @@ -156,15 +156,28 @@ Full story: [v2.0.0 release notes](./releases/2.0.0.md); user guide: [Agent Team Full story: [v2.1.0 release notes](./releases/2.1.0.md); guides: [Team Runs](./teams) and [Skills](./skills). +### v2.2 — Replaceable runtimes, recoverable long work ✅ Released (2026-08-29) + +2.1 defined a complete run. 2.2 turns “what executes it, how it resumes after interruption, and how it collaborates with external agents” into infrastructure. + +- **Employee Runtime Contract**: provider registry, session factory, capability declarations, and normalized events let native and external runtimes share conversations, workspaces, tool governance, and UI projection +- **DeepSeek Harness Runtime**: authenticated JSON-RPC process bridge, thinking/text/tool/lifecycle events, host tool policy, managed installation and configuration, and child-environment isolation +- **Durable Goal execution**: bounded segments + database scheduler + supervisor + leases / attempts / accepted-input queue recover unfinished goals after backend restarts +- **A2A interoperability**: MateClaw acts as both A2A server and client with Agent Cards, streamed tasks, get/cancel, and constrained outbound calls +- **Runtime reliability and boundaries**: Team delivery gates, checkpoint recovery, long-form output, approval-time input, generated files, and workspace-scoped administration are hardened +- Optional OfficeCLI, ACP prompt timeout, and plugin-tool discovery round out external-runtime operations + +Full story: [v2.2.0 release notes](./releases/2.2.0.md); guides: [DeepSeek Harness](./deepseek-harness), [Persistent Goals](./goals), and [A2A](./a2a). + --- -## Next: Agent Loop & Team follow-through +## Next: Resident runtimes & Team follow-through > "Great things in business are never done by one person. They're done by a team of people." -Look back along the line: v1.2 gave employees an identity, v1.3 made flows orchestratable, v1.4 made employees follow goals and spin up delegation trees, v1.7 made long tasks visible, v2.0 made teams a standing roster, and **v2.1 made every round deliverable, learnable, and governable**. +Look back along the line: v1.2 gave employees an identity, v1.3 made flows orchestratable, v1.4 made employees follow goals and spin up delegation trees, v1.7 made long tasks visible, v2.0 made teams a standing roster, v2.1 made every round deliverable, learnable, and governable, and **v2.2 made execution engines replaceable, long goals recoverable across requests and restarts, and agents interoperable across systems**. -One "stop" remains: **employees are reactive.** Goal auto-followup only lives **within a single run**; cron and triggers can wake an employee up, but every wake-up is an isolated response. No employee is truly **on duty** — continuously watching its area of responsibility and deciding for itself when to act. +One “stop” remains: **durable Goals can continue, but employees still lack a general resident responsibility loop.** Cron and triggers can wake them, and the Goal supervisor can recover one objective, but there is no unified inbox, heartbeat, duty journal, or priority policy across objectives. The next step generalizes 2.2's runtime / queue / recovery foundation into a true on-duty state. ### Agent Team follow-through — the roster exists; now it grows skills @@ -183,7 +196,7 @@ A new state for employees: **on duty**. Not waiting for you to speak, but cyclin - [ ] **Resident loop runtime**: an employee can be set "on duty," waking on a configurable heartbeat (minutes to days) to check its area of responsibility - [ ] **Task inbox**: channel messages, trigger events, delegations from other employees, to-dos you toss over — one queue, consumed by priority on each wake-up -- [ ] **Cross-session goal continuation**: v1.4/v1.5 auto-followup lives inside a single run; the loop carries goals across sessions and across days until every criterion is checked +- [x] **Cross-request and restart-safe Goal continuation (2.2)**: the Goal supervisor, durable queue, leases / attempts, and recovery carry goals across bounded execution segments and service restarts until the checklist passes or execution explicitly pauses - [ ] **Budgets and circuit breakers**: per-loop token / cost / turn budgets; consecutive failures trip the breaker into sleep pending your decision; ToolGuard approval gates still intercept sensitive actions — autonomy is not loss of control - [ ] **Loop journal**: what it did each wake-up, why it chose not to act, what it spent — human-readable and replayable, what DREAMS.md is to memory - [ ] **Pause / resume / clock-out**: controllable from the UI and from channel commands; the Run Overview sidebar shows every on-duty employee's loop state @@ -239,7 +252,8 @@ A leader on a loop, members summoned on demand — that's a **self-running digit | **v1.8** | It does a whole job | Content Studio — one sentence to a publishable 公众号 / 小红书 post + browser ref interaction | ✅ Released | | **v2.0** | **It leads a team** | **Agent Teams + a shared task board — the lead decomposes and dispatches, members run in parallel, deliverables and full observability** | ✅ Released | | **v2.1** | **It turns collaboration into a run** | **Unified Team Runs + closed skill evolution + replayable reasoning trajectories + proactive channel delivery** | ✅ Released | -| **Next** | **It's on duty** | **Agent Loop resident cycles + team follow-through (peer review / team goals / group binding / retrospectives) = a department that runs itself** | 📋 Planned | +| **v2.2** | **It reshapes the runtime** | **Pluggable runtimes + DeepSeek Harness + durable Goal recovery + A2A interoperability** | ✅ Released | +| **Next** | **It's on duty** | **Resident runtime (heartbeat / inbox / journal / circuit breakers) + team follow-through = a department that runs itself** | 📋 Planned | --- @@ -251,7 +265,7 @@ We're building it because we believe one thing: **AI shouldn't be a chat box on a webpage. It should be your second brain.** -It lives in your DingTalk, your Feishu, your Telegram. It's read every document you have. It remembers what you said three months ago. It uses your company's internal tools. It consolidates memory while you sleep. It runs an entire business flow on your behalf. **It can now deliver one complete round of team work and learn from it; next, that team stays on duty and watches over the things you can't get to.** +It lives in your DingTalk, your Feishu, your Telegram. It's read every document you have. It remembers what you said three months ago. It uses your company's internal tools. It consolidates memory while you sleep. It runs an entire business flow on your behalf. **Its runtime is now replaceable, its long goals recoverable, and its external agents interoperable; next, that foundation becomes a digital department that stays on duty.** Someday, you'll forget it's a program. diff --git a/mateclaw-server/src/main/resources/docs/zh/a2a.md b/mateclaw-server/src/main/resources/docs/zh/a2a.md new file mode 100644 index 00000000..8c8e5a37 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/a2a.md @@ -0,0 +1,39 @@ +# A2A 协议 + +MateClaw 可以把已启用智能体暴露为 A2A JSON-RPC 端点,也可以通过工具调用其他 A2A 对端。 + +## 配置 + +```yaml +mateclaw: + a2a: + enabled: true + base-url: https://your-public-host + call-timeout-ms: 120000 + max-tasks: 1000 + task-ttl-seconds: 3600 +``` + +生产环境必须配置 `base-url`。为空时,MateClaw 会按入站请求推导名片 URL,这依赖反向代理正确传递 Host 与协议头。 + +## 入站 + +- `GET /.well-known/agent-card.json` 和匿名 `GET /api/a2a/card` 返回最小公开名片,不包含 `skills`。 +- 带 Bearer 访问 `GET /api/a2a/card` 返回 enabled 智能体列表到 `skills[]`;调用时把智能体 id 放到 `message.metadata.skillId`。 +- `POST /api/a2a` 复用 MateClaw Bearer token 鉴权,支持 `message/send`、`message/stream`、`tasks/get`、`tasks/cancel`。 + +## 出站工具 + +智能体可调用 `call_a2a_agent`: + +```json +{ + "url": "https://peer.example.com/api/a2a", + "headers": { + "Authorization": "Bearer token" + }, + "stream": false +} +``` + +出站默认拒绝内网和保留地址,不跟随重定向,限制响应大小;阻塞发送返回在途任务时,会继续轮询 `tasks/get`。 diff --git a/mateclaw-server/src/main/resources/docs/zh/acp.md b/mateclaw-server/src/main/resources/docs/zh/acp.md index a09246b7..5b173a35 100644 --- a/mateclaw-server/src/main/resources/docs/zh/acp.md +++ b/mateclaw-server/src/main/resources/docs/zh/acp.md @@ -198,7 +198,7 @@ ACP 服务端可以在做敏感动作前(写文件、跑 shell 命令等)发 - `initialize` 握手:15 秒 - `session/new`:10 秒 -- 整个 `session/prompt` 往返:5 分钟 +- 整个 `session/prompt` 往返:默认 300 秒,可按端点配置,最高 3600 秒 - stdio 缓冲上限:单次 50 MiB(行级 `stdio_buffer_limit_bytes` 可改) --- @@ -215,6 +215,7 @@ ACP 服务端可以在做敏感动作前(写文件、跑 shell 命令等)发 | `args_json` | TEXT | NULL | CLI 参数(JSON 数组) | | `env_json` | TEXT | NULL | 环境变量覆盖(JSON 对象) | | `tool_parse_mode` | VARCHAR(32) | `call_title` | `call_title` / `call_detail` / `update_detail` | +| `prompt_timeout_seconds` | INT | 300 | `session/prompt` 调用超时,最高 3600 秒 | | `builtin` | BOOLEAN | FALSE | 内置行写保护 | | `trusted` | BOOLEAN | TRUE | 自动放行权限请求 | | `enabled` | BOOLEAN | FALSE | 默认关闭,按需打开 | diff --git a/mateclaw-server/src/main/resources/docs/zh/deepseek-harness.md b/mateclaw-server/src/main/resources/docs/zh/deepseek-harness.md new file mode 100644 index 00000000..2a4d404c --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/deepseek-harness.md @@ -0,0 +1,202 @@ +--- +title: DeepSeek Harness 接入 +description: 在 MateClaw 中安装 DeepSeek Harness,并把它配置为数字员工运行时。 +head: + - - meta + - name: keywords + content: DeepSeek Harness,DSH,数字员工,Agent runtime,JSON-RPC,Cordis +--- + +# DeepSeek Harness 接入 + +本文说明如何把官方 DeepSeek Harness(简称 DSH)接入 MateClaw,并在员工页面创建一个由 DSH 驱动的数字员工。 + +DSH 在 MateClaw 中是**员工运行时**,不是 MCP 工具,也不是普通插件。MCP 负责为员工提供工具;DSH 负责启动外部 Agent 进程、运行 ReAct 循环并把思考、工具和文本事件流回 MateClaw。 + +## 架构 + +```text +MateClaw Chat / SSE + | + v +DSH Runtime Provider + | + v JSON-RPC over stdin/stdout +dsh-jsonrpc-agent + | + v +DeepSeek API + Cordis composition +``` + +MateClaw 仍然负责员工、会话、权限、工作空间、消息持久化和 UI 投影。DSH 只负责运行时回合。API Key 由 MateClaw 的 DeepSeek 提供商配置注入到 DSH 子进程,不要把密钥写进 `runtimeConfig`、员工提示词或仓库文件。 + +## 前置条件 + +- macOS、Linux 或 Windows(本文命令以 macOS / Linux 为例) +- JDK 21 +- 已启动的 MateClaw 后端和前端 +- DeepSeek API Key +- 已构建的 DSH JSON-RPC Agent +- DSH 仓库中的 Cordis 配置文件 + +确认 DSH 可执行文件: + +```bash +"$DSH_JSONRPC_AGENT" --help +test -x "$DSH_JSONRPC_AGENT" +test -f "$DSH_CORDIS_CONFIG" +``` + +## 安装 DSH + +请以 [DeepSeek Harness 官方仓库](https://github.com/deepseek-ai/deepseek-harness) 的安装说明为准构建运行时。构建完成后,需要得到两个路径: + +```text +/dist-exe/dsh-jsonrpc-agent-pkg- +/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml +``` + +不要把 DSH 二进制复制到 MateClaw 的源码仓库。推荐放在独立目录,并通过环境变量告诉 MateClaw 位置。 + +## 在 IDEA 中配置后端 + +打开 IDEA 的 **Run | Edit Configurations...**,选择 MateClaw 的 Spring Boot 配置,在 **Environment variables** 中增加: + +```text +DSH_JSONRPC_AGENT=/absolute/path/to/dsh-jsonrpc-agent-pkg-macos-arm64 +DSH_CORDIS_CONFIG=/absolute/path/to/cordis.yml +DSH_CWD=/absolute/path/to/mateclaw-workspace +``` + +示例: + +```text +DSH_JSONRPC_AGENT=/opt/deepseek-harness/dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 +DSH_CORDIS_CONFIG=/opt/deepseek-harness/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml +DSH_CWD=/var/lib/mateclaw/workspace +``` + +`DSH_CWD` 必须是后端进程可读写的目录。IDEA 启动配置中的路径必须是绝对路径;修改后需要重启后端,Spring Boot 不会热加载环境变量。 + +## 配置 DeepSeek 提供商 + +1. 登录 MateClaw。 +2. 打开 **设置 → 模型**。 +3. 配置并启用 **DeepSeek** 提供商。 +4. 填入 DeepSeek API Key 和 Base URL。 +5. 确认至少有一个启用的 DeepSeek chat 模型。 + +DSH 默认模型是 `deepseek-v4-flash`。如果员工没有绑定具体模型,MateClaw 会使用全局默认模型名,并从 `deepseek` 提供商注入凭证。自定义模型时,模型必须能由 DeepSeek Harness 的 DeepSeek provider route 使用。 + +## 创建 DSH 数字员工 + +在 **数字员工 → 新建** 中: + +1. 填写员工名称、角色和目标。 +2. 将运行时选择为 **DSH / DeepSeek Harness**。 +3. 配置工作空间;留空时使用 `DSH_CWD`。 +4. `runtimeConfig` 使用 JSON 对象,例如: + +```json +{ + "mode": "qa", + "workspace": "default", + "policy": "read-only" +} +``` + +5. 保存员工并进入聊天。 + +运行时配置只描述员工级策略。不要在其中写 `DEEPSEEK_API_KEY`、Cookie、Bearer Token 或本机敏感路径。 + +## 验证清单 + +在 DSH 员工会话中发送: + +```text +请只回复:DSH_RUNTIME_OK +``` + +成功标准: + +- 员工标题显示 `DSH Harness`。 +- 输入框发送后能看到思考状态和文本流。 +- 日志出现 `provider=deepseek` 和 `apiKeyConfigured=true`。 +- 日志出现 `turn/end` 且 `kind=completed`。 +- 页面不会显示“本次没有输出”。 +- 同一个会话不会被并发启动两个 DSH live session。 + +不要复用已经完成过的测试 `conversationId` 创建新的 DSH live session。DSH 会检测到磁盘上的 session 日志与新的 live session 不一致,并返回 `id collision`。请使用“新对话”创建新的会话。 + +## 日志与诊断 + +后端日志通常位于: + +```text +logs/mateclaw.log +``` + +重点搜索: + +```bash +rg "\[DSH\]|MISSING_CREDENTIAL|EMPTY_RESPONSE|id collision" logs/mateclaw.log +``` + +安全诊断接口: + +```http +GET /api/v1/admin/agent-runtime/dsh/diagnostics +``` + +它只返回命令、可执行文件、Cordis 文件和能力状态,不返回 API Key。 + +## 常见问题 + +### `MISSING_CREDENTIAL` + +检查: + +1. 设置 → 模型中的 DeepSeek 提供商是否已启用。 +2. API Key 是否保存成功。 +3. 员工是否使用 DSH,而不是把 DSH 二进制配置成 MCP command。 +4. 后端是否使用了修改后的 IDEA 配置并完成重启。 + +日志中的 `apiKeyConfigured=false` 表示凭证没有进入 DSH 子进程。 + +### `EMPTY_RESPONSE` + +检查模型是否可用、Base URL 是否正确,以及该模型是否支持当前请求。先使用固定短消息验证,再逐步增加工具或技能。 + +### `dsh.command_unavailable` + +`DSH_JSONRPC_AGENT` 必须指向真实可执行文件。检查文件权限: + +```bash +chmod +x /absolute/path/to/dsh-jsonrpc-agent-pkg-macos-arm64 +``` + +### `dsh.cordis_missing` + +`DSH_CORDIS_CONFIG` 必须指向实际存在的 `cordis.yml`,不是 DSH 包目录。若传入包目录,MateClaw 会尝试解析其下的 `runtime/cordis.yml`。 + +### 页面显示重复回答 + +确保使用最新后端版本。DSH 会同时发送增量文本事件和最终消息快照,MateClaw 只应投影增量文本,不能把快照再次追加到回答中。 + +## MCP、插件和 DSH 的边界 + +| 机制 | 适合做什么 | 是否替代 DSH | +|------|------------|-------------| +| MCP | 提供文件、GitHub、数据库等工具 | 否 | +| 插件 | 扩展 MateClaw 的工具、模型、渠道或记忆能力 | 否 | +| DSH 员工运行时 | 承载 DeepSeek Harness 的 Agent 循环和外部进程 | 是员工运行时,不是工具 | + +推荐组合是:**DSH 作为员工运行时,MCP 作为工具层,MateClaw 作为治理和可视化层**。 + +## 安全建议 + +- API Key 只放在 MateClaw 模型提供商配置或受控环境变量中。 +- DSH 工作空间使用专用目录,不要直接指向整个用户主目录。 +- 初次接入使用只读策略和最小工具集。 +- 不要把 `.sessions/`、日志或配置文件提交到 Git。 +- 生产环境中限制 DSH 子进程的文件、网络和凭证访问范围。 diff --git a/mateclaw-server/src/main/resources/docs/zh/goals.md b/mateclaw-server/src/main/resources/docs/zh/goals.md index 858342f8..654020f3 100644 --- a/mateclaw-server/src/main/resources/docs/zh/goals.md +++ b/mateclaw-server/src/main/resources/docs/zh/goals.md @@ -9,6 +9,14 @@ head: # 持久化目标 +## 持续执行模式(第一版) + +新建目标默认 `persistentExecution=true`,省略预算时 `turnBudget=0`、`llmCallBudget=0` 表示不设累计上限。显式正预算仍生效;已有目标保留旧模式,不会在升级后自动启动。 + +持续目标由数据库队列和后台 supervisor 跨图片段调度;单次图执行的次数上限只结束当前片段,不结束目标。队列、冷却、重试和过期租约可在服务重启后恢复。仅当清单全部通过且有非空证据时才能完成,Stop、缺少必要输入、审批拒绝和预算耗尽会暂停,需明确 resume。 + +`GET /api/v1/goals/{id}/execution` 返回独立的调度状态、原因和到期时间;它是最近的调度记录,目标当前状态以 goal API 为准。流中通过 `goal_continuation` 广播调度变化。第一版支持单后端实例的原生 runtime,不保证外部工具副作用恰好一次;恢复先检查已有产物与异步句柄。预算在片段边界检查,不是逐请求的硬费用限制。 + > **以前你每轮都要把上下文重复一遍。现在你定一个目标,员工自己跟。** 一次对话里你说"帮我把这个博客部署到 fly.io",员工答完一轮就停了。下一轮你要再问"DNS 配好没?证书呢?测试跑了吗?"——你在替它记目标。 @@ -109,7 +117,7 @@ POST /api/v1/goals ### 自动延续是怎么发生的 -如果 `autoFollowupEnabled=true` 且这一轮 evaluator 判 "continue",后台会: +持续模式会持久化下一次执行时间,由 supervisor 发起新的图片段。下面的图内延续流程只适用于 `persistentExecution=false` 的旧模式: 1. 写一条 `followup_injected` 事件到时间线 2. 给对话末尾 APPEND 一条用户消息。**1.5.0 起,如果目标有清单,这条消息会明确列出还没通过的那几条准则**——"5/8 已完成,剩余:① …… ② ……,去做剩下的";没有清单时回退到笼统的 "Continue working on the goal. Still missing: {gap}." @@ -198,9 +206,9 @@ Plan-Execute 模式下,单个步骤可能**抛异常**,也可能陷入**停 --- -## 4 个内置工具(员工可用) +## 内置目标工具(员工可用) -员工的工具集里默认包含这 4 个(无需手动绑定,是 agent-wide 系统级工具): +员工的工具集里默认包含以下工具(无需手动绑定,是 agent-wide 系统级工具): | 工具 | 用途 | 触发提示词示例 | |---|---|---| @@ -208,6 +216,7 @@ Plan-Execute 模式下,单个步骤可能**抛异常**,也可能陷入**停 | **addGoalCriterion** | 追加子准则到已有目标 | "再加一条准则:必须支持 IPv6" | | **completeGoal** | 显式标记完成 | "所有事项已做完,请 completeGoal" | | **getGoalStatus** | 查询当前 goal 状态 | "我们现在进展到哪了?" | +| **waitForGoalInput** | 持续目标缺少必要输入时暂停并记录原因 | "缺少部署域名,请等待用户补充" | 完成时(`completeGoal`,或 evaluator 判定**每一条准则都通过**),员工会把这个目标的总结同步到[长期记忆](./memory),后续对话能查得回来。 @@ -215,7 +224,7 @@ Plan-Execute 模式下,单个步骤可能**抛异常**,也可能陷入**停 ## 子员工不能改父员工的目标 -[多员工协作](./agents)里 parent 员工可以委派 child 员工干活。Child **看不到**这 4 个 goal 工具 — 目标是 parent 会话的状态,child 是无状态的执行体。 +[多员工协作](./agents)里 parent 员工可以委派 child 员工干活。Child **看不到**这些 goal 工具 — 目标是 parent 会话的状态,child 是无状态的执行体。 > 这一条是设计意图,不是 bug。child 帮 parent 做事,但目标的"所有权"留在 parent 那。 @@ -227,7 +236,7 @@ Plan-Execute 模式下,单个步骤可能**抛异常**,也可能陷入**停 turnsUsed >= turnBudget 或 (agentLlmCallsUsed + evalLlmCallsUsed) >= llmCallBudget ``` -任一条命中 → 目标状态翻为 **exhausted**,不再触发评估、不再注入 follow-up,光环变橙红色。员工的最后一轮回答会正常发送给你。 +持续模式仅检查正预算;`0` 表示不限。达到预算后目标进入 **paused**,调度状态为 **budget_limited**,增加预算后可 resume。旧模式仍进入终态 **exhausted**,需要新建目标才能继续。当前片段的回答仍会保存。 你的选择: @@ -247,7 +256,8 @@ turnsUsed >= turnBudget 或 (agentLlmCallsUsed + evalLlmCallsUsed) >= llmCallB active ──evaluator 全部准则通过 / completeGoal──→ completed (终态) ↓ - active ──turns_used/llm_calls 用完 ─────────→ exhausted (终态) + active ──正预算用完(持续模式)───────────→ paused + active ──预算用完(旧模式)───────────────→ exhausted (终态) ↓ active ──user abandon ─────────────────────→ abandoned (终态) ``` @@ -300,9 +310,18 @@ mateclaw: default-auto-followup: true # 运行期总开关;关掉则无论 per-goal 标志如何,都不注入自动延续 allow-auto-followup: true - # 默认 turn 预算 + # 单后端实例同时运行的持久化目标 Segment 上限 + max-concurrent-segments: 4 + # 普通 Segment 结算后再次续跑的全局最小间隔(秒) + minimum-continuation-interval-seconds: 1 + # provider 或评估器发生可重试故障后,暂停认领其他目标的时间(秒;0 = 关闭) + provider-failure-global-backoff-seconds: 30 + # 新目标默认持续模式;省略预算表示不限(0) + default-persistent-execution: true + supervisor-poll-ms: 5000 + # 旧模式的默认 turn 预算 default-turn-budget: 20 - # 默认 LLM 调用预算(agent + evaluator 之和) + # 旧模式的默认 LLM 调用预算(agent + evaluator 之和) default-llm-call-budget: 200 # 自动延续之间至少隔多久(秒) auto-followup-cooldown-seconds: 0 @@ -318,18 +337,23 @@ mateclaw: evaluator-context-messages: 8 ``` +`minimum-continuation-interval-seconds` 与目标自身的 `followupCooldownSeconds` 取较大值。 +provider 全局退避只保存在当前后端实例内;重启恢复仍以数据库中的 continuation +`next_run_at` 和每目标失败次数为准。耐久或低配模型测试建议使用并发 `1`、最小间隔 +`300` 秒、provider 全局退避 `300` 秒,再根据日志逐步放量。 + --- ## 数据库 -两张表,都用 `mate_` 前缀: +相关表使用 `mate_` 前缀: | 表 | 用途 | |---|---| | `mate_agent_goal` | 目标本体;含 status / budget / 双 LLM 计数器 / 自动延续配置 | | `mate_agent_goal_event` | 目标的事件追加日志,drawer 时间线读它 | -迁移由 Flyway 跑 `V120__agent_goal.sql`(H2 / MySQL / KingbaseES 三方言)。 +持续调度表 `mate_goal_continuation` 记录队列、到期时间和租约。迁移由 Flyway 跑 `V120__agent_goal.sql` 和 `V188__goal_continuation.sql`(H2 / MySQL / KingbaseES 三方言)。 --- diff --git a/mateclaw-server/src/main/resources/docs/zh/index.md b/mateclaw-server/src/main/resources/docs/zh/index.md index cc4a9031..1e287285 100644 --- a/mateclaw-server/src/main/resources/docs/zh/index.md +++ b/mateclaw-server/src/main/resources/docs/zh/index.md @@ -20,12 +20,15 @@ hero: link: https://github.com/mateaix/mateclaw features: + - icon: ⚙️ + title: 员工 Runtime,不只一种 Agent Loop + details: 2.2.0 用统一 Runtime Contract 把员工身份与推理引擎解耦。Native 与 DeepSeek Harness 共用会话、工作空间、工具治理和事件投影;持久目标跨请求与后端重启恢复,A2A 连接外部 Agent。 - icon: 🧑‍💼 title: 数字员工,不是聊天机器人 details: 你雇佣同事,不是开聊天框。每位有角色 / 目标 / 背景故事、像素艺术头像与专属配色——6 个内置模板开箱可用。ReAct + Plan-and-Execute 双模式,员工之间并行委派。 - icon: 🤝 title: 团队,不是单打独斗 - details: 一次团队请求对应一个 Team Run:目标、任务 DAG、成员执行、最终汇总与交付物统一追踪。Chat 交付成果、Agents 观察实时运行、Teams 管理历史与审批——2.1.0 起,一轮协作就是一份完整工作记录。 + details: 一次团队请求对应一个 Team Run:目标、任务 DAG、成员执行、最终汇总与交付物统一追踪。Chat 交付成果、Agents 观察实时运行、Teams 管理历史与审批;2.2.0 进一步加固 checkpoint 恢复与交付物完成门。 - icon: 🧩 title: 技能是骨架,不是插件 details: 一份 SKILL.md + 一份 LESSONS.md(用得越多越聪明)。8 个起步模板,向导 5 步出包,安装前自动 Pre-flight 检查。MCP / ACP 双桥接,连 Claude Code、Codex 都能进来当员工。 diff --git a/mateclaw-server/src/main/resources/docs/zh/releases.md b/mateclaw-server/src/main/resources/docs/zh/releases.md index bce87686..4e387e16 100644 --- a/mateclaw-server/src/main/resources/docs/zh/releases.md +++ b/mateclaw-server/src/main/resources/docs/zh/releases.md @@ -10,6 +10,7 @@ | 版本 | 日期 | 亮点 | |------|------|------| +| [v2.2.0](./releases/2.2.0) | 2026-08-29 | **Runtime 主线**——员工运行时 contract + provider registry + session 生命周期,把员工与内置推理循环解耦;**DeepSeek Harness** 以认证 JSON-RPC 子进程接入,工具继续经过宿主工作空间策略与 Tool Guard,并提供安装/配置/校验/启停管理;**持久目标跨有界片段和后端重启恢复**(数据库队列 · supervisor · lease · attempt · 输入排队 · provider 退避 · checklist 证据完成门);**A2A 双向互联**(Agent Card · message/send/stream · task 查询/取消 · `call_a2a_agent` · SSRF/超时/响应上限);Team checkpoint / 任务板恢复 / 交付物完成门 / worker 附件与长回答加固;可选 OfficeCLI 引擎 + ACP prompt timeout + 工作空间边界收口 | | [v2.1.0](./releases/2.1.0) | 2026-08-15 | **统一 Team Run**——一次团队请求、任务 DAG、成员执行、最终汇总与交付物共用 `runId`,Chat 成果交付 / Agents 实时观察 / Teams 历史治理读取同一投影,成员子会话不再污染普通会话列表 · **Skill 自进化闭环**(跨会话重复请求 mining · reflection · 受约束自动绑定 · curator 治理移交 · origin 策略 · 快照与恢复点,按工作空间隔离且默认保守) · **推理轨迹可回放**(实时 `` 提取 · UI 真实耗时 · 全部/最终轮次控制 · 纯文本 trajectory 导出) · 主动渠道消息 + Cron 定向投递 · 模型窗口覆盖/探测/目录预算 · 渐进式工具桥 + 行动完成约束 · 浏览器 ref/导航/等待加固 · WebChat/SSE/LLM 流回收与超时 · 飞书执行进度 · Qwen3-ASR HTTP · 会话批量删除 · 按日文件目录 · 64 位 id 与数值工具 schema 精度修复 | | [v2.0.0](./releases/2.0.0) | 2026-07-31 | **Agent 团队与共享任务板**——Lead 拆任务、成员并行执行(团队/角色 · 八状态看板 · `blockedBy` 依赖编排 · 前置结果自动传递 · 结果通报唤醒 Lead · 交付物登记下载 · 任务时间线 + 团队 SSE 实时看板 · 执行租约心跳 + 取消即中断 + `in_review` 审批卡点) · **Plan-Execute 计划整体移交任务板**(步骤→任务 · 依赖→并行 · 停靠恢复门确定性汇总) · 工作空间隔离全面收口(渠道会话 id 编入渠道 · 同名技能跨工作空间共存且运行时按会话工作空间解析) · 渠道魔法命令(`/new` `/clear` `/status` `/stop` `/model` `/help`)+ 企微事件驱动进度气泡(实时工具轨迹 · 分阶段滚动叙述) · 会话回退/重新生成服务端语义 · 自动批准未命中可解释(原因码落审计行 + 一键补策略 + 表单防呆) · LLM 错误恢复策略化(过载/限流分治 · `Retry-After` 回馈退避 · provider TTL 回收 · 抖动防重试风暴) · 聊天附件在线预览(pdf/docx/xlsx/html/文本) · SKILL.md 单一事实源 + 捆绑文件控制台管理 · Mem0 可选插件记忆 provider · 知识图谱关系模式白名单 | | [v1.8.0](./releases/1.8.0) | 2026-07-12 | 内容工作室——一句话到可发布成品(预置「内容工作室」员工跑通 选题→搜集→成文→配图→去AI化→排版→交付) · **微信公众号(公众号)** 图文文章(`gzh_article` · 内联样式 HTML · `gzh_publish` 推进草稿箱)+ **小红书** 以图为主图文笔记(`xhs_note` · ≥3 张竖版 3:4 卡片 · 在线预览) · 可度量**去 AI 化**(启发式 AI 痕迹评分 → 检测/改写/复检闭环,硬上限 3 轮) · 发布链加固(正文图上传进微信 · AES-GCM 密钥加密 · 微信服务+token 复用 · 重试 + 中文错误提示 · 兜底封面) · **内容日历**(交付即合规扫描 + 自动落台账 · 选题指纹去重 · 只读页) · 浏览器 Agent **无障碍树 ref 交互** + 真实浏览器隐私护栏 + 受控 CDP 逃生舱 · 注意力锚定 + 工具调用循环护栏 + 改动后校验提醒 · 快加载(初始包体 ↓约 78%) · 上下文占用面板 · 跨知识库 wikilink · MCP 进度通知 · 火山方舟供应商 · PostgreSQL 16 | diff --git a/mateclaw-server/src/main/resources/docs/zh/roadmap.md b/mateclaw-server/src/main/resources/docs/zh/roadmap.md index 4b542e74..f1da3fe9 100644 --- a/mateclaw-server/src/main/resources/docs/zh/roadmap.md +++ b/mateclaw-server/src/main/resources/docs/zh/roadmap.md @@ -156,15 +156,28 @@ MateClaw 就是这个东西。 完整故事:[v2.1.0 Release Notes](./releases/2.1.0.md),使用指南:[Team Run 与团队协作](./teams)、[技能系统](./skills)。 +### v2.2 —— 运行时可替换、长任务可恢复 ✅ 已发布(2026-08-29) + +2.1 定义了什么是一份完整运行;2.2 让“由谁来运行、如何在中断后继续、怎样与外部 Agent 协作”成为基础设施能力。 + +- **员工 Runtime Contract**:provider registry、session factory、能力声明与统一事件流,让 native / 外部 runtime 共用会话、工作空间、工具治理和 UI 投影 +- **DeepSeek Harness Runtime**:认证 JSON-RPC 子进程桥、思考/文本/工具/生命周期事件、宿主工具策略、受管理安装配置与环境隔离 +- **持久目标执行**:有界 segment + 数据库调度队列 + supervisor + lease / attempt / 输入排队,服务重启后恢复未完成目标 +- **A2A 互联**:MateClaw 同时成为 A2A server 和 client,支持 Agent Card、流式任务、查询/取消以及受限出站调用 +- **运行时可靠性与边界**:Team 交付完成门、checkpoint 恢复、长回答、审批输入、生成文件和管理视图的工作空间隔离全面加固 +- 可选 OfficeCLI 文档引擎、ACP prompt timeout 与插件工具发现补齐扩展运行时的日常能力 + +完整故事:[v2.2.0 Release Notes](./releases/2.2.0.md),使用指南:[DeepSeek Harness](./deepseek-harness)、[持久化目标](./goals)、[A2A 协议](./a2a)。 + --- -## 下一站:Agent Loop 与团队进阶 +## 下一站:常驻 Runtime 与团队进阶 > "伟大的事业不是一个人做成的,是一个团队做成的。" -回头看这条线:v1.2 员工有了身份,v1.3 流程能编排,v1.4 员工会自主跟目标、能临时拉起委派树,v1.7 长任务看得见,v2.0 团队成了常设编制,**v2.1 又让每轮团队工作可交付、可学习、可治理**。 +回头看这条线:v1.2 员工有了身份,v1.3 流程能编排,v1.4 员工会自主跟目标、能临时拉起委派树,v1.7 长任务看得见,v2.0 团队成了常设编制,v2.1 让每轮团队工作可交付、可学习、可治理,**v2.2 则让执行引擎可替换、长目标能跨请求和重启恢复、Agent 能跨系统互联**。 -还剩一个"停":**员工是被动的。** 目标的自动延续只活在**单次运行内**;cron 和触发器能定时叫醒它,但每次醒来都是一次孤立的响应。没有一个员工真正"在岗"——持续盯着自己的职责范围,自己决定什么时候该干什么。 +还剩一个“停”:**持久目标已经会继续,但员工还没有通用的常驻职责循环。** cron 和触发器能定时叫醒它,Goal supervisor 能恢复一项目标,却还没有统一 inbox、心跳、值班日志和跨目标的优先级决策。下一步是把 2.2 的 runtime / queue / recovery 基础推广成真正的“在岗”状态。 ### Agent Team 进阶 —— 编制有了,接下来长本事 @@ -183,7 +196,7 @@ MateClaw 就是这个东西。 - [ ] **常驻循环运行时**:员工可以被设为"在岗",按可配置的心跳(分钟级到天级)自主醒来检查职责范围 - [ ] **任务收件箱(Inbox)**:渠道消息、触发器事件、其他员工的委派、你随手丢的待办——统一进一个队列,循环醒来按优先级消化 -- [ ] **跨会话目标延续**:v1.4 / v1.5 的自动延续只活在单次运行内;loop 让目标跨会话、跨天持续推进,直到清单全勾完 +- [x] **跨请求与重启的目标延续(2.2)**:Goal supervisor、持久队列、lease / attempt 和恢复逻辑把目标带过有界执行片段与服务重启,直到清单通过或明确暂停 - [ ] **预算与熔断**:每循环有 token / 成本 / 轮次预算,连续失败自动熔断进入休眠等你处置;ToolGuard 审批门禁照常拦截敏感操作——自主不等于失控 - [ ] **循环日志(Loop Journal)**:每次醒来干了什么、为什么决定不干、花了多少——人类可读、可回放,像 DREAMS.md 之于记忆 - [ ] **暂停 / 恢复 / 一键下班**:UI 和渠道命令都能控制;运行总览侧栏显示每个在岗员工的循环状态 @@ -239,7 +252,8 @@ MateClaw 就是这个东西。 | **v1.8** | 它干完一整件活 | 内容工作室 —— 一句话到可发布的公众号 / 小红书成品 + 浏览器 ref 交互 | ✅ 已发布 | | **v2.0** | **它带队干活** | **Agent 团队 + 共享任务板 —— Lead 拆解派发、成员并行执行、交付物与全程可观测** | ✅ 已发布 | | **v2.1** | **它把协作变成运行** | **统一 Team Run + Skill 自进化闭环 + 可回放推理轨迹 + 主动渠道投递** | ✅ 已发布 | -| **下一站** | **它长期在岗** | **Agent Loop 常驻循环 + 团队进阶(互审 / 团队目标 / 群绑定 / 复盘)= 会自己运转的数字部门** | 📋 规划中 | +| **v2.2** | **它重塑运行时** | **可插拔 Runtime + DeepSeek Harness + 持久 Goal 恢复 + A2A 互联** | ✅ 已发布 | +| **下一站** | **它长期在岗** | **通用常驻 Runtime(心跳 / inbox / journal / 熔断)+ 团队进阶 = 会自己运转的数字部门** | 📋 规划中 | --- @@ -251,7 +265,7 @@ MateClaw 就是这个东西。 **AI 不应该是一个网页上的对话框。它应该是你的第二个大脑。** -它住在你的钉钉里、你的飞书里、你的 Telegram 里。它读过你所有的文档。它记得你三个月前说过的话。它会用你公司的内部工具。它在你睡觉的时候整理记忆。它能替你跑一整条业务流程。**现在它已经能把一轮团队工作完整交付并从中学习;下一步,是让这支团队长期在岗,替你盯着那些你顾不上的事。** +它住在你的钉钉里、你的飞书里、你的 Telegram 里。它读过你所有的文档。它记得你三个月前说过的话。它会用你公司的内部工具。它在你睡觉的时候整理记忆。它能替你跑一整条业务流程。**现在它的运行时可替换、长目标可恢复、外部 Agent 可互联;下一步,是把这套底座推进成长期在岗的数字部门。** 总有一天,你会忘记它是一个程序。 diff --git a/mateclaw-server/src/test/java/vip/mate/acp/service/AcpDelegationServiceTimeoutTest.java b/mateclaw-server/src/test/java/vip/mate/acp/service/AcpDelegationServiceTimeoutTest.java new file mode 100644 index 00000000..55c9c9a0 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/acp/service/AcpDelegationServiceTimeoutTest.java @@ -0,0 +1,36 @@ +package vip.mate.acp.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.acp.model.AcpEndpointEntity; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class AcpDelegationServiceTimeoutTest { + + @Test + @DisplayName("ACP prompt timeout defaults to 5 minutes when endpoint is unset") + void defaultPromptTimeout() { + AcpEndpointEntity endpoint = new AcpEndpointEntity(); + + assertEquals(300_000L, AcpDelegationService.resolvePromptTimeoutMillis(endpoint)); + } + + @Test + @DisplayName("ACP prompt timeout uses the endpoint setting") + void endpointPromptTimeout() { + AcpEndpointEntity endpoint = new AcpEndpointEntity(); + endpoint.setPromptTimeoutSeconds(900); + + assertEquals(900_000L, AcpDelegationService.resolvePromptTimeoutMillis(endpoint)); + } + + @Test + @DisplayName("ACP prompt timeout is clamped to a one-hour hard ceiling") + void clampPromptTimeout() { + AcpEndpointEntity endpoint = new AcpEndpointEntity(); + endpoint.setPromptTimeoutSeconds(7200); + + assertEquals(3_600_000L, AcpDelegationService.resolvePromptTimeoutMillis(endpoint)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceTurnAdmissionTest.java b/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceTurnAdmissionTest.java new file mode 100644 index 00000000..07344975 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceTurnAdmissionTest.java @@ -0,0 +1,51 @@ +package vip.mate.agent; + +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import reactor.core.publisher.Flux; +import vip.mate.memory.MemoryProperties; +import java.util.function.BiFunction; +import java.util.function.Function; +import static org.junit.jupiter.api.Assertions.*; + +class AgentServiceTurnAdmissionTest { + @Test void onlyInteractiveCompletionWakesApprovalWaitEvenAfterContextHasExited() { + MemoryProperties properties = new MemoryProperties(); + properties.setLifecycleMediatorEnabled(false); + AgentService service = new AgentService(null,null,null,null,properties,null,null); + var events = org.mockito.Mockito.mock(org.springframework.context.ApplicationEventPublisher.class); + ReflectionTestUtils.setField(service,"events",events); + var source = reactor.core.publisher.Sinks.empty(); + BiFunction> invoke = (message,conversation) -> source.asMono().flux(); + Function content = Function.identity(); + Flux automatic = ReflectionTestUtils.invokeMethod(service,"withLifecycleFlux",1L,"a","conv",invoke,content); + vip.mate.agent.context.GoalContinuationContext.call(automatic::subscribe); + source.tryEmitEmpty(); + org.mockito.Mockito.verifyNoInteractions(events); + + BiFunction> interactiveInvoke = (message,conversation) -> Flux.empty(); + Flux interactive = ReflectionTestUtils.invokeMethod(service,"withLifecycleFlux",1L,"b","conv",interactiveInvoke,content); + interactive.blockLast(); + org.mockito.Mockito.verify(events).publishEvent(new vip.mate.goal.service.GoalExecutionSignal.TurnFinished("conv")); + } + + @Test void admissionIsLazySharedAndReleasedOnCancellation() { + MemoryProperties properties = new MemoryProperties(); + properties.setLifecycleMediatorEnabled(false); + AgentService service = new AgentService(null,null,null,null,properties,null,null); + BiFunction> invoke = (message,conversation) -> Flux.never(); + Function content = Function.identity(); + Flux first = ReflectionTestUtils.invokeMethod(service,"withLifecycleFlux",1L,"a","conv",invoke,content); + Flux second = ReflectionTestUtils.invokeMethod(service,"withLifecycleFlux",1L,"b","conv",invoke,content); + assertNotNull(first); assertNotNull(second); + var subscription = first.subscribe(); + var error = new java.util.concurrent.atomic.AtomicReference(); + second.subscribe(value -> {},error::set); + assertNotNull(error.get(),"a second turn must not execute alongside the first"); + subscription.dispose(); + error.set(null); + var third = second.subscribe(value -> {},error::set); + assertNull(error.get()); + third.dispose(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceUniquenessTest.java b/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceUniquenessTest.java index 1e5957c7..56fff5ea 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceUniquenessTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceUniquenessTest.java @@ -16,6 +16,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; /** @@ -93,6 +94,36 @@ class AgentServiceUniquenessTest { assertNotEquals(a.getId(), b.getId()); } + @Test + @DisplayName("createAgent 默认使用 native runtime,兼容旧 Agent") + void createDefaultsToNativeRuntime() { + AgentEntity created = agentService.createAgent(newAgent("Native-default", workspaceA)); + + assertEquals("native", created.getRuntimeType()); + assertEquals("native", agentService.getAgent(created.getId()).getRuntimeType()); + + created.setRuntimeConfig("{\"binary\":\"dsh\"}"); + agentService.updateAgent(created); + assertEquals("{\"binary\":\"dsh\"}", + agentService.getAgent(created.getId()).getRuntimeConfig()); + + created.setRuntimeConfig(null); + agentService.updateAgent(created); + assertNull(agentService.getAgent(created.getId()).getRuntimeConfig()); + } + + @Test + @DisplayName("createAgent 拒绝未注册的 runtime provider") + void createRejectsUnknownRuntimeProvider() { + AgentEntity agent = newAgent("Unknown-runtime", workspaceA); + agent.setRuntimeType("unknown-provider"); + + MateClawException ex = assertThrows(MateClawException.class, + () -> agentService.createAgent(agent)); + assertEquals(400, ex.getCode()); + assertEquals("err.agent.runtime_unsupported", ex.getMsgKey()); + } + @Test @DisplayName("createAgent 拒绝空名(fail-fast 在 unique 检查之前)") void createRejectsBlankName() { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentCronIsolationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentCronIsolationTest.java index bdc88a6a..7e56d2a4 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentCronIsolationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentCronIsolationTest.java @@ -6,12 +6,14 @@ import org.junit.jupiter.api.Test; import org.springframework.ai.chat.messages.Message; import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ChatOriginHolder; +import vip.mate.agent.context.GoalContinuationContext; import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.model.MessageEntity; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -98,6 +100,45 @@ class BaseAgentCronIsolationTest { "a normal turn keeps prior history (the trailing current user row is de-duplicated)"); } + @Test + void goalContinuation_keepsHistoryButUsesExplicitInstruction() { + ConversationService conv = mock(ConversationService.class); + List stored = List.of(user("Reply READY"), assistant("READY")); + when(conv.countMessages("goal_conv")).thenReturn((long) stored.size()); + when(conv.listMessages("goal_conv")).thenReturn(stored); + stubRender(conv); + TestAgent agent = newAgent(conv); + ChatOriginHolder.set(ChatOrigin.web("goal_conv", "u1", 1L, null)); + + GoalContinuationContext.call(() -> { + assertEquals(2, agent.history("goal_conv", "Continue with checkpoint 1").size()); + assertEquals("Continue with checkpoint 1", + agent.currentMessage("goal_conv", "Continue with checkpoint 1"), + "the durable continuation must not replay the last persisted user instruction"); + return null; + }); + } + + @Test + void queuedUserTurn_reconstructsStoredContentAndRestoresContinuationScope() { + ConversationService conv = mock(ConversationService.class); + when(conv.listMessages("goal_conv")).thenReturn(List.of(user("Current user with attachment text"))); + stubRender(conv); + TestAgent agent = newAgent(conv); + + GoalContinuationContext.call(() -> { + GoalContinuationContext.call(false, () -> { + assertTrue(GoalContinuationContext.active()); + assertEquals("Current user with attachment text", agent.currentMessage("goal_conv", "fallback")); + return null; + }); + assertEquals("Continue after queued input", agent.currentMessage("goal_conv", "Continue after queued input")); + return null; + }); + assertFalse(GoalContinuationContext.active()); + assertFalse(GoalContinuationContext.explicitPrompt()); + } + // ---------- scaffold ---------- private static TestAgent newAgent(ConversationService conv) { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/StateGraphReActAgentStreamedContentDeltaTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/StateGraphReActAgentStreamedContentDeltaTest.java index 88e31ebf..3028edff 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/StateGraphReActAgentStreamedContentDeltaTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/StateGraphReActAgentStreamedContentDeltaTest.java @@ -166,4 +166,27 @@ class StateGraphReActAgentStreamedContentDeltaTest { assertEquals(1, deltas.size()); assertFalse(deltas.get(0).isEvent()); } + + @Test + @DisplayName("long-form chunks are not persisted separately from their combined final answer") + void longFormChunk_combinedFinalAnswerOwnsPersistence() { + assertFalse(StateGraphReActAgent.shouldEmitStreamedContent( + false, true, "chapter one", "")); + assertFalse(StateGraphReActAgent.shouldEmitStreamedContent( + true, true, "last chapter", "chapter one...last chapter")); + } + + @Test + @DisplayName("terminal streamed text already contained in final answer is not duplicated") + void normalTerminalContent_finalAnswerOwnsPersistence() { + assertFalse(StateGraphReActAgent.shouldEmitStreamedContent( + true, false, "answer", "answer")); + } + + @Test + @DisplayName("terminal body omitted from a warning-only final answer remains persistable") + void evidenceWarning_keepsSeparateBodyPersistence() { + assertTrue(StateGraphReActAgent.shouldEmitStreamedContent( + true, false, "unsupported answer body", "[证据不足] missing source")); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorReturnDirectTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorReturnDirectTest.java index 18f45ed1..d900f65e 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorReturnDirectTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorReturnDirectTest.java @@ -10,11 +10,15 @@ import org.springframework.ai.tool.definition.ToolDefinition; import org.springframework.ai.tool.metadata.ToolMetadata; import vip.mate.agent.AgentToolSet; import vip.mate.agent.GraphEventPublisher; +import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.graph.state.DirectToolOutput; +import vip.mate.tool.ToolInputValidationException; import vip.mate.tool.guard.ToolGuard; import vip.mate.tool.guard.ToolGuardResult; import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; @@ -29,6 +33,7 @@ import static org.junit.jupiter.api.Assertions.*; * fixed placeholder, not the sensitive content. *

  • An {@code EVENT_TOOL_DIRECT_RESULT} event is emitted with the full text * and {@code renderAs=assistant_message}.
  • + *
  • A payload-free {@code EVENT_TOOL_COMPLETE} closes the live tool card.
  • *
  • Non-direct tools in the same batch keep their existing behavior.
  • * */ @@ -81,11 +86,17 @@ class ToolExecutionExecutorReturnDirectTest { assertEquals(SECRET, data.get("result")); assertEquals("assistant_message", data.get("renderAs")); - // (4) no tool_call_completed event for the direct tool — direct path replaces it - boolean hasCompleted = result.events().stream() - .anyMatch(e -> GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(e.type())); - assertFalse(hasCompleted, "direct path replaces tool_call_completed; double-emit would " + - "leak the placeholder into UI as a tool result card"); + // (4) the started tool card receives a terminal pair, without leaking + // the direct payload into its ordinary result field. + var completed = result.events().stream() + .filter(e -> GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(e.type())) + .toList(); + assertEquals(1, completed.size()); + assertEquals(Boolean.TRUE, completed.get(0).data().get("success")); + assertEquals(ToolExecutionExecutor.DIRECT_TOOL_PLACEHOLDER, + completed.get(0).data().get("result")); + assertFalse(String.valueOf(completed.get(0).data().get("result")) + .contains("EMPLOYEE-SALARY")); } @Test @@ -110,6 +121,39 @@ class ToolExecutionExecutorReturnDirectTest { assertFalse(hasDirect); } + @Test + @DisplayName("duplicate load_skill calls execute once across batch and loaded state") + void duplicateSkillLoadsAreShortCircuitedBeforeParallelExecution() { + AtomicInteger executions = new AtomicInteger(); + ToolCallback loadSkill = stubCallback("load_skill", false, args -> { + executions.incrementAndGet(); + return "skill instructions"; + }); + ToolExecutionExecutor executor = newExecutor(loadSkill); + AssistantMessage.ToolCall first = new AssistantMessage.ToolCall( + "skill_1", "function", "load_skill", "{\"skillName\":\"docx\"}"); + AssistantMessage.ToolCall duplicate = new AssistantMessage.ToolCall( + "skill_2", "function", "load_skill", "{\"skillName\":\"DOCX\"}"); + + ToolExecutionExecutor.ToolExecutionResult batch = executor.execute( + List.of(first, duplicate), "conv", "agent", false, "", null, + ChatOrigin.EMPTY, Set.of()); + + assertEquals(1, executions.get()); + assertEquals(2, batch.responses().size()); + assertTrue(batch.responses().get(1).responseData().contains("already loaded")); + assertTrue(batch.events().stream().anyMatch(event -> + GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(event.type()) + && "skill_2".equals(event.data().get("toolCallId")) + && Boolean.TRUE.equals(event.data().get("success")))); + + ToolExecutionExecutor.ToolExecutionResult laterTurn = executor.execute( + List.of(first), "conv", "agent", false, "", null, + ChatOrigin.EMPTY, Set.of("docx")); + assertEquals(1, executions.get()); + assertTrue(laterTurn.responses().getFirst().responseData().contains("already loaded")); + } + @Test @DisplayName("RFC-052: returnDirect tool throwing yields generic message (no exception details leak)") void directTool_throwing_genericErrorMessage() { @@ -133,6 +177,27 @@ class ToolExecutionExecutorReturnDirectTest { assertFalse(content.contains("OracleDriver"), "Stack/connection details must not leak"); } + @Test + @DisplayName("RFC-052: safe input validation errors return to the model for correction") + void directTool_validationErrorIsActionableAndDoesNotShortCircuit() { + ToolCallback invalidDirect = stubCallback("renderDocx", true, args -> { + throw new ToolInputValidationException("markdown must not be blank"); + }); + ToolExecutionExecutor executor = newExecutor(invalidDirect); + + AssistantMessage.ToolCall call = new AssistantMessage.ToolCall( + "call_v", "function", "renderDocx", "{\"markdown\":\"\"}"); + ToolExecutionExecutor.ToolExecutionResult result = + executor.execute(List.of(call), "conv_v", "agent_v", false, "user_v", null); + + assertFalse(result.hasDirectOutputs(), "invalid input must not trigger returnDirect"); + assertEquals("Tool input validation failed: markdown must not be blank", + result.responses().get(0).responseData()); + assertTrue(result.events().stream() + .anyMatch(e -> GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(e.type()) + && Boolean.FALSE.equals(e.data().get("success")))); + } + @Test @DisplayName("RFC-052: pre-approved direct tool replays through direct path") void executePreApproved_directTool_takesDirectPath() { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorWorkspaceGuardContextTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorWorkspaceGuardContextTest.java new file mode 100644 index 00000000..d45db569 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorWorkspaceGuardContextTest.java @@ -0,0 +1,72 @@ +package vip.mate.agent.graph.executor; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.metadata.ToolMetadata; +import vip.mate.agent.AgentToolSet; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.tool.guard.model.GuardEvaluation; +import vip.mate.tool.guard.model.ToolInvocationContext; +import vip.mate.tool.guard.service.ToolGuardService; + +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ToolExecutionExecutorWorkspaceGuardContextTest { + + @Test + @DisplayName("guard evaluation receives state workspaceBasePath when origin has no base path (#617)") + void guardUsesWorkspaceBasePathArgumentWhenOriginIsBlank(@TempDir Path workspaceRoot) { + ToolGuardService guardService = mock(ToolGuardService.class); + when(guardService.evaluate(any(ToolInvocationContext.class), eq(true))) + .thenReturn(GuardEvaluation.allow("write_file")); + ToolExecutionExecutor executor = new ToolExecutionExecutor( + AgentToolSet.fromCallbacks(List.of(), List.of(stub("write_file"))), + guardService, + null, + null); + + executor.execute( + List.of(new AssistantMessage.ToolCall( + "call_1", "function", "write_file", + "{\"filePath\":\"deck.md\",\"content\":\"# Deck\"}")), + "conv-617", + "agent-617", + false, + "alice", + workspaceRoot.toString(), + ChatOrigin.web("conv-617", "alice", 1L, null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ToolInvocationContext.class); + verify(guardService).evaluate(captor.capture(), eq(true)); + assertThat(captor.getValue().workspaceBasePath()).isEqualTo(workspaceRoot.toString()); + } + + private static ToolCallback stub(String name) { + ToolDefinition def = ToolDefinition.builder() + .name(name) + .description("test tool " + name) + .inputSchema("{\"type\":\"object\",\"properties\":{}}") + .build(); + ToolMetadata md = ToolMetadata.builder().returnDirect(false).build(); + return new ToolCallback() { + @Override public ToolDefinition getToolDefinition() { return def; } + @Override public ToolMetadata getToolMetadata() { return md; } + @Override public String call(String arguments) { return "ok"; } + @Override public String call(String arguments, ToolContext toolContext) { return "ok"; } + }; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/GoalEvaluationNodeContinuationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/GoalEvaluationNodeContinuationTest.java index 91d4bd87..eea8a165 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/GoalEvaluationNodeContinuationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/GoalEvaluationNodeContinuationTest.java @@ -4,12 +4,14 @@ import com.alibaba.cloud.ai.graph.OverAllState; import org.junit.jupiter.api.Test; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.UserMessage; +import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.context.ConversationWindowManager; import vip.mate.agent.graph.state.FinishReason; import vip.mate.agent.graph.state.MateClawStateKeys; import vip.mate.goal.config.GoalProperties; import vip.mate.goal.model.GoalEntity; import vip.mate.goal.model.GoalEvaluationResult; +import vip.mate.goal.model.GoalCriterion; import vip.mate.goal.model.GoalResponse; import vip.mate.goal.service.GoalEvaluationService; import vip.mate.goal.service.GoalFollowupService; @@ -176,6 +178,43 @@ class GoalEvaluationNodeContinuationTest { assertFalse(out.containsKey(MateClawStateKeys.CURRENT_ITERATION)); } + @Test + void persistentGoalYieldsToDurableSupervisorInsteadOfSpendingGraphFollowups() throws Exception { + Fixture f = new Fixture(); + GoalEntity persistent = f.goalService.getById(1L); + persistent.setPersistentExecution(true); + persistent.setStatus(vip.mate.goal.model.GoalStatus.ACTIVE); + Map out = f.node().apply(f.state(FinishReason.NORMAL.getValue(),0,0)); + assertEquals(Boolean.TRUE,out.get(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN)); + assertFalse(out.containsKey(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED)); + verify(f.followupService,never()).maybeBuildFollowup(any(),any()); + } + + @Test + void goalEvaluationEventCarriesStateSafeGoalSnapshot() throws Exception { + Fixture f = new Fixture(); + GoalEntity persistent = f.goalService.getById(1L); + persistent.setPersistentExecution(true); + persistent.setStatus(vip.mate.goal.model.GoalStatus.ACTIVE); + + GoalResponse response = new GoalResponse(); + response.setId(1L); + response.setTitle("ship the feature"); + response.setCriteria(List.of(new GoalCriterion("C1", "tests pass", false, ""))); + when(f.goalService.toResponse(any())).thenReturn(response); + + Map out = f.node().apply(f.state(FinishReason.NORMAL.getValue(),0,0)); + @SuppressWarnings("unchecked") + List events = + (List) out.get(MateClawStateKeys.PENDING_EVENTS); + Object goalSnapshot = events.get(0).data().get("goal"); + + assertInstanceOf(Map.class, goalSnapshot); + Object criteria = ((Map) goalSnapshot).get("criteria"); + assertInstanceOf(List.class, criteria); + assertInstanceOf(Map.class, ((List) criteria).get(0)); + } + // ===== Test fixture ===== private static final class Fixture { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java index acf25a25..b4f58a2b 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java @@ -105,6 +105,28 @@ class ReasoningNodeOutputTest { assertEquals("回答内容", output.get(FINAL_ANSWER)); } + @Test + @DisplayName("plain long-form requests reject hallucinated artifact tool calls") + void plainLongFormArtifactToolCall_continuesWithoutExecutingTool() throws Exception { + AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall( + "docx-1", "function", "renderDocx", "{\"filename\":\"novel\"}"); + AssistantMessage assistant = AssistantMessage.builder() + .content("我将生成文档") + .toolCalls(List.of(toolCall)) + .build(); + NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult( + "我将生成文档", "", assistant, List.of(toolCall), true, 100, 50); + when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result); + + Map state = baseStateMap(); + state.put(USER_MESSAGE, "帮我写个 5000 字的玄幻短篇小说,角色和剧情都你自己编。"); + Map output = createNode().apply(new OverAllState(state)); + + assertEquals(false, output.get(NEEDS_TOOL_CALL)); + assertEquals(true, output.get(CONTINUE_REASONING)); + assertEquals(List.of(), output.get(TOOL_CALLS)); + } + @Test @DisplayName("action-required text-only candidate requests one reasoning continuation") void actionRequiredTextOnly_continuesOnce() throws Exception { @@ -141,6 +163,134 @@ class ReasoningNodeOutputTest { assertTrue(((String) output.get(FINAL_ANSWER)).contains("未观察到实际")); } + @Test + @DisplayName("long-form text request continues when generated content is far below requested length") + void longFormTextRequest_continuesUntilRequestedLength() throws Exception { + String partial = "玄".repeat(1200); + NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult( + partial, "", new AssistantMessage(partial), + List.of(), false, 100, 900); + when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result); + + Map state = baseStateMap(); + state.put(USER_MESSAGE, "帮我写个 10000 字的玄幻小说,角色和剧情都你自己编。"); + state.put(MAX_ITERATIONS, 100); + Map output = createNode().apply(new OverAllState(state)); + + assertEquals(true, output.get(CONTINUE_REASONING)); + assertEquals("", output.get(FINAL_ANSWER)); + assertEquals(1, output.get(CURRENT_ITERATION)); + assertEquals(partial, output.get("long_form_draft"), + "Each continuation must retain the generated body for the terminal answer"); + List appended = (List) output.get(MESSAGES); + assertEquals(2, appended.size()); + assertTrue(appended.get(1) instanceof org.springframework.ai.chat.messages.UserMessage); + assertTrue(((org.springframework.ai.chat.messages.UserMessage) appended.get(1)).getText() + .contains("继续写"), + "Continuation prompt should ask the model to keep writing instead of ending the run"); + } + + @Test + @DisplayName("long-form continuation persists all chunks as one final answer") + void longFormTextRequest_combinesContinuationChunksInFinalAnswer() throws Exception { + String firstChunk = "甲".repeat(6000); + String finalChunk = "乙".repeat(4000); + NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult( + finalChunk, "", new AssistantMessage(finalChunk), + List.of(), false, 100, 900); + when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result); + + Map state = baseStateMap(); + state.put(USER_MESSAGE, "帮我写个 10000 字的玄幻小说,角色和剧情都你自己编。"); + state.put(MAX_ITERATIONS, 100); + state.put(CURRENT_ITERATION, 1); + state.put("long_form_draft", firstChunk); + + Map output = createNode().apply(new OverAllState(state)); + + assertEquals(false, output.get(CONTINUE_REASONING)); + assertEquals(firstChunk + finalChunk, output.get(FINAL_ANSWER)); + assertEquals(true, output.get(CONTENT_STREAMED), + "The combined answer was already streamed chunk by chunk and must not be broadcast twice"); + } + + @Test + @DisplayName("configured max iterations stops long-form continuation at the configured boundary") + void longFormTextRequest_honorsConfiguredMaxIterations() throws Exception { + String partial = "玄".repeat(1200); + NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult( + partial, "", new AssistantMessage(partial), + List.of(), false, 100, 900); + when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result); + + Map state = baseStateMap(); + state.put(USER_MESSAGE, "帮我写个 10000 字的玄幻小说,角色和剧情都你自己编。"); + state.put(MAX_ITERATIONS, 1); + + Map output = createNode().apply(new OverAllState(state)); + + assertEquals(false, output.get(CONTINUE_REASONING)); + assertEquals(partial, output.get(FINAL_ANSWER)); + } + + @Test + @DisplayName("long-form length parser accepts a grouped 10,000-character request") + void requestedLongFormChars_acceptsGroupedNumber() { + assertEquals(10_000, ReasoningNode.requestedLongFormChars("写一篇 10,000 字小说").orElseThrow()); + } + + @Test + @DisplayName("plain long-form writing stays inline and cannot terminate through artifact render tools") + void plainLongFormRequest_filtersArtifactDeliveryTools() { + ToolCallback renderDocx = mockTool("renderDocxFromFiles"); + ToolCallback writeFile = mockTool("write_file"); + ToolCallback progress = mockTool("progress_update"); + + List filtered = ReasoningNode.filterLongFormArtifactTools( + "帮我写个 10000 字的玄幻小说,角色和剧情都你自己编。", + List.of(renderDocx, writeFile, progress)); + + assertEquals(List.of(progress), filtered); + } + + @Test + @DisplayName("explicit document delivery keeps artifact render tools available") + void explicitLongFormDocumentRequest_keepsArtifactDeliveryTools() { + ToolCallback renderDocx = mockTool("renderDocxFromFiles"); + + List filtered = ReasoningNode.filterLongFormArtifactTools( + "写一篇 10000 字小说并生成 Word 文档给我下载。", + List.of(renderDocx)); + + assertEquals(List.of(renderDocx), filtered); + } + + @Test + @DisplayName("artifact words from injected memory do not override the current plain writing request") + void injectedMemoryArtifactPreference_doesNotKeepArtifactTools() { + ToolCallback writeFile = mockTool("write_file"); + String augmentedMessage = """ + + 用户偏好 Word 文档、文件下载和保存到工作区。 + + 帮我写个 10000 字的玄幻小说,角色和剧情都你自己编。 + """; + + List filtered = ReasoningNode.filterLongFormArtifactTools( + augmentedMessage, List.of(writeFile)); + + assertTrue(filtered.isEmpty()); + } + + private static ToolCallback mockTool(String name) { + ToolCallback callback = mock(ToolCallback.class); + org.springframework.ai.tool.definition.ToolDefinition definition = + mock(org.springframework.ai.tool.definition.ToolDefinition.class); + when(definition.name()).thenReturn(name); + when(callback.getToolDefinition()).thenReturn(definition); + return callback; + } + @Test @DisplayName("failed action receipt overrides a model success claim") void failedActionReceipt_blocksSuccessClaim() throws Exception { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/StepExecutionSkillCatalogTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/StepExecutionSkillCatalogTest.java new file mode 100644 index 00000000..f2836717 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/StepExecutionSkillCatalogTest.java @@ -0,0 +1,64 @@ +package vip.mate.agent.graph.plan.node; + +import com.alibaba.cloud.ai.graph.OverAllState; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.Message; +import vip.mate.agent.graph.plan.state.PlanStateAccessor; +import vip.mate.agent.graph.plan.state.PlanStateKeys; +import vip.mate.agent.graph.state.MateClawStateKeys; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class StepExecutionSkillCatalogTest { + + @Test + @SuppressWarnings("unchecked") + void stepMessagesRenderSkillCatalogWithSkillsLoadedThisRun() throws Exception { + AtomicReference> seenLoaded = new AtomicReference<>(); + StepExecutionNode node = new StepExecutionNode( + null, null, null, null, null, null, null, null, + loaded -> { + seenLoaded.set(loaded); + return "## Skills\n- docx"; + }, + 1_000L); + + Method method = StepExecutionNode.class.getDeclaredMethod( + "buildStepMessages", + PlanStateAccessor.class, String.class, String.class, + String.class, String.class, String.class); + method.setAccessible(true); + + List messages = (List) method.invoke( + node, + accessor(Set.of("docx")), + "生成 Word 文档", + "system", + "/tmp/workspace", + "qwen", + "dashscope"); + + assertEquals(Set.of("docx"), seenLoaded.get()); + assertTrue(messages.stream().anyMatch(m -> m.getText().contains("## Skills"))); + } + + private static PlanStateAccessor accessor(Set loadedSkills) { + Map values = new HashMap<>(); + values.put(PlanStateKeys.GOAL, "生成文档"); + values.put(PlanStateKeys.PLAN_STEPS, new ArrayList<>(List.of("生成 Word 文档"))); + values.put(PlanStateKeys.CURRENT_STEP_INDEX, 0); + values.put(PlanStateKeys.COMPLETED_RESULTS, new ArrayList()); + values.put(PlanStateKeys.WORKING_CONTEXT, ""); + values.put(MateClawStateKeys.LOADED_SKILLS, loadedSkills); + return new PlanStateAccessor(new OverAllState(values)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/AgentRuntimeAggregatorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/AgentRuntimeAggregatorTest.java new file mode 100644 index 00000000..19dea60f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/AgentRuntimeAggregatorTest.java @@ -0,0 +1,77 @@ +package vip.mate.agent.runtime; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import vip.mate.agent.AgentService; +import vip.mate.agent.delegation.SubagentRegistry; +import vip.mate.agent.model.AgentEntity; +import vip.mate.channel.web.ChatStreamTracker; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AgentRuntimeAggregatorTest { + + private static final long WORKSPACE_A = 10L; + private static final long WORKSPACE_B = 20L; + + @Test + void workspaceSnapshotExcludesRunsAndSubagentsFromOtherWorkspaces() { + ChatStreamTracker tracker = new ChatStreamTracker(new ObjectMapper()); + tracker.register("conv-a"); + tracker.bindRunMeta("conv-a", 100L, "alice"); + tracker.register("conv-b"); + tracker.bindRunMeta("conv-b", 200L, "bob"); + tracker.register("conv-unknown"); + + SubagentRegistry subagents = new SubagentRegistry(); + subagents.register("conv-a", "child-a", 100L, "task a", null); + subagents.register("conv-b", "child-b", 200L, "task b", null); + + AgentService agents = mock(AgentService.class); + when(agents.getAgent(100L)).thenReturn(agent(100L, WORKSPACE_A, "A")); + when(agents.getAgent(200L)).thenReturn(agent(200L, WORKSPACE_B, "B")); + + AgentRuntimeAggregator aggregator = new AgentRuntimeAggregator(tracker, subagents, agents); + + AgentRuntimeAggregator.RuntimeSnapshot snapshot = aggregator.snapshot(WORKSPACE_A); + + assertThat(snapshot.runs()) + .extracting(AgentRuntimeAggregator.RunCard::conversationId) + .containsExactly("conv-a"); + assertThat(snapshot.subagents()) + .extracting(AgentRuntimeAggregator.SubagentCard::childConversationId) + .containsExactly("child-a"); + assertThat(snapshot.summary().running()).isEqualTo(1); + assertThat(snapshot.summary().subagentsActive()).isEqualTo(1); + } + + @Test + void runBelongsToWorkspaceOnlyWhenAgentMetadataMatches() { + ChatStreamTracker tracker = new ChatStreamTracker(new ObjectMapper()); + tracker.register("conv-a"); + tracker.bindRunMeta("conv-a", 100L, "alice"); + tracker.register("conv-b"); + tracker.bindRunMeta("conv-b", 200L, "bob"); + + AgentService agents = mock(AgentService.class); + when(agents.getAgent(100L)).thenReturn(agent(100L, WORKSPACE_A, "A")); + when(agents.getAgent(200L)).thenReturn(agent(200L, WORKSPACE_B, "B")); + + AgentRuntimeAggregator aggregator = new AgentRuntimeAggregator( + tracker, new SubagentRegistry(), agents); + + assertThat(aggregator.runBelongsToWorkspace("conv-a", WORKSPACE_A)).isTrue(); + assertThat(aggregator.runBelongsToWorkspace("conv-b", WORKSPACE_A)).isFalse(); + assertThat(aggregator.runBelongsToWorkspace("missing", WORKSPACE_A)).isFalse(); + } + + private static AgentEntity agent(Long id, Long workspaceId, String name) { + AgentEntity agent = new AgentEntity(); + agent.setId(id); + agent.setWorkspaceId(workspaceId); + agent.setName(name); + return agent; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/AgentRuntimeControllerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/AgentRuntimeControllerTest.java new file mode 100644 index 00000000..76610d26 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/AgentRuntimeControllerTest.java @@ -0,0 +1,108 @@ +package vip.mate.agent.runtime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import vip.mate.agent.delegation.SubagentRegistry; +import vip.mate.audit.service.AuditEventService; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.exception.MateClawException; +import vip.mate.i18n.I18nService; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.agent.runtime.dsh.DshRuntimeService; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AgentRuntimeControllerTest { + + private static final long WORKSPACE_ID = 10L; + private AgentRuntimeAggregator aggregator; + private ChatStreamTracker streamTracker; + private SubagentRegistry subagentRegistry; + private AgentRuntimeController controller; + + @BeforeEach + void setUp() { + aggregator = mock(AgentRuntimeAggregator.class); + streamTracker = mock(ChatStreamTracker.class); + subagentRegistry = mock(SubagentRegistry.class); + controller = new AgentRuntimeController( + aggregator, + streamTracker, + subagentRegistry, + mock(AuditEventService.class), + mock(ConversationService.class), + mock(I18nService.class), + mock(DshRuntimeService.class)); + } + + @Test + void snapshotUsesCurrentWorkspace() { + AgentRuntimeAggregator.RuntimeSnapshot snapshot = + new AgentRuntimeAggregator.RuntimeSnapshot( + new AgentRuntimeAggregator.Summary(0, 0, 0, 0, 0), + List.of(), List.of(), 123L); + when(aggregator.snapshot(WORKSPACE_ID)).thenReturn(snapshot); + + assertEquals(snapshot, controller.snapshot(WORKSPACE_ID, admin()).getData()); + + verify(aggregator).snapshot(WORKSPACE_ID); + } + + @Test + void snapshotRequiresWorkspace() { + MateClawException ex = assertThrows(MateClawException.class, + () -> controller.snapshot(null, admin())); + + assertEquals(400, ex.getCode()); + } + + @Test + void stopRejectsRunOutsideCurrentWorkspace() { + when(aggregator.runBelongsToWorkspace("conv-b", WORKSPACE_ID)).thenReturn(false); + + MateClawException ex = assertThrows(MateClawException.class, + () -> controller.stopFriendly("conv-b", WORKSPACE_ID, admin())); + + assertEquals(404, ex.getCode()); + verify(streamTracker, never()).requestStop("conv-b"); + } + + @Test + void recycleRejectsRunOutsideCurrentWorkspace() { + when(aggregator.runBelongsToWorkspace("conv-b", WORKSPACE_ID)).thenReturn(false); + + MateClawException ex = assertThrows(MateClawException.class, + () -> controller.recycle("conv-b", WORKSPACE_ID, admin())); + + assertEquals(404, ex.getCode()); + verify(streamTracker, never()).forceRecycle("conv-b"); + } + + @Test + void interruptRejectsSubagentOutsideCurrentWorkspace() { + when(aggregator.subagentBelongsToWorkspace("sa-b", WORKSPACE_ID)).thenReturn(false); + + MateClawException ex = assertThrows(MateClawException.class, + () -> controller.interruptSubagent("sa-b", WORKSPACE_ID, admin())); + + assertEquals(404, ex.getCode()); + verify(subagentRegistry, never()).interrupt("sa-b"); + } + + private static Authentication admin() { + return new UsernamePasswordAuthenticationToken( + "admin", + "n/a", + List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/ConversationTurnGateTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/ConversationTurnGateTest.java new file mode 100644 index 00000000..59c1e72b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/ConversationTurnGateTest.java @@ -0,0 +1,35 @@ +package vip.mate.agent.runtime; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class ConversationTurnGateTest { + @Test void onlyOneTurnOwnsConversationAndOldReleaseCannotReleaseNewOwner() { + ConversationTurnGate gate = new ConversationTurnGate(); + var first = gate.tryAcquire("conversation"); + assertNotNull(first); + assertNull(gate.tryAcquire("conversation")); + assertNotNull(gate.tryAcquire("other")); + first.close(); + var second = gate.tryAcquire("conversation"); + assertNotNull(second); + first.close(); + assertNull(gate.tryAcquire("conversation")); + second.close(); + assertNotNull(gate.tryAcquire("conversation")); + } + + @Test void admittedBackgroundCallCanEnterLifecycleWithoutReleasingOuterPermit() { + ConversationTurnGate gate = new ConversationTurnGate(); + var outer = gate.tryAcquire("conv"); + gate.withPermit(outer, () -> { + var nested = gate.tryAcquire("conv"); + assertNotNull(nested); + nested.close(); + return null; + }); + assertNull(gate.tryAcquire("conv")); + outer.close(); + assertNotNull(gate.tryAcquire("conv")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/RuntimeEventProjectorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/RuntimeEventProjectorTest.java new file mode 100644 index 00000000..6c0795df --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/RuntimeEventProjectorTest.java @@ -0,0 +1,47 @@ +package vip.mate.agent.runtime; + +import org.junit.jupiter.api.Test; +import vip.mate.agent.AgentService; +import vip.mate.agent.runtime.contract.RuntimeEvent; +import vip.mate.agent.runtime.contract.RuntimeEventType; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RuntimeEventProjectorTest { + @Test + void projectsAssistantAndToolEventsToExistingStreamVocabulary() { + AgentService.StreamDelta assistant = RuntimeEventProjector.project( + RuntimeEvent.of("session-1", 4, RuntimeEventType.ASSISTANT_DELTA, null, + Map.of("delta", "hello"))); + assertEquals("hello", assistant.content()); + + AgentService.StreamDelta tool = RuntimeEventProjector.project( + RuntimeEvent.of("session-1", 5, RuntimeEventType.TOOL_STARTED, null, + Map.of("callId", "call-1", "toolName", "read_file"))); + assertEquals("tool_call_started", tool.eventType()); + assertEquals("call-1", tool.eventData().get("toolCallId")); + assertEquals("read_file", tool.eventData().get("toolName")); + assertEquals("session-1", tool.eventData().get("runtimeSessionId")); + } + + @Test + void usesRuntimeEventTextWhenDeltaFieldIsAbsent() { + AgentService.StreamDelta assistant = RuntimeEventProjector.project( + RuntimeEvent.of("session-1", 1, RuntimeEventType.ASSISTANT_DELTA, + "from-text", Map.of())); + + assertEquals("from-text", assistant.content()); + } + + @Test + void projectsTerminalEventsWithoutChangingTerminalMeaning() { + AgentService.StreamDelta failed = RuntimeEventProjector.project( + RuntimeEvent.terminal("session-1", 9, RuntimeEventType.FAILED, + Map.of("message", "bridge closed"))); + assertEquals("error", failed.eventType()); + assertTrue(failed.eventData().containsKey("runtimeSequence")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/RuntimeEventStreamAdapterTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/RuntimeEventStreamAdapterTest.java new file mode 100644 index 00000000..dffd5075 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/RuntimeEventStreamAdapterTest.java @@ -0,0 +1,25 @@ +package vip.mate.agent.runtime; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import vip.mate.agent.runtime.contract.RuntimeEvent; +import vip.mate.agent.runtime.contract.RuntimeEventType; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class RuntimeEventStreamAdapterTest { + @Test + void adaptsProviderFluxInOrder() { + var deltas = RuntimeEventStreamAdapter.adapt(Flux.just( + RuntimeEvent.of("session", 0, RuntimeEventType.ASSISTANT_DELTA, + null, Map.of("delta", "a")), + RuntimeEvent.of("session", 1, RuntimeEventType.THINKING_DELTA, + null, Map.of("delta", "b")))).collectList().block(); + + assertEquals(2, deltas.size()); + assertEquals("a", deltas.get(0).content()); + assertEquals("b", deltas.get(1).thinking()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/AgentRuntimeCoordinatorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/AgentRuntimeCoordinatorTest.java new file mode 100644 index 00000000..c2a0dd72 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/AgentRuntimeCoordinatorTest.java @@ -0,0 +1,34 @@ +package vip.mate.agent.runtime.contract; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import vip.mate.agent.model.AgentEntity; + +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AgentRuntimeCoordinatorTest { + @Test + void startsOnlyAfterSessionValidation() { + AgentRuntimeConnection connection = mock(AgentRuntimeConnection.class); + AgentRuntimeProvider provider = mock(AgentRuntimeProvider.class); + when(provider.type()).thenReturn("dsh"); + when(provider.validate(org.mockito.ArgumentMatchers.any())).thenReturn(RuntimeValidation.success()); + when(provider.start(org.mockito.ArgumentMatchers.any())).thenReturn(connection); + + AgentRuntimeCoordinator coordinator = new AgentRuntimeCoordinator( + new RuntimeProviderRegistry(List.of(provider)), new ObjectMapper()); + AgentEntity agent = new AgentEntity(); + agent.setId(1L); + agent.setWorkspaceId(2L); + agent.setRuntimeType("dsh"); + agent.setRuntimeConfig("{}"); + + assertSame(connection, coordinator.start(agent, "conversation", "session", "model", + Path.of("/workspace"), Path.of("/workspace/agent"))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeContractInvariantTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeContractInvariantTest.java new file mode 100644 index 00000000..85522e0e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeContractInvariantTest.java @@ -0,0 +1,40 @@ +package vip.mate.agent.runtime.contract; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RuntimeContractInvariantTest { + + @Test + void terminalFlagMustMatchEventType() { + assertThrows(IllegalArgumentException.class, + () -> new RuntimeEvent("session-1", 1, RuntimeEventType.ASSISTANT_DELTA, + "text", Map.of(), true)); + } + + @Test + void completedResultCannotContainError() { + assertThrows(IllegalArgumentException.class, + () -> new RuntimeResult(RuntimeResult.Status.COMPLETED, "answer", "error", "broken")); + } + + @Test + void sessionConfigurationIsImmutable() { + Map configuration = new HashMap<>(); + configuration.put("permissionMode", "read-only"); + RuntimeSession session = new RuntimeSession( + "session-1", "conversation-1", 1L, 2L, "model", Path.of("/workspace"), configuration); + + configuration.put("permissionMode", "danger-full-access"); + + assertTrue(session.configuration().get("permissionMode").equals("read-only")); + assertThrows(UnsupportedOperationException.class, + () -> session.configuration().put("new", true)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeEventLogTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeEventLogTest.java new file mode 100644 index 00000000..4e9056f3 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeEventLogTest.java @@ -0,0 +1,38 @@ +package vip.mate.agent.runtime.contract; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class RuntimeEventLogTest { + + @Test + void rejectsEventsAfterTerminalEvent() { + RuntimeEventLog log = new RuntimeEventLog("session-1"); + + log.append(RuntimeEvent.of("session-1", 1, RuntimeEventType.ASSISTANT_DELTA, + "hello", Map.of())); + log.append(RuntimeEvent.terminal("session-1", 2, RuntimeEventType.COMPLETED, + Map.of("answer", "hello"))); + + assertThrows(IllegalStateException.class, + () -> log.append(RuntimeEvent.of("session-1", 3, RuntimeEventType.ASSISTANT_DELTA, + "late", Map.of()))); + } + + @Test + void rejectsOutOfOrderEventsAndWrongSession() { + RuntimeEventLog log = new RuntimeEventLog("session-1"); + log.append(RuntimeEvent.of("session-1", 2, RuntimeEventType.RUNTIME_READY, + null, Map.of())); + + assertThrows(IllegalArgumentException.class, + () -> log.append(RuntimeEvent.of("session-1", 1, RuntimeEventType.ASSISTANT_DELTA, + "late", Map.of()))); + assertThrows(IllegalArgumentException.class, + () -> log.append(RuntimeEvent.of("session-2", 3, RuntimeEventType.ASSISTANT_DELTA, + "wrong", Map.of()))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeProviderRegistryTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeProviderRegistryTest.java new file mode 100644 index 00000000..4745b73f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeProviderRegistryTest.java @@ -0,0 +1,57 @@ +package vip.mate.agent.runtime.contract; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class RuntimeProviderRegistryTest { + + @Test + void blankRuntimeUsesNativeProvider() { + AgentRuntimeProvider nativeProvider = provider("native"); + RuntimeProviderRegistry registry = new RuntimeProviderRegistry(List.of(nativeProvider, provider("dsh"))); + + assertEquals(nativeProvider, registry.resolve(null)); + assertEquals(nativeProvider, registry.resolve("")); + } + + @Test + void unknownRuntimeIsRejected() { + RuntimeProviderRegistry registry = new RuntimeProviderRegistry(List.of(provider("native"))); + + assertThrows(IllegalArgumentException.class, () -> registry.resolve("acp")); + } + + @Test + void duplicateRuntimeTypesAreRejected() { + assertThrows(IllegalArgumentException.class, + () -> new RuntimeProviderRegistry(List.of(provider("dsh"), provider("dsh")))); + } + + private static AgentRuntimeProvider provider(String type) { + return new AgentRuntimeProvider() { + @Override + public String type() { + return type; + } + + @Override + public RuntimeValidation validate(RuntimeSession session) { + return RuntimeValidation.success(); + } + + @Override + public RuntimeCapabilities capabilities() { + return new RuntimeCapabilities(true, true, true, true); + } + + @Override + public AgentRuntimeConnection start(RuntimeSession session) { + throw new UnsupportedOperationException(); + } + }; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeSessionFactoryTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeSessionFactoryTest.java new file mode 100644 index 00000000..de6b45cb --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeSessionFactoryTest.java @@ -0,0 +1,77 @@ +package vip.mate.agent.runtime.contract; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import vip.mate.agent.model.AgentEntity; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class RuntimeSessionFactoryTest { + + private final RuntimeProviderRegistry registry = new RuntimeProviderRegistry(List.of( + provider("native", RuntimeValidation.success()), + provider("dsh", RuntimeValidation.success()) + )); + private final RuntimeSessionFactory factory = new RuntimeSessionFactory( + registry, new ObjectMapper()); + + @Test + void createsWorkspaceBoundDshSessionFromPersistedConfig() { + AgentEntity agent = agent("dsh", "{\"binary\":\"deepseek\"}"); + + RuntimeSession session = factory.create(agent, "conversation-1", "session-1", + "model-a", Path.of("/workspace"), Path.of("/workspace/project")); + + assertEquals("session-1", session.sessionId()); + assertEquals("conversation-1", session.conversationId()); + assertEquals(Map.of("binary", "deepseek"), session.configuration()); + } + + @Test + void rejectsDshSessionOutsideWorkspace() { + assertThrows(IllegalArgumentException.class, () -> factory.create( + agent("dsh", "{}"), "conversation-1", "session-1", "model-a", + Path.of("/workspace"), Path.of("/tmp/outside"))); + } + + @Test + void rejectsDshSessionWithoutWorkspace() { + assertThrows(IllegalArgumentException.class, () -> factory.create( + agent("dsh", "{}"), "conversation-1", "session-1", "model-a", + null, Path.of("/workspace"))); + } + + @Test + void rejectsNonObjectRuntimeConfig() { + assertThrows(IllegalArgumentException.class, () -> factory.create( + agent("dsh", "[]"), "conversation-1", "session-1", "model-a", + Path.of("/workspace"), Path.of("/workspace"))); + } + + private static AgentEntity agent(String runtimeType, String runtimeConfig) { + AgentEntity agent = new AgentEntity(); + agent.setId(7L); + agent.setWorkspaceId(9L); + agent.setRuntimeType(runtimeType); + agent.setRuntimeConfig(runtimeConfig); + return agent; + } + + private static AgentRuntimeProvider provider(String type, RuntimeValidation validation) { + return new AgentRuntimeProvider() { + @Override public String type() { return type; } + @Override public RuntimeValidation validate(RuntimeSession session) { return validation; } + @Override public RuntimeCapabilities capabilities() { + return new RuntimeCapabilities(true, true, true, true); + } + @Override public AgentRuntimeConnection start(RuntimeSession session) { + throw new UnsupportedOperationException("test provider"); + } + }; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeEventsTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeEventsTest.java new file mode 100644 index 00000000..44f89919 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeEventsTest.java @@ -0,0 +1,26 @@ +package vip.mate.agent.runtime.dsh; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DshBridgeEventsTest { + @Test + void createsReadyToolApprovalAndSubagentMessages() { + assertEquals("ready", DshBridgeEvents.ready("s-1").method()); + assertEquals("tool/call", DshBridgeEvents.toolCall("c-1", "read", Map.of("path", "a.txt")).method()); + assertEquals("approval/ask", DshBridgeEvents.approvalAsk("a-1", "bash", "run command").method()); + assertEquals("subagent/lifecycle", DshBridgeEvents.subagentLifecycle( + "child-1", "started", Map.of("goal", "inspect")).method()); + } + + @Test + void createsToolCancellationNotification() { + DshBridgeMessage message = DshBridgeEvents.toolCancel("c-1"); + + assertEquals("tool/cancel", message.method()); + assertEquals("c-1", message.params().get("callId")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeProtocolTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeProtocolTest.java new file mode 100644 index 00000000..4ca25213 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeProtocolTest.java @@ -0,0 +1,72 @@ +package vip.mate.agent.runtime.dsh; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DshBridgeProtocolTest { + private final DshBridgeProtocol protocol = new DshBridgeProtocol(new ObjectMapper()); + + @Test + void requestRoundTripsAsJsonLine() { + DshBridgeMessage request = DshBridgeMessage.request( + "7", "session/open", Map.of("sessionId", "s-1")); + + DshBridgeMessage decoded = protocol.decode(protocol.encode(request)); + + assertEquals(request, decoded); + assertFalse(protocol.isNotification(decoded)); + } + + @Test + void notificationHasNoRequestId() { + DshBridgeMessage notification = DshBridgeMessage.notification( + "tool/cancel", Map.of("callId", "call-1")); + + assertTrue(protocol.isNotification(notification)); + assertEquals(notification, protocol.decode(protocol.encode(notification))); + } + + @Test + void tokenAuthenticatorAcceptsOnlyExactToken() { + DshBridgeAuthenticator authenticator = new DshBridgeAuthenticator("secret"); + + assertTrue(authenticator.accepts("secret")); + assertFalse(authenticator.accepts("Secret")); + assertFalse(authenticator.accepts(null)); + } + + @Test + void lineConnectionRequiresAuthentication() throws Exception { + DshBridgeMessage message = DshBridgeMessage.notification("ready", Map.of()); + ByteArrayInputStream input = new ByteArrayInputStream(protocol.encode(message) + .getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + DshBridgeConnection connection = new DshBridgeConnection( + input, output, protocol, new DshBridgeAuthenticator("secret")); + + assertFalse(connection.authenticate("wrong")); + assertTrue(connection.authenticate("secret")); + assertEquals(message, connection.receive()); + connection.send(message); + assertEquals(protocol.encode(message), output.toString(StandardCharsets.UTF_8)); + connection.close(); + } + + @Test + void malformedMessagesAndUnknownMethodsAreRejected() { + assertThrows(IllegalArgumentException.class, () -> protocol.decode("{}")); + assertThrows(IllegalArgumentException.class, () -> protocol.decode("not-json")); + assertFalse(DshBridgeMethods.isSupported("unknown/method")); + assertTrue(DshBridgeMethods.isSupported("session/prompt")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeRequestsTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeRequestsTest.java new file mode 100644 index 00000000..c9e43ce5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeRequestsTest.java @@ -0,0 +1,41 @@ +package vip.mate.agent.runtime.dsh; + +import org.junit.jupiter.api.Test; +import vip.mate.agent.runtime.contract.RuntimeSession; + +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DshBridgeRequestsTest { + @Test + void createsSessionOpenWithSessionContext() { + DshBridgeMessage message = DshBridgeRequests.sessionOpen(session(), Map.of("read", true)); + + assertEquals("session/open", message.method()); + assertEquals("session-1", message.params().get("sessionId")); + assertEquals("conversation-1", message.params().get("conversationId")); + assertEquals("read-only", message.params().get("permissionMode")); + } + + @Test + void createsPromptCancelPolicyAndUsageRequests() { + assertEquals("session/prompt", DshBridgeRequests.prompt("7", "hello").method()); + assertEquals("session/cancel", DshBridgeRequests.cancel("8", "session-1").method()); + assertEquals("policy/update", DshBridgeRequests.policyUpdate("9", Map.of("mode", "read-only")).method()); + assertEquals("context/usage", DshBridgeRequests.contextUsage("10", "session-1").method()); + } + + @Test + void rejectsBlankRequestIdentifiers() { + assertThrows(IllegalArgumentException.class, () -> DshBridgeRequests.prompt("", "hello")); + assertThrows(IllegalArgumentException.class, () -> DshBridgeRequests.cancel("1", "")); + } + + private static RuntimeSession session() { + return new RuntimeSession("session-1", "conversation-1", 1L, 2L, + "model", Path.of("/workspace"), Map.of("permissionMode", "read-only")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshProcessManagerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshProcessManagerTest.java new file mode 100644 index 00000000..9c9b0f20 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshProcessManagerTest.java @@ -0,0 +1,86 @@ +package vip.mate.agent.runtime.dsh; + +import org.junit.jupiter.api.Test; +import vip.mate.agent.runtime.contract.RuntimeSession; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DshProcessManagerTest { + + @Test + void missingBinaryPreventsProcessLaunch() { + AtomicBoolean launched = new AtomicBoolean(); + DshProcessManager manager = new DshProcessManager( + () -> Optional.empty(), + (binary, session, home, token) -> { + launched.set(true); + return new FakeProcess(); + }); + + assertThrows(IllegalStateException.class, () -> manager.start(session())); + assertFalse(launched.get()); + } + + @Test + void closeStopsProcessAndRemovesSessionHome() throws Exception { + Path binary = Files.createTempFile("dsh", "bin"); + assertTrue(binary.toFile().setExecutable(true)); + FakeProcess process = new FakeProcess(); + DshProcessManager manager = new DshProcessManager( + () -> Optional.of(binary), + (ignored, ignoredSession, home, ignoredToken) -> { + process.home = home; + return process; + }); + + DshManagedProcess managed = manager.start(session()); + Path home = managed.diagnostics().sessionHome(); + assertTrue(Files.exists(home)); + assertTrue(managed.diagnostics().bridgeTokenRedacted()); + assertTrue(manager.activeSessionIds().contains("session-1")); + assertTrue(manager.stop("session-1")); + assertFalse(manager.stop("session-1")); + + assertTrue(process.destroyed.get()); + assertFalse(Files.exists(home)); + assertFalse(manager.activeSessionIds().contains("session-1")); + } + + private static RuntimeSession session() { + return new RuntimeSession("session-1", "conversation-1", 1L, 2L, + "model", Path.of("/workspace"), Map.of()); + } + + private static final class FakeProcess implements DshProcessHandle { + private final AtomicBoolean destroyed = new AtomicBoolean(); + private Path home; + + @Override + public boolean isAlive() { + return !destroyed.get(); + } + + @Override + public void destroy() { + destroyed.set(true); + } + + @Override + public void destroyForcibly() { + destroyed.set(true); + } + + @Override + public boolean awaitExit(long millis) { + return destroyed.get(); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshRuntimeServiceTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshRuntimeServiceTest.java new file mode 100644 index 00000000..af78c4ea --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshRuntimeServiceTest.java @@ -0,0 +1,151 @@ +package vip.mate.agent.runtime.dsh; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import reactor.core.Disposable; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.runtime.contract.RuntimeEvent; +import vip.mate.agent.runtime.contract.RuntimeEventType; +import vip.mate.agent.runtime.contract.RuntimeSession; +import vip.mate.agent.runtime.dsh.management.DshRuntimeConfigService; +import vip.mate.agent.runtime.dsh.management.DshRuntimeConfiguration; +import vip.mate.llm.model.ModelProviderEntity; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.llm.service.ModelProviderService; + +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTimeout; + +class DshRuntimeServiceTest { + + @Test + void sessionWorkingDirectoryWinsOverGlobalRuntimeDirectory() { + RuntimeSession session = new RuntimeSession( + "session-1", "conversation-1", 1L, 2L, "model", + Path.of("/workspace/agent"), Map.of()); + DshRuntimeConfiguration configuration = new DshRuntimeConfiguration( + "/bin/dsh", "", "/workspace/global", "", "", ""); + + assertEquals(Path.of("/workspace/agent"), + DshRuntimeService.resolveWorkingDirectory(session, configuration)); + } + + @Test + void usageChunkBecomesContextUsageEvent() throws Exception { + DshRuntimeService service = service(); + RuntimeEvent event = service.mapEvent("session-1", 7, + new ObjectMapper().readTree(""" + { + "type":"assistant/chunk", + "data":{"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":45}}} + } + """)); + + assertEquals(RuntimeEventType.CONTEXT_USAGE, event.type()); + assertEquals(123L, ((Number) event.data().get("promptTokens")).longValue()); + assertEquals(45L, ((Number) event.data().get("completionTokens")).longValue()); + assertEquals(123L, ((Number) event.data().get("inputTokens")).longValue()); + assertEquals(45L, ((Number) event.data().get("outputTokens")).longValue()); + } + + @Test + void cancelProcessDestroysLiveProcess() { + Process process = Mockito.mock(Process.class); + Mockito.when(process.isAlive()).thenReturn(true); + + DshRuntimeService.cancelProcess(process); + + Mockito.verify(process).destroy(); + Mockito.verify(process).destroyForcibly(); + } + + @Test + void cancelProcessStopsChildHoldingParentPipes() throws Exception { + Process process = new ProcessBuilder("sh", "-c", "sleep 30").start(); + try { + DshRuntimeService.cancelProcess(process); + assertTrue(process.waitFor(2, TimeUnit.SECONDS), "parent process should stop promptly"); + assertTrue(process.exitValue() != 0 || !process.isAlive()); + } finally { + if (process.isAlive()) process.destroyForcibly(); + } + } + + @Test + void streamSubscriptionReturnsBeforeSynchronousDshReadLoopFinishes() { + DshRuntimeService service = service("/bin/sh -c \"sleep 5\""); + AgentEntity agent = new AgentEntity(); + agent.setId(1L); + agent.setWorkspaceId(2L); + agent.setModelName("model"); + + Disposable subscription = assertTimeout(Duration.ofSeconds(2), () -> + service.stream(agent, "hello", "conversation", "model").subscribe()); + assertFalse(subscription.isDisposed()); + subscription.dispose(); + } + + @Test + void commandLineKeepsQuotedExecutablePathTogether() { + assertEquals(List.of("/opt/Deep Seek/dsh-jsonrpc-agent", "--stdio"), + DshRuntimeService.commandLine("\"/opt/Deep Seek/dsh-jsonrpc-agent\" --stdio")); + } + + @Test + void childEnvironmentKeepsOnlyRuntimeVariablesAndExplicitCredentials() { + RuntimeSession session = session(Path.of("/workspace/project")); + DshRuntimeConfiguration configuration = new DshRuntimeConfiguration( + "/bin/dsh", "/opt/dsh/cordis.yml", "/workspace/global", + "https://configured.example/v1", "model", "configured-key"); + ModelProviderEntity provider = new ModelProviderEntity(); + provider.setApiKey("provider-key"); + provider.setBaseUrl("https://provider.example/v1"); + Map inherited = new HashMap<>(); + inherited.put("PATH", "/usr/bin"); + inherited.put("HOME", "/Users/mate"); + inherited.put("AWS_SECRET_ACCESS_KEY", "must-not-leak"); + inherited.put("DEEPSEEK_API_KEY", "inherited-key"); + inherited.put("DSH_CORDIS_CONFIG", "/stale/cordis.yml"); + + Map environment = DshRuntimeService.childEnvironment( + inherited, session, configuration, provider); + + assertEquals("/usr/bin", environment.get("PATH")); + assertEquals("/Users/mate", environment.get("HOME")); + assertEquals("/workspace/project", environment.get("DSH_CWD")); + assertEquals("/opt/dsh/cordis.yml", environment.get("DSH_CORDIS_CONFIG")); + assertEquals("configured-key", environment.get("DEEPSEEK_API_KEY")); + assertEquals("https://configured.example/v1", environment.get("DEEPSEEK_BASE_URL")); + assertFalse(environment.containsKey("AWS_SECRET_ACCESS_KEY")); + assertFalse(environment.containsValue("inherited-key")); + assertFalse(environment.containsValue("/stale/cordis.yml")); + } + + private static DshRuntimeService service() { + return service("/bin/dsh"); + } + + private static DshRuntimeService service(String executable) { + DshRuntimeConfigService config = Mockito.mock(DshRuntimeConfigService.class); + Mockito.when(config.resolve()).thenReturn(new DshRuntimeConfiguration( + executable, "", "/tmp", "", "model", "")); + return new DshRuntimeService(new ObjectMapper(), + Mockito.mock(ModelConfigService.class), + Mockito.mock(ModelProviderService.class), config); + } + + private static RuntimeSession session(Path workingDirectory) { + return new RuntimeSession("session-1", "conversation-1", 1L, 2L, + "model", workingDirectory, Map.of()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolCatalogTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolCatalogTest.java new file mode 100644 index 00000000..8d7c7013 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolCatalogTest.java @@ -0,0 +1,34 @@ +package vip.mate.agent.runtime.dsh; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DshToolCatalogTest { + @Test + void projectsToolDefinitionsAndDeduplicatesByRuntimeName() { + ToolCallback first = callback("read", "read file", "{\"type\":\"object\"}"); + ToolCallback duplicate = callback("read", "duplicate", "{}"); + ToolCallback second = callback("search", "search files", "{}"); + + List descriptors = DshToolCatalog.fromCallbacks(List.of(first, duplicate, second)); + + assertEquals(2, descriptors.size()); + assertEquals("read", descriptors.get(0).name()); + assertEquals("read file", descriptors.get(0).description()); + assertEquals("search", descriptors.get(1).name()); + } + + private static ToolCallback callback(String name, String description, String schema) { + ToolCallback callback = mock(ToolCallback.class); + when(callback.getToolDefinition()).thenReturn(ToolDefinition.builder() + .name(name).description(description).inputSchema(schema).build()); + return callback; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolDispatcherTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolDispatcherTest.java new file mode 100644 index 00000000..01d4699d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolDispatcherTest.java @@ -0,0 +1,59 @@ +package vip.mate.agent.runtime.dsh; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DshToolDispatcherTest { + @Test + void allowedCallRunsHostCallback() { + ToolCallback callback = callback("read"); + when(callback.call("{\"path\":\"a.txt\"}")).thenReturn("content"); + DshToolDispatcher dispatcher = dispatcher(callback, + new DshToolPolicy(Path.of("/workspace"), "read-only", SetOf.none(), + SetOf.of("read"), SetOf.none(), SetOf.none())); + + DshToolDispatchResult result = dispatcher.dispatch( + "read", "{\"path\":\"a.txt\"}", Path.of("/workspace/a.txt")); + + assertEquals(DshToolDecision.ALLOW, result.decision()); + assertEquals("content", result.output()); + } + + @Test + void deniedAndApprovalCallsDoNotRunCallback() { + ToolCallback callback = callback("edit"); + DshToolDispatcher dispatcher = dispatcher(callback, + new DshToolPolicy(Path.of("/workspace"), "read-only", SetOf.none(), + SetOf.none(), SetOf.of("edit"), SetOf.none())); + + assertEquals(DshToolDecision.DENY, + dispatcher.dispatch("edit", "{}", Path.of("/workspace/a.txt")).decision()); + assertEquals(DshToolDecision.DENY, + dispatcher.dispatch("edit", "{}", Path.of("/tmp/a.txt")).decision()); + } + + private static DshToolDispatcher dispatcher(ToolCallback callback, DshToolPolicy policy) { + return new DshToolDispatcher(List.of(callback), policy, new DshToolPolicyEvaluator()); + } + + private static ToolCallback callback(String name) { + ToolCallback callback = mock(ToolCallback.class); + when(callback.getToolDefinition()).thenReturn(ToolDefinition.builder() + .name(name).description(name).inputSchema("{}").build()); + return callback; + } + + private static final class SetOf { + static java.util.Set none() { return java.util.Set.of(); } + static java.util.Set of(String value) { return java.util.Set.of(value); } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolPolicyEvaluatorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolPolicyEvaluatorTest.java new file mode 100644 index 00000000..ad6ae382 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolPolicyEvaluatorTest.java @@ -0,0 +1,41 @@ +package vip.mate.agent.runtime.dsh; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DshToolPolicyEvaluatorTest { + private final DshToolPolicyEvaluator evaluator = new DshToolPolicyEvaluator(); + private final DshToolPolicy policy = new DshToolPolicy( + Path.of("/workspace"), "read-only", Set.of("disabled"), + Set.of("read"), Set.of("edit"), Set.of("safe")); + + @Test + void disabledToolIsDeniedBeforeOtherRules() { + assertEquals(DshToolDecision.DENY, evaluator.decide(policy, "disabled", null)); + } + + @Test + void pathOutsideWorkspaceIsDenied() { + assertEquals(DshToolDecision.DENY, + evaluator.decide(policy, "read", Path.of("/tmp/outside.txt"))); + } + + @Test + void readOnlyModeDeniesEditTools() { + assertEquals(DshToolDecision.DENY, + evaluator.decide(policy, "edit", Path.of("/workspace/a.txt"))); + } + + @Test + void explicitApprovalIsReturnedForAllowedEditInWriteMode() { + DshToolPolicy writePolicy = new DshToolPolicy( + Path.of("/workspace"), "workspace-write", Set.of(), + Set.of("read"), Set.of("edit"), Set.of()); + assertEquals(DshToolDecision.APPROVAL, + evaluator.decide(writePolicy, "edit", Path.of("/workspace/a.txt"))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshManagementStateTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshManagementStateTest.java new file mode 100644 index 00000000..64f38b01 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshManagementStateTest.java @@ -0,0 +1,24 @@ +package vip.mate.agent.runtime.dsh.management; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DshManagementStateTest { + + @Test + void runtimeCannotBeEnabledBeforeVerificationIsReady() { + assertFalse(DshManagementState.CONFIG_INVALID.canEnable()); + assertFalse(DshManagementState.CHECK_FAILED.canEnable()); + assertTrue(DshManagementState.READY.canEnable()); + } + + @Test + void installationAndVerificationStatesAreNotConfusedWithEnabled() { + assertFalse(DshManagementState.NOT_INSTALLED.isOperational()); + assertFalse(DshManagementState.INSTALLED_UNCONFIGURED.isOperational()); + assertFalse(DshManagementState.READY.isOperational()); + assertTrue(DshManagementState.ENABLED.isOperational()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigResolverTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigResolverTest.java new file mode 100644 index 00000000..45656a62 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/management/DshRuntimeConfigResolverTest.java @@ -0,0 +1,70 @@ +package vip.mate.agent.runtime.dsh.management; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; + +class DshRuntimeConfigResolverTest { + + @Test + void databaseValuesOverrideEnvironmentFallback() { + DshRuntimeConfiguration resolved = DshRuntimeConfigResolver.resolve( + Map.of( + "dsh.executable_path", "/managed/dsh-agent", + "dsh.cordis_config_path", "/managed/cordis.yml", + "dsh.working_directory", "/managed/workspace", + "dsh.base_url", "https://managed.example.com", + "dsh.model_name", "managed-model", + "dsh.api_key", "managed-secret"), + Map.of( + "mateclaw.agent.runtime.dsh.command", "/legacy/dsh-agent", + "mateclaw.agent.runtime.dsh.cordis-config", "/legacy/cordis.yml"), + Map.of( + "DSH_JSONRPC_AGENT", "/env/dsh-agent", + "DSH_CORDIS_CONFIG", "/env/cordis.yml", + "DSH_CWD", "/env/workspace")); + + assertEquals("/managed/dsh-agent", resolved.executablePath()); + assertEquals("/managed/cordis.yml", resolved.cordisConfigPath()); + assertEquals("/managed/workspace", resolved.workingDirectory()); + assertEquals("https://managed.example.com", resolved.baseUrl()); + assertEquals("managed-model", resolved.modelName()); + assertEquals("managed-secret", resolved.apiKey()); + } + + @Test + void blankDatabaseValuesFallBackToPropertiesThenEnvironment() { + DshRuntimeConfiguration resolved = DshRuntimeConfigResolver.resolve( + Map.of( + "dsh.executable_path", "", + "dsh.cordis_config_path", "", + "dsh.working_directory", ""), + Map.of( + "mateclaw.agent.runtime.dsh.command", "/properties/dsh-agent", + "mateclaw.agent.runtime.dsh.cordis-config", "/properties/cordis.yml"), + Map.of( + "DSH_JSONRPC_AGENT", "/env/dsh-agent", + "DSH_CORDIS_CONFIG", "/env/cordis.yml", + "DSH_CWD", "/env/workspace")); + + assertEquals("/properties/dsh-agent", resolved.executablePath()); + assertEquals("/properties/cordis.yml", resolved.cordisConfigPath()); + assertEquals("/env/workspace", resolved.workingDirectory()); + } + + @Test + void apiKeyIsExcludedFromPublicStatusProjection() { + DshRuntimeConfiguration resolved = DshRuntimeConfigResolver.resolve( + Map.of("dsh.api_key", "super-secret"), Map.of(), Map.of()); + + Map status = resolved.publicStatus(); + + assertEquals(true, status.get("apiKeyConfigured")); + assertFalse(status.containsKey("apiKey")); + assertNull(status.get("apiKey")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/AgentStreamAccumulatorReturnDirectTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/AgentStreamAccumulatorReturnDirectTest.java new file mode 100644 index 00000000..e7f613c6 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/AgentStreamAccumulatorReturnDirectTest.java @@ -0,0 +1,57 @@ +package vip.mate.channel.web; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.agent.AgentService.StreamDelta; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class AgentStreamAccumulatorReturnDirectTest { + + private static final String DIRECT_PLACEHOLDER = + "[Tool result returned directly to user. " + + "Content withheld from model context per tool policy.]"; + + private static final AgentStreamAccumulator.Sink NOOP_SINK = + new AgentStreamAccumulator.Sink() { + @Override + public void broadcast(String conversationId, String eventName, Object payload) { } + + @Override + public void updatePhase(String conversationId, String phase) { } + }; + + @Test + @DisplayName("returnDirect result terminates its tool card without persisting the direct payload there") + void directResultHasCompletedToolPair() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + AgentStreamAccumulator accumulator = new AgentStreamAccumulator(mapper, NOOP_SINK); + String conversationId = "conv-direct"; + + accumulator.accept(StreamDelta.event("tool_call_started", Map.of( + "toolCallId", "call-1", "toolName", "renderDocx", "arguments", "{}")), + conversationId); + accumulator.accept(StreamDelta.event("tool_direct_result", Map.of( + "toolCallId", "call-1", "toolName", "renderDocx", + "result", "SECRET-DIRECT-PAYLOAD")), conversationId); + accumulator.accept(StreamDelta.event("tool_call_completed", Map.of( + "toolCallId", "call-1", "toolName", "renderDocx", + "result", DIRECT_PLACEHOLDER, "success", true)), + conversationId); + + JsonNode metadata = mapper.readTree(accumulator.toMetadataJson()); + JsonNode call = metadata.path("toolCalls").get(0); + JsonNode segment = metadata.path("segments").get(0); + + assertEquals("completed", call.path("status").asText()); + assertEquals("completed", segment.path("status").asText()); + assertEquals(DIRECT_PLACEHOLDER, segment.path("toolResult").asText()); + assertFalse(metadata.toString().contains("SECRET-DIRECT-PAYLOAD")); + assertEquals("renderDocx", metadata.path("directToolNames").get(0).asText()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerDurableQueueTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerDurableQueueTest.java new file mode 100644 index 00000000..b30c43e5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerDurableQueueTest.java @@ -0,0 +1,63 @@ +package vip.mate.channel.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.Authentication; +import vip.mate.agent.AgentService; +import vip.mate.approval.ApprovalWorkflowService; +import vip.mate.channel.web.ConversationInputQueueStore.QueuedInput; +import vip.mate.memory.event.ConversationCompletionPublisher; +import vip.mate.memory.identity.MemoryOwnerResolver; +import vip.mate.tool.document.preview.OfficePreviewService; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.core.service.ChatUploadLocationResolver; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ChatControllerDurableQueueTest { + + @Test + void interruptPersistsInputBeforePublishingAcceptance() { + AgentService agents = mock(AgentService.class); + ConversationService conversations = mock(ConversationService.class); + ApprovalWorkflowService approvals = mock(ApprovalWorkflowService.class); + ChatStreamTracker streams = mock(ChatStreamTracker.class); + ConversationInputQueueStore queue = mock(ConversationInputQueueStore.class); + Authentication authentication = mock(Authentication.class); + when(authentication.getName()).thenReturn("alice"); + when(conversations.isConversationOwner("conv", "alice")).thenReturn(true); + when(streams.isRunning("conv")).thenReturn(true); + when(streams.notifyQueuedInput("conv")).thenReturn(true); + QueuedInput stored = new QueuedInput(91L, "conv", 2L, "alice", "follow-up", + List.of(), "queued", null, null, null, + LocalDateTime.now(), LocalDateTime.now()); + when(queue.enqueue(eq("conv"), eq(2L), eq("alice"), eq("follow-up"), + eq(List.of()), any())).thenReturn(stored); + + ChatController controller = new ChatController(agents, conversations, approvals, streams, + new ObjectMapper(), mock(ConversationCompletionPublisher.class), + mock(MemoryOwnerResolver.class), mock(ChatUploadLocationResolver.class), + mock(OfficePreviewService.class), queue); + ChatController.InterruptRequest request = new ChatController.InterruptRequest(); + request.setMessage("follow-up"); + request.setAgentId(2L); + request.setContentParts(List.of()); + + var response = controller.interruptStream("conv", request, authentication); + + assertThat(response.getData()).containsEntry("queued", true) + .containsEntry("queueItemId", "91"); + var order = inOrder(queue, streams); + order.verify(queue).enqueue(eq("conv"), eq(2L), eq("alice"), + eq("follow-up"), eq(List.of()), any()); + order.verify(streams).notifyQueuedInput("conv"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerPersistStatusTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerPersistStatusTest.java index 1249680f..fde828f1 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerPersistStatusTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerPersistStatusTest.java @@ -71,6 +71,15 @@ class ChatControllerPersistStatusTest { .isEqualTo("interrupted"); } + @Test + @DisplayName("queued input stays durable while the current turn awaits approval") + void awaitingApprovalDoesNotDrainQueuedInput() { + assertThat(ChatController.shouldDrainQueuedInput("awaiting_approval", true, true)).isFalse(); + assertThat(ChatController.shouldDrainQueuedInput("completed", true, true)).isFalse(); + assertThat(ChatController.shouldDrainQueuedInput("completed", true, false)).isTrue(); + assertThat(ChatController.shouldDrainQueuedInput("completed", false, false)).isFalse(); + } + @Test @DisplayName("empty completed turns persist an explicit placeholder") void emptyCompletedTurnUsesPlaceholder() { diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerPreviewRouteTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerPreviewRouteTest.java index 88db9f93..10a3d1bb 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerPreviewRouteTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerPreviewRouteTest.java @@ -62,7 +62,8 @@ class ChatControllerPreviewRouteTest { mock(vip.mate.memory.event.ConversationCompletionPublisher.class), mock(MemoryOwnerResolver.class), uploadLocationResolver, - officePreviewService); + officePreviewService, + mock(ConversationInputQueueStore.class)); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerWorkerReadOnlyTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerWorkerReadOnlyTest.java index 15d61aed..8b421a3b 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerWorkerReadOnlyTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerWorkerReadOnlyTest.java @@ -7,7 +7,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.core.Authentication; +import org.springframework.test.util.ReflectionTestUtils; import vip.mate.agent.AgentService; +import vip.mate.agent.runtime.ConversationTurnGate; import vip.mate.approval.ApprovalWorkflowService; import vip.mate.memory.identity.MemoryOwnerResolver; import vip.mate.memory.event.ConversationCompletionPublisher; @@ -19,6 +21,10 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Optional; @ExtendWith(MockitoExtension.class) class ChatControllerWorkerReadOnlyTest { @@ -32,15 +38,18 @@ class ChatControllerWorkerReadOnlyTest { @Mock private MemoryOwnerResolver memoryOwnerResolver; @Mock private ChatUploadLocationResolver uploadLocationResolver; @Mock private OfficePreviewService officePreviewService; + @Mock private ConversationInputQueueStore inputQueue; @Mock private Authentication authentication; private ChatController controller; + private final ConversationTurnGate gate = new ConversationTurnGate(); @BeforeEach void setUp() { controller = new ChatController(agentService, conversationService, approvalService, streamTracker, objectMapper, completionPublisher, memoryOwnerResolver, - uploadLocationResolver, officePreviewService); + uploadLocationResolver, officePreviewService, inputQueue); + ReflectionTestUtils.setField(controller, "turnGate", gate); } @Test @@ -72,4 +81,193 @@ class ChatControllerWorkerReadOnlyTest { verify(streamTracker, never()).register(any()); verify(agentService, never()).chatStructuredStream(any(), any(), any(), any(), any(), any()); } + + @Test + void rejectsApprovalWhileAnotherProducerOwnsTheStream() { + ChatController.ChatStreamRequest request = new ChatController.ChatStreamRequest(); + request.setConversationId("busy-conversation"); + request.setMessage("/approve"); + when(authentication.getName()).thenReturn("alice"); + when(conversationService.isUserMessageAllowed("busy-conversation")).thenReturn(true); + org.mockito.Mockito.lenient().when(streamTracker.isRunning("busy-conversation")).thenReturn(true); + + controller.chatStream(request, 1L, authentication); + + verify(approvalService, never()).findPendingByConversation(any()); + verify(approvalService, never()).resolveAndConsume(any(), any()); + verify(streamTracker, never()).register(any()); + verify(conversationService, never()).removeApprovalPlaceholders(any()); + } + + @Test + void rejectsForeignConversationBeforeInspectingApproval() { + ChatController.ChatStreamRequest request = new ChatController.ChatStreamRequest(); + request.setConversationId("foreign-conversation"); + request.setMessage("/approve"); + when(authentication.getName()).thenReturn("alice"); + when(conversationService.isUserMessageAllowed("foreign-conversation")).thenReturn(true); + org.mockito.Mockito.lenient().when(conversationService.conversationExists("foreign-conversation")).thenReturn(true); + + controller.chatStream(request, 1L, authentication); + + verify(approvalService, never()).findPendingByConversation(any()); + verify(streamTracker, never()).register(any()); + } + + @Test + void synchronousChatRejectsBusyStreamBeforePersistingInput() { + ChatController.ChatRequest request = new ChatController.ChatRequest(); + request.setConversationId("busy-conversation"); + request.setMessage("new request"); + when(authentication.getName()).thenReturn("alice"); + org.mockito.Mockito.lenient().when(streamTracker.isRunning("busy-conversation")).thenReturn(true); + org.mockito.Mockito.lenient().when(agentService.chatWithUsage(any(), any(), any(), any())) + .thenReturn(new AgentService.ChatResult("reply", 0, 0, "model", "provider")); + + controller.chat(1L, request, 1L, authentication); + + verify(conversationService, never()).getOrCreateConversation(any(), any(), any(), any()); + verify(conversationService, never()).saveMessage(any(), any(), any(), org.mockito.ArgumentMatchers.anyList()); + verify(agentService, never()).chatWithUsage(any(), any(), any(), any()); + } + + @Test + void autonomousReservationRejectsApprovalAndRegenerationBeforeMutation() { + when(authentication.getName()).thenReturn("alice"); + when(conversationService.isUserMessageAllowed("auto-conversation")).thenReturn(true); + try (var autonomous = gate.tryAcquire("auto-conversation")) { + assertNotNull(autonomous); + ChatController.ChatStreamRequest request = new ChatController.ChatStreamRequest(); + request.setConversationId("auto-conversation"); + request.setMessage("/approve"); + controller.chatStream(request, 1L, authentication); + request.setMessage("continue"); + request.setRegenerate(true); + controller.chatStream(request, 1L, authentication); + + assertNull(gate.tryAcquire("auto-conversation"), "rejected requests must not release another owner"); + } + + verify(approvalService, never()).findPendingByConversation(any()); + verify(approvalService, never()).resolveAndConsume(any(), any()); + verify(conversationService, never()).prepareRegenerate(any()); + verify(streamTracker, never()).register(any()); + verify(agentService, never()).chatStructuredStream(any(), any(), any(), any(), any(), any()); + } + + @Test + void missingApprovalReleasesSetupReservation() { + when(authentication.getName()).thenReturn("alice"); + when(conversationService.isUserMessageAllowed("idle-conversation")).thenReturn(true); + ChatController.ChatStreamRequest request = new ChatController.ChatStreamRequest(); + request.setConversationId("idle-conversation"); + request.setMessage("/approve"); + + controller.chatStream(request, 1L, authentication); + + verify(approvalService).findPendingByConversation("idle-conversation"); + try (var next = gate.tryAcquire("idle-conversation")) { + assertNotNull(next); + } + } + + @Test + void idleApprovalIsConsumedWhileHoldingSetupReservation() { + when(authentication.getName()).thenReturn("alice"); + when(conversationService.isUserMessageAllowed("idle-conversation")).thenReturn(true); + var pending = org.mockito.Mockito.mock(vip.mate.approval.PendingApproval.class); + when(pending.getPendingId()).thenReturn("pending-1"); + when(approvalService.findPendingByConversation("idle-conversation")).thenReturn(pending); + when(approvalService.resolveAndConsume("pending-1", "alice")).thenAnswer(invocation -> { + assertNull(gate.tryAcquire("idle-conversation"), "approval consumption must reserve ingress"); + return vip.mate.approval.ResolveOutcome.alreadyResolved("pending-1"); + }); + ChatController.ChatStreamRequest request = new ChatController.ChatStreamRequest(); + request.setConversationId("idle-conversation"); + request.setMessage("/approve"); + + controller.chatStream(request, 1L, authentication); + + verify(approvalService).resolveAndConsume("pending-1", "alice"); + try (var next = gate.tryAcquire("idle-conversation")) { + assertNotNull(next); + } + } + + @Test + void approvalCommandConsumesTheRequestedPendingInsteadOfTheOldest() { + when(authentication.getName()).thenReturn("alice"); + when(conversationService.isUserMessageAllowed("multi-approval")).thenReturn(true); + var requested = org.mockito.Mockito.mock(vip.mate.approval.PendingApproval.class); + when(requested.getPendingId()).thenReturn("pending-newest"); + when(requested.getConversationId()).thenReturn("multi-approval"); + when(requested.getStatus()).thenReturn("pending"); + when(approvalService.getPending("pending-newest")).thenReturn(Optional.of(requested)); + when(approvalService.resolveAndConsume("pending-newest", "alice")) + .thenReturn(vip.mate.approval.ResolveOutcome.alreadyResolved("pending-newest")); + + ChatController.ChatStreamRequest request = new ChatController.ChatStreamRequest(); + request.setConversationId("multi-approval"); + request.setMessage("/approve"); + request.setPendingApprovalId("pending-newest"); + + controller.chatStream(request, 1L, authentication); + + verify(approvalService).getPending("pending-newest"); + verify(approvalService).resolveAndConsume("pending-newest", "alice"); + verify(approvalService, never()).findPendingByConversation(any()); + } + + @Test + void approvalCommandRejectsPendingFromAnotherConversation() { + when(authentication.getName()).thenReturn("alice"); + when(conversationService.isUserMessageAllowed("owned-conversation")).thenReturn(true); + var foreign = org.mockito.Mockito.mock(vip.mate.approval.PendingApproval.class); + when(foreign.getConversationId()).thenReturn("foreign-conversation"); + when(approvalService.getPending("foreign-pending")).thenReturn(Optional.of(foreign)); + + ChatController.ChatStreamRequest request = new ChatController.ChatStreamRequest(); + request.setConversationId("owned-conversation"); + request.setMessage("/deny"); + request.setPendingApprovalId("foreign-pending"); + + controller.chatStream(request, 1L, authentication); + + verify(approvalService, never()).resolve(any(), any(), any()); + verify(approvalService, never()).findPendingByConversation(any()); + } + + @Test + void reconnectCanAttachWhileAutonomousTurnOwnsReservation() { + when(authentication.getName()).thenReturn("alice"); + when(conversationService.isConversationOwner("auto-conversation", "alice")).thenReturn(true); + ChatController.ChatStreamRequest request = new ChatController.ChatStreamRequest(); + request.setConversationId("auto-conversation"); + request.setReconnect(true); + try (var autonomous = gate.tryAcquire("auto-conversation")) { + assertNotNull(autonomous); + controller.chatStream(request, 1L, authentication); + assertNull(gate.tryAcquire("auto-conversation")); + } + + verify(streamTracker).attach(org.mockito.ArgumentMatchers.eq("auto-conversation"), any(), + org.mockito.ArgumentMatchers.eq(0L)); + verify(streamTracker, never()).register(any()); + } + + @Test + void synchronousChatRejectsAutonomousReservationBeforeMutation() { + when(authentication.getName()).thenReturn("alice"); + ChatController.ChatRequest request = new ChatController.ChatRequest(); + request.setConversationId("auto-conversation"); + request.setMessage("new request"); + try (var autonomous = gate.tryAcquire("auto-conversation")) { + assertNotNull(autonomous); + controller.chat(1L, request, 1L, authentication); + assertNull(gate.tryAcquire("auto-conversation")); + } + + verify(conversationService, never()).getOrCreateConversation(any(), any(), any(), any()); + verify(agentService, never()).chatWithUsage(any(), any(), any(), any()); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerDetachSemanticsTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerDetachSemanticsTest.java index 56a7e71c..50c77ece 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerDetachSemanticsTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerDetachSemanticsTest.java @@ -34,6 +34,15 @@ import static org.junit.jupiter.api.Assertions.assertTrue; */ class ChatStreamTrackerDetachSemanticsTest { + @Test + void stopPublishesDurableGoalControlEvenBetweenSegments() { + ChatStreamTracker tracker = newTracker(); + var context = org.mockito.Mockito.mock(org.springframework.context.ApplicationContext.class); + org.springframework.test.util.ReflectionTestUtils.setField(tracker,"applicationContext",context); + tracker.requestStop("idle-goal"); + org.mockito.Mockito.verify(context).publishEvent(new vip.mate.goal.service.GoalExecutionSignal.Stop("idle-goal")); + } + private ChatStreamTracker newTracker() { return new ChatStreamTracker(new ObjectMapper()); } diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerEventIdTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerEventIdTest.java index 548530df..3f161abc 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerEventIdTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerEventIdTest.java @@ -18,6 +18,19 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class ChatStreamTrackerEventIdTest { + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void continuationStateAfterSegmentCompletionIsAvailableOnReconnect(boolean fenced) { + ChatStreamTracker tracker = new ChatStreamTracker(new ObjectMapper()); + var handle = tracker.register("goal-channel"); + tracker.complete(handle); + if (fenced) tracker.broadcast(handle, "goal_continuation", "{\"state\":\"queued\"}"); + else tracker.broadcastObject("goal-channel", "goal_continuation", java.util.Map.of("state", "queued")); + CapturingEmitter reconnected = new CapturingEmitter(); + tracker.attach("goal-channel", reconnected); + assertTrue(reconnected.names.contains("goal_continuation")); + } + @Test void eventIdsIncreaseAcrossChannelsAndRecreatedRunState() { ChatStreamTracker tracker = new ChatStreamTracker(new ObjectMapper()); @@ -107,11 +120,16 @@ class ChatStreamTrackerEventIdTest { private static final class CapturingEmitter extends SseEmitter { private final List ids = new ArrayList<>(); + private final List names = new ArrayList<>(); @Override public void send(SseEventBuilder builder) throws IOException { Set entries = builder.build(); for (ResponseBodyEmitter.DataWithMediaType entry : entries) { + if (entry.getData() instanceof String text) { + text.lines().filter(line -> line.startsWith("event:")) + .forEach(line -> names.add(line.substring(6).trim())); + } if (entry.getData() instanceof String text && text.startsWith("id:")) { int end = text.indexOf('\n'); ids.add(Long.parseLong(text.substring(3, end).trim())); diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerOrphanPolicyTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerOrphanPolicyTest.java index 56d47f4e..d52bf1c6 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerOrphanPolicyTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerOrphanPolicyTest.java @@ -376,6 +376,22 @@ class ChatStreamTrackerOrphanPolicyTest { assertFalse(staleLateDisposable.isDisposed()); } + @Test + @DisplayName("A disposable registered after Stop is requested is cancelled immediately") + void lateDisposableIsCancelledWhenStopWonTheRace() { + ChatStreamTracker tracker = newTracker(); + String cid = "late-stop-disposable"; + ChatStreamTracker.RunHandle handle = tracker.register(cid); + tracker.incrementFlux(cid); + + assertTrue(tracker.requestStop(cid)); + + RecordingDisposable lateDisposable = new RecordingDisposable(); + tracker.setDisposable(handle, lateDisposable); + + assertTrue(lateDisposable.isDisposed()); + } + @Test @DisplayName("A throwing disposable cannot leave an evicting tombstone mapped") void throwingDisposableStillRemovesClaimedState() { diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerQueueDrainTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerQueueDrainTest.java index 16456c3a..86b3547e 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerQueueDrainTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerQueueDrainTest.java @@ -4,7 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class ChatStreamTrackerQueueDrainTest { @@ -13,40 +14,22 @@ class ChatStreamTrackerQueueDrainTest { } @Test - @DisplayName("Queued inputs survive RunState replacement and drain FIFO") - void queuedInputsSurviveRunStateReplacementAndDrainFifo() { + @DisplayName("Run state tracks only a durable queue wake signal") + void runStateTracksOnlyDurableQueueWakeSignal() { ChatStreamTracker tracker = newTracker(); String conversationId = "queue-drain"; tracker.register(conversationId); tracker.incrementFlux(conversationId); - assertTrue(tracker.enqueueMessage(conversationId, "q1", 101L, false)); - assertTrue(tracker.enqueueMessage(conversationId, "q2", 101L, false)); - assertTrue(tracker.enqueueMessage(conversationId, "q3", 101L, false)); + assertTrue(tracker.notifyQueuedInput(conversationId)); + assertTrue(tracker.hasQueuedInputNotification(conversationId)); - ChatStreamTracker.CompletionResult first = tracker.completeAndConsumeIfLast(conversationId); - assertTrue(first.allDone()); - assertNotNull(first.queuedInput()); - assertEquals("q1", first.queuedInput().message()); + ChatStreamTracker.CompletionResult completed = tracker.completeAndConsumeIfLast(conversationId); + assertTrue(completed.allDone()); + assertFalse(tracker.notifyQueuedInput(conversationId)); tracker.register(conversationId); tracker.incrementFlux(conversationId); - ChatStreamTracker.CompletionResult second = tracker.completeAndConsumeIfLast(conversationId); - assertTrue(second.allDone()); - assertNotNull(second.queuedInput()); - assertEquals("q2", second.queuedInput().message()); - - tracker.register(conversationId); - tracker.incrementFlux(conversationId); - ChatStreamTracker.CompletionResult third = tracker.completeAndConsumeIfLast(conversationId); - assertTrue(third.allDone()); - assertNotNull(third.queuedInput()); - assertEquals("q3", third.queuedInput().message()); - - tracker.register(conversationId); - tracker.incrementFlux(conversationId); - ChatStreamTracker.CompletionResult empty = tracker.completeAndConsumeIfLast(conversationId); - assertTrue(empty.allDone()); - assertNull(empty.queuedInput()); + assertFalse(tracker.hasQueuedInputNotification(conversationId)); } } diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ConversationInputQueueStoreTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ConversationInputQueueStoreTest.java new file mode 100644 index 00000000..1c49539f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ConversationInputQueueStoreTest.java @@ -0,0 +1,71 @@ +package vip.mate.channel.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; +import vip.mate.channel.web.ConversationInputQueueStore.QueuedInput; +import vip.mate.workspace.conversation.model.MessageContentPart; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConversationInputQueueStoreTest { + private JdbcTemplate jdbc; + private ObjectMapper mapper; + private ConversationInputQueueStore store; + private final LocalDateTime now = LocalDateTime.of(2026, 8, 27, 9, 0); + + @BeforeEach + void setUp() { + JdbcDataSource dataSource = new JdbcDataSource(); + dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID() + + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1"); + new ResourceDatabasePopulator( + new ClassPathResource("db/migration/h2/V120__agent_goal.sql"), + new ClassPathResource("db/migration/h2/V188__goal_continuation.sql"), + new ClassPathResource("db/migration/h2/V189__goal_attempt_and_input_queue.sql")) + .execute(dataSource); + jdbc = new JdbcTemplate(dataSource); + mapper = new ObjectMapper(); + store = new ConversationInputQueueStore(jdbc, mapper); + } + + @Test + void fifoClaimSurvivesStoreReconstructionAndPreservesAttachments() { + MessageContentPart attachment = MessageContentPart.file("media-1", "report.pdf", "application/pdf"); + QueuedInput first = store.enqueue("conv", 1L, "mate", "one", List.of(attachment), now); + QueuedInput second = store.enqueue("conv", 1L, "mate", "two", List.of(), now.plusNanos(1)); + + QueuedInput claimed = store.claimNext("conv", "attempt-a", now.plusSeconds(1)).orElseThrow(); + assertThat(claimed.id()).isEqualTo(first.id()); + assertThat(claimed.contentParts()).singleElement().extracting(MessageContentPart::getFileName) + .isEqualTo("report.pdf"); + assertThat(store.bindMessage(first.id(), "attempt-a", 101L, now.plusSeconds(2))).isTrue(); + assertThat(store.consume(first.id(), "attempt-a", now.plusSeconds(3))).isTrue(); + + ConversationInputQueueStore restarted = new ConversationInputQueueStore(jdbc, mapper); + assertThat(restarted.claimNext("conv", "attempt-b", now.plusSeconds(4))) + .get().extracting(QueuedInput::id).isEqualTo(second.id()); + assertThat(restarted.get(first.id()).persistedMessageId()).isEqualTo(101L); + assertThat(restarted.get(first.id()).state()).isEqualTo("consumed"); + } + + @Test + void claimReleaseAndCancellationAreFencedByAttempt() { + QueuedInput input = store.enqueue("conv", 1L, "mate", "queued", List.of(), now); + assertThat(store.claimNext("conv", "attempt-a", now.plusSeconds(1))).isPresent(); + + assertThat(store.release(input.id(), "attempt-b", now.plusSeconds(2))).isFalse(); + assertThat(store.release(input.id(), "attempt-a", now.plusSeconds(2))).isTrue(); + assertThat(store.countQueued("conv")).isEqualTo(1); + assertThat(store.cancel(input.id(), "stream_finished", now.plusSeconds(3))).isTrue(); + assertThat(store.claimNext("conv", "attempt-c", now.plusSeconds(4))).isEmpty(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/config/OpenApiLockedDownAccessTest.java b/mateclaw-server/src/test/java/vip/mate/config/OpenApiLockedDownAccessTest.java index 2ffce683..1691bc3d 100644 --- a/mateclaw-server/src/test/java/vip/mate/config/OpenApiLockedDownAccessTest.java +++ b/mateclaw-server/src/test/java/vip/mate/config/OpenApiLockedDownAccessTest.java @@ -48,6 +48,15 @@ class OpenApiLockedDownAccessTest { assertEquals(HttpStatus.UNAUTHORIZED, resp.getStatusCode()); } + @Test + @DisplayName("Anonymous generated-file download is blocked (401)") + void anonymousGeneratedFileDownloadBlocked() { + ResponseEntity resp = rest.getForEntity( + "/api/v1/files/generated/00000000-0000-0000-0000-000000000000", + String.class); + assertEquals(HttpStatus.UNAUTHORIZED, resp.getStatusCode()); + } + @Test @DisplayName("A genuinely public endpoint stays reachable when Swagger is locked") void publicEndpointStillReachable() { diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java index 21f96eb5..3d4e722c 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java @@ -4,15 +4,23 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DuplicateKeyException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; import vip.mate.MateClawApplication; +import vip.mate.approval.event.ApprovalResolutionEvent; import vip.mate.exception.MateClawException; import vip.mate.goal.model.GoalCreateRequest; import vip.mate.goal.model.GoalEntity; import vip.mate.goal.model.GoalStatus; import vip.mate.goal.service.GoalService; +import vip.mate.goal.service.GoalContinuationStore; import java.sql.Timestamp; import java.time.LocalDateTime; @@ -23,7 +31,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.fail; /** - * Integration test that pins two load-bearing DB invariants: + * Integration tests for goal persistence and transaction boundaries: * *
      *
    1. {@code GoalStatus} persists as lowercase strings ({@code "active"} @@ -33,6 +41,8 @@ import static org.junit.jupiter.api.Assertions.fail; *
    2. The {@code uk_agent_goal_active_conv} unique index rejects a * second active-row insert for the same conversation. Service-layer * pre-check is a UX nicety; this is the source of truth.
    3. + *
    4. Resume and approval decisions commit both goal and continuation state + * before independent transaction observers are allowed to continue.
    5. *
    * *

    Uses an in-memory H2 MySQL-compat database so Flyway runs V120 @@ -53,6 +63,9 @@ class GoalPersistenceIntegrationTest { @Autowired private GoalService goalService; @Autowired private JdbcTemplate jdbc; + @Autowired private GoalContinuationStore continuations; + @Autowired private PlatformTransactionManager transactionManager; + @Autowired private ApplicationEventPublisher events; private GoalCreateRequest req(String convId, String title) { GoalCreateRequest r = new GoalCreateRequest(); @@ -137,4 +150,75 @@ class GoalPersistenceIntegrationTest { assertNotNull(second); assertEquals(GoalStatus.ACTIVE, second.getStatus()); } + + @Test + void resumeCommitsGoalAndContinuationTogether() { + GoalEntity goal = persistentGoal("conv-resume-transaction", "paused"); + goalService.pause(goal.getId(), "alice"); + + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + goalService.resume(goal.getId(), "alice"); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override public void afterCommit() { + inIndependentTransaction(() -> assertEquals("queued", continuations.get(goal.getId()).state(), + "Resume must commit continuation state before after-commit consumers observe it")); + } + }); + }); + + assertEquals(GoalStatus.ACTIVE, goalService.getById(goal.getId()).getStatus()); + assertEquals("queued", continuations.get(goal.getId()).state(), + "The continuation update must commit with the proxied resume transaction"); + } + + @Test + void approvalDenialAfterCommitDurablyPausesGoalAndContinuation() { + assertAfterCommitApprovalPauses("conv-denial-transaction", "USER_MANUAL", "denied"); + } + + @Test + void approvalTimeoutAfterCommitDurablyPausesGoalAndContinuation() { + assertAfterCommitApprovalPauses("conv-timeout-transaction", "TIMEOUT", null); + } + + private void assertAfterCommitApprovalPauses(String conversationId, String decisionSource, String note) { + GoalEntity goal = persistentGoal(conversationId, "waiting_approval"); + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + // Bind the JDBC resource just as ApprovalWorkflowService does while resolving approval. + jdbc.queryForObject("SELECT status FROM mate_agent_goal WHERE id=?", String.class, goal.getId()); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override public void afterCommit() { + events.publishEvent(new ApprovalResolutionEvent("pending-" + goal.getId(), conversationId, + "1", "alice", "shell", "{}", null, null, decisionSource, note)); + // A different connection must see the pause before the event returns; + // do not rely on original-connection cleanup incidentally committing JDBC writes. + inIndependentTransaction(() -> { + assertEquals(GoalStatus.PAUSED, goalService.getById(goal.getId()).getStatus()); + assertEquals("paused", continuations.get(goal.getId()).state()); + }); + } + }); + }); + + assertEquals(GoalStatus.PAUSED, goalService.getById(goal.getId()).getStatus(), + "An approval callback must commit its own transaction after the approval transaction committed"); + assertEquals("paused", continuations.get(goal.getId()).state()); + } + + private void inIndependentTransaction(Runnable assertion) { + TransactionTemplate independent = new TransactionTemplate(transactionManager); + independent.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + independent.executeWithoutResult(status -> assertion.run()); + } + + private GoalEntity persistentGoal(String conversationId, String continuationState) { + GoalCreateRequest request = req(conversationId, "transaction boundary"); + request.setPersistentExecution(true); + request.setAutoFollowupEnabled(true); + GoalEntity goal = goalService.create(request, "alice"); + LocalDateTime now = LocalDateTime.now(); + jdbc.update("INSERT INTO mate_goal_continuation(goal_id,state,next_run_at,updated_at) VALUES(?,?,?,?)", + goal.getId(), continuationState, now, now); + return goal; + } } diff --git a/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalExecutionControllerTest.java b/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalExecutionControllerTest.java new file mode 100644 index 00000000..9ff0b341 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalExecutionControllerTest.java @@ -0,0 +1,31 @@ +package vip.mate.goal.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.service.GoalService; +import vip.mate.goal.service.GoalContinuationStore; +import vip.mate.goal.service.GoalAttemptStore; +import vip.mate.workspace.conversation.ConversationService; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class GoalExecutionControllerTest { + @Test void onlyConversationOwnerMayReadExecutionState() { + var goals=mock(GoalService.class); + var store=mock(GoalContinuationStore.class); + var conversations=mock(ConversationService.class); + var goal=new GoalEntity();goal.setConversationId("private"); + when(goals.getById(1L)).thenReturn(goal); + var attempts=mock(GoalAttemptStore.class); + var controller=new GoalExecutionController(goals,store,conversations,attempts); + var user=new UsernamePasswordAuthenticationToken("alice","ignored"); + assertThrows(vip.mate.exception.MateClawException.class,()->controller.execution(1L,user)); + verifyNoInteractions(store); + when(conversations.isConversationOwner("private","alice")).thenReturn(true); + controller.execution(1L,user); + verify(store).get(1L); + controller.attempts(1L,user); + verify(attempts).listRecent(1L,50); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalAttemptStoreTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalAttemptStoreTest.java new file mode 100644 index 00000000..853e6e0c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalAttemptStoreTest.java @@ -0,0 +1,67 @@ +package vip.mate.goal.service; + +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; +import vip.mate.goal.model.GoalAttempt; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class GoalAttemptStoreTest { + private GoalAttemptStore store; + private final LocalDateTime now = LocalDateTime.of(2026, 8, 27, 9, 0); + + @BeforeEach + void setUp() { + JdbcDataSource dataSource = new JdbcDataSource(); + dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID() + + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1"); + new ResourceDatabasePopulator( + new ClassPathResource("db/migration/h2/V120__agent_goal.sql"), + new ClassPathResource("db/migration/h2/V188__goal_continuation.sql"), + new ClassPathResource("db/migration/h2/V189__goal_attempt_and_input_queue.sql")) + .execute(dataSource); + store = new GoalAttemptStore(new JdbcTemplate(dataSource)); + } + + @Test + void lifecycleIsLeaseFencedAndTerminalRowsAreImmutable() { + GoalAttempt attempt = store.create(7L, "conv-7", null, "continuation", + "lease-a", now.plusMinutes(1), null, now); + + assertThat(store.markRunning(attempt.id(), "wrong", now.plusSeconds(1))).isFalse(); + assertThat(store.markRunning(attempt.id(), "lease-a", now.plusSeconds(1))).isTrue(); + assertThat(store.checkpoint(attempt.id(), "lease-a", "uncertain", "tool_started", null, + now.plusSeconds(2))).isTrue(); + assertThat(store.finish(attempt.id(), "wrong", "succeeded", "normal", null, + now.plusSeconds(3))).isFalse(); + assertThat(store.finish(attempt.id(), "lease-a", "succeeded", "normal", null, + now.plusSeconds(3))).isTrue(); + assertThat(store.markRunning(attempt.id(), "lease-a", now.plusSeconds(4))).isFalse(); + + GoalAttempt finished = store.get(attempt.id()); + assertThat(finished.state()).isEqualTo("succeeded"); + assertThat(finished.replaySafety()).isEqualTo("uncertain"); + assertThat(finished.checkpointType()).isEqualTo("tool_started"); + assertThat(finished.finishedAt()).isEqualTo(now.plusSeconds(3)); + } + + @Test + void historyPreservesRecoveryParentAndCreationOrder() { + GoalAttempt first = store.create(7L, "conv-7", null, "continuation", + "lease-a", now.plusMinutes(1), null, now); + GoalAttempt recovery = store.create(7L, "conv-7", first.id(), "recovery", + "lease-b", now.plusMinutes(2), 91L, now.plusSeconds(5)); + + assertThat(store.listRecent(7L, 10)).extracting(GoalAttempt::id) + .containsExactly(recovery.id(), first.id()); + assertThat(store.get(recovery.id()).parentAttemptId()).isEqualTo(first.id()); + assertThat(store.get(recovery.id()).inputItemId()).isEqualTo(91L); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalContinuationStoreTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalContinuationStoreTest.java new file mode 100644 index 00000000..75d92dca --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalContinuationStoreTest.java @@ -0,0 +1,136 @@ +package vip.mate.goal.service; + +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class GoalContinuationStoreTest { + JdbcTemplate jdbc; + GoalContinuationStore store; + LocalDateTime now = LocalDateTime.of(2026, 8, 26, 12, 0); + + @BeforeEach void setup() { + JdbcDataSource ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1"); + new ResourceDatabasePopulator( + new ClassPathResource("db/migration/h2/V120__agent_goal.sql"), + new ClassPathResource("db/migration/h2/V188__goal_continuation.sql"), + new ClassPathResource("db/migration/h2/V189__goal_attempt_and_input_queue.sql")).execute(ds); + jdbc = new JdbcTemplate(ds); + store = new GoalContinuationStore(jdbc); + } + + void goal(long id, boolean persistent, String status) { + jdbc.update(""" + INSERT INTO mate_agent_goal(id,conversation_id,agent_id,workspace_id,created_by,title, + description,status,persistent_execution,auto_followup_enabled,create_time,update_time) + VALUES(?, ?,1,1,'alice','goal','full objective',?,?,TRUE,?,?) + """, id, "conv-" + id, status, persistent, now, now); + } + + @Test void discoversOnlyEligibleGoalsAndSurvivesStoreRecreation() { + goal(1, true, "active"); goal(2, false, "active"); goal(3, true, "paused"); + store.discover(now); store.discover(now); + assertEquals(1, new GoalContinuationStore(jdbc).due(now, 10).size()); + assertEquals(1L, store.due(now, 10).getFirst().goalId()); + } + + @Test void claimIsExclusiveAndSettlementIsFenced() { + goal(1, true, "active"); store.discover(now); + assertTrue(store.claim(1L, "worker-a", now, now.plusSeconds(60))); + assertFalse(store.claim(1L, "worker-b", now, now.plusSeconds(60))); + assertFalse(store.settle(1L, "worker-b", "queued", now, 0, "wrong worker")); + assertTrue(store.settle(1L, "worker-a", "retry", now.plusSeconds(10), 1, "network")); + assertTrue(store.due(now, 10).isEmpty()); + assertEquals(1, store.due(now.plusSeconds(10), 10).size()); + } + + @Test void expiredLeaseCanBeRecoveredButOldWorkerCannotSettle() { + goal(1, true, "active"); store.discover(now); + assertTrue(store.claim(1L, "old", now, now.plusSeconds(60))); + assertTrue(store.due(now.plusSeconds(59), 10).isEmpty()); + assertEquals("running", store.due(now.plusSeconds(61), 10).getFirst().state()); + assertTrue(store.claim(1L, "new", now.plusSeconds(61), now.plusSeconds(120))); + assertFalse(store.renew(1L, "old", now.plusSeconds(180))); + assertFalse(store.settle(1L, "old", "queued", now, 0, "stale")); + } + + @Test void pauseOrCompletionBetweenDiscoveryAndClaimPreventsExecution() { + goal(1, true, "active"); store.discover(now); + jdbc.update("UPDATE mate_agent_goal SET status='completed' WHERE id=1"); + assertFalse(store.claim(1L, "worker", now, now.plusSeconds(60))); + assertTrue(store.due(now, 10).isEmpty()); + } + + @Test void stopPersistsAndExplicitResumeRequeues() { + goal(1, true, "active"); store.discover(now); + store.suspendConversation("conv-1", "user_stopped"); + store.discover(now.plusDays(1)); + assertTrue(store.due(now.plusDays(1), 10).isEmpty()); + assertEquals("paused", store.get(1L).state()); + store.resume(1L, now.plusDays(1)); + assertEquals(1, store.due(now.plusDays(1), 10).size()); + } + + @Test void approvalWaitRequiresInteractiveReplayRatherThanTimerExpiry() { + goal(1,true,"active");store.discover(now); + store.claim(1L,"worker",now,now.plusSeconds(60)); + store.settle(1L,"worker","waiting_approval",now,0,"approval_required"); + assertTrue(store.due(now.plusDays(1),10).isEmpty()); + store.turnFinished("conv-1",now.plusDays(1)); + assertEquals(1,store.due(now.plusDays(1),10).size()); + } + + @Test void fastApprovalReplayCannotLoseWakeupBeforeWaitingStateIsWritten() { + goal(1,true,"active");store.discover(now); + store.claim(1L,"worker",now,now.plusSeconds(60)); + store.turnFinished("conv-1",now); + store.settle(1L,"worker","waiting_approval",now,0,"approval_required"); + assertEquals("queued",store.get(1L).state()); + assertEquals(1,store.due(now,10).size()); + } + + @Test void freshSupervisorsContinueBeyondOldFollowupCapAndStopOnCompletion() { + goal(1,true,"active"); + var goals=org.mockito.Mockito.mock(GoalService.class); + var runner=org.mockito.Mockito.mock(GoalSegmentRunner.class); + var coordinator=new GoalRunCoordinator(new GoalContinuationStore(jdbc),new GoalAttemptStore(jdbc),goals, + new vip.mate.goal.config.GoalProperties()); + var recovery=org.mockito.Mockito.mock(GoalRecoveryService.class); + var running=new vip.mate.agent.runtime.RunningConversationRegistry(); + var streams=new vip.mate.channel.web.ChatStreamTracker(new com.fasterxml.jackson.databind.ObjectMapper()); + var properties=new vip.mate.goal.config.GoalProperties(); + var entity=new vip.mate.goal.model.GoalEntity(); + entity.setId(1L);entity.setConversationId("conv-1");entity.setStatus(vip.mate.goal.model.GoalStatus.ACTIVE); + entity.setPersistentExecution(true);entity.setAutoFollowupEnabled(true);entity.setTitle("twelve required steps"); + entity.setTurnBudget(0);entity.setLlmCallBudget(0); + org.mockito.Mockito.when(goals.getById(1L)).thenReturn(entity); + var count=new java.util.concurrent.atomic.AtomicInteger(); + org.mockito.Mockito.when(runner.run(org.mockito.ArgumentMatchers.any(GoalRunCoordinator.ClaimedRun.class),org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.anyBoolean())) + .thenAnswer(inv -> { + if(count.incrementAndGet()==12) { + entity.setStatus(vip.mate.goal.model.GoalStatus.COMPLETED); + jdbc.update("UPDATE mate_agent_goal SET status='completed' WHERE id=1"); + } + return new vip.mate.goal.model.SegmentOutcome.Continue("normal"); + }); + for(int i=0;i<15;i++) { + // Recreate all scheduler state between segments, as after a server restart. + var scheduler=new GoalContinuationSupervisor(new GoalContinuationStore(jdbc),goals,properties, + new GoalFollowupService(properties,new com.fasterxml.jackson.databind.ObjectMapper()),runner,running,streams, + coordinator,recovery, + java.time.Clock.fixed(now.plusSeconds(i*5L).toInstant(java.time.ZoneOffset.UTC),java.time.ZoneOffset.UTC),Runnable::run); + scheduler.tick(); + } + assertEquals(12,count.get()); + assertEquals("completed",store.get(1L).state()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalContinuationSupervisorTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalContinuationSupervisorTest.java new file mode 100644 index 00000000..2b56121f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalContinuationSupervisorTest.java @@ -0,0 +1,203 @@ +package vip.mate.goal.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import vip.mate.agent.runtime.RunningConversationRegistry; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.goal.config.GoalProperties; +import vip.mate.goal.model.GoalAttempt; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalStatus; +import vip.mate.goal.model.SegmentOutcome; + +import java.time.*; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class GoalContinuationSupervisorTest { + GoalContinuationStore store = mock(GoalContinuationStore.class); + GoalService goals = mock(GoalService.class); + GoalSegmentRunner runner = mock(GoalSegmentRunner.class); + GoalRunCoordinator coordinator = mock(GoalRunCoordinator.class); + GoalRecoveryService recovery = mock(GoalRecoveryService.class); + RunningConversationRegistry running = mock(RunningConversationRegistry.class); + ChatStreamTracker streams = mock(ChatStreamTracker.class); + GoalProperties properties = new GoalProperties(); + LocalDateTime now = LocalDateTime.of(2026,8,26,12,0); + GoalEntity goal = new GoalEntity(); + GoalContinuationStore.Continuation candidate; + GoalRunCoordinator.ClaimedRun claimed; + GoalContinuationSupervisor supervisor; + + @BeforeEach void setup() { + goal.setId(1L); goal.setConversationId("conv"); goal.setStatus(GoalStatus.ACTIVE); + goal.setPersistentExecution(true); goal.setAutoFollowupEnabled(true); + goal.setTitle("full goal"); goal.setTurnBudget(0); goal.setLlmCallBudget(0); + candidate=new GoalContinuationStore.Continuation(1L,"conv","queued",now,null,null,0,"",null,0); + GoalAttempt attempt=new GoalAttempt("attempt",1L,"conv",null,"continuation","claimed","lease", + now.plusSeconds(60),null,null,"safe","claimed",null,null,null,null,now,now); + claimed=new GoalRunCoordinator.ClaimedRun(candidate,goal,attempt,2); + when(goals.getById(1L)).thenReturn(goal); + when(store.due(any(), anyInt())).thenReturn(List.of(candidate)); + when(coordinator.claim(candidate,goal,now)).thenReturn(claimed); + when(coordinator.markRunning(claimed,now)).thenReturn(true); + when(coordinator.settle(eq(claimed),any(),any())).thenReturn(true); + when(runner.run(eq(claimed),anyString(),anyBoolean())).thenReturn(new SegmentOutcome.Continue("normal")); + supervisor = new GoalContinuationSupervisor(store, goals, properties, + new GoalFollowupService(properties,new ObjectMapper()), runner, running, streams,coordinator,recovery, + Clock.fixed(now.toInstant(ZoneOffset.UTC),ZoneOffset.UTC), Runnable::run); + } + + @Test void incompleteGoalIsRescheduledAcrossMultipleSegments() { + supervisor.tick(); supervisor.tick(); supervisor.tick(); + verify(runner,times(3)).run(eq(claimed),contains("full goal"),eq(false)); + verify(coordinator,times(3)).settle(eq(claimed),isA(SegmentOutcome.Continue.class),eq(now)); + } + + @Test void configuredConcurrencyLimitsSubmittedSegments() { + properties.setMaxConcurrentSegments(1); + GoalEntity second = new GoalEntity(); + second.setId(2L); second.setConversationId("conv-2"); second.setStatus(GoalStatus.ACTIVE); + second.setPersistentExecution(true); second.setAutoFollowupEnabled(true); + second.setTitle("second goal"); second.setTurnBudget(0); second.setLlmCallBudget(0); + var secondCandidate = new GoalContinuationStore.Continuation( + 2L,"conv-2","queued",now,null,null,0,"",null,0); + GoalAttempt secondAttempt = new GoalAttempt("attempt-2",2L,"conv-2",null,"continuation", + "claimed","lease-2",now.plusSeconds(60),null,null,"safe","claimed", + null,null,null,null,now,now); + var secondClaimed = new GoalRunCoordinator.ClaimedRun(secondCandidate,second,secondAttempt,2); + when(goals.getById(2L)).thenReturn(second); + when(store.due(any(), anyInt())).thenReturn(List.of(candidate,secondCandidate)); + when(coordinator.claim(secondCandidate,second,now)).thenReturn(secondClaimed); + List submitted = new ArrayList<>(); + supervisor = new GoalContinuationSupervisor(store, goals, properties, + new GoalFollowupService(properties,new ObjectMapper()), runner, running, streams, + coordinator,recovery,Clock.fixed(now.toInstant(ZoneOffset.UTC),ZoneOffset.UTC),submitted::add); + + supervisor.tick(); + + assertEquals(1,submitted.size()); + verify(coordinator).claim(candidate,goal,now); + verify(coordinator,never()).claim(secondCandidate,second,now); + } + + @Test void busyFirstCandidateDoesNotStarveAnotherDueGoalAtConcurrencyOne() { + properties.setMaxConcurrentSegments(1); + GoalEntity second = new GoalEntity(); + second.setId(2L); second.setConversationId("conv-2"); second.setStatus(GoalStatus.ACTIVE); + second.setPersistentExecution(true); second.setAutoFollowupEnabled(true); + second.setTitle("second goal"); second.setTurnBudget(0); second.setLlmCallBudget(0); + var secondCandidate = new GoalContinuationStore.Continuation( + 2L,"conv-2","queued",now,null,null,0,"",null,0); + GoalAttempt secondAttempt = new GoalAttempt("attempt-2",2L,"conv-2",null,"continuation", + "claimed","lease-2",now.plusSeconds(60),null,null,"safe","claimed", + null,null,null,null,now,now); + var secondClaimed = new GoalRunCoordinator.ClaimedRun(secondCandidate,second,secondAttempt,2); + when(goals.getById(2L)).thenReturn(second); + when(store.due(any(), anyInt())).thenAnswer(invocation -> { + int limit=invocation.getArgument(1); + return List.of(candidate,secondCandidate).subList(0,Math.min(limit,2)); + }); + when(running.isActive("conv")).thenReturn(true); + when(coordinator.claim(secondCandidate,second,now)).thenReturn(secondClaimed); + when(coordinator.markRunning(secondClaimed,now)).thenReturn(true); + when(coordinator.settle(eq(secondClaimed),any(),any())).thenReturn(true); + when(runner.run(eq(secondClaimed),anyString(),anyBoolean())) + .thenReturn(new SegmentOutcome.Continue("normal")); + + supervisor.tick(); + + verify(coordinator).claim(secondCandidate,second,now); + } + + @Test void cooldownIsDurablyDeferredWithoutCallingModel() { + goal.setFollowupCooldownSeconds(60); goal.setLastFollowupAt(now.minusSeconds(10)); + supervisor.tick(); + verifyNoInteractions(runner); + verify(coordinator).settle(eq(claimed),argThat(outcome -> outcome instanceof SegmentOutcome.Defer defer + && now.plusSeconds(50).equals(defer.nextRunAt())),eq(now)); + } + + @Test void neverStartsAlongsideUserTurnOrQueuedInput() { + when(running.isActive("conv")).thenReturn(true); + supervisor.tick(); + verify(coordinator,never()).claim(any(),any(),any()); + verifyNoInteractions(runner); + } + + @Test void completionIsSettledThroughCoordinator() { + when(runner.run(eq(claimed),anyString(),anyBoolean())).thenAnswer(inv -> { + goal.setStatus(GoalStatus.COMPLETED); return new SegmentOutcome.Continue("normal"); + }); + supervisor.tick(); supervisor.tick(); + verify(runner).run(eq(claimed),anyString(),anyBoolean()); + verify(coordinator).settle(eq(claimed),isA(SegmentOutcome.Continue.class),eq(now)); + } + + @Test void shutdownCancellationLeavesLeaseForRestartRecovery() { + when(runner.run(eq(claimed),anyString(),anyBoolean())).thenAnswer(inv -> { + supervisor.close(); return new SegmentOutcome.Cancelled("stopped"); + }); + supervisor.tick(); + verify(runner).cancelAll(); + verify(coordinator,never()).settle(any(),any(),any()); + } + + @Test void transientFailureGetsRetryAndPermanentErrorBlocks() { + properties.setProviderFailureGlobalBackoffSeconds(0); + when(runner.run(eq(claimed),anyString(),anyBoolean())) + .thenThrow(new java.io.UncheckedIOException(new java.io.IOException("connection reset"))); + supervisor.tick(); + verify(coordinator).settle(eq(claimed),isA(SegmentOutcome.Retry.class),eq(now)); + reset(runner,coordinator); + when(coordinator.claim(candidate,goal,now)).thenReturn(claimed); + when(coordinator.markRunning(claimed,now)).thenReturn(true); + when(coordinator.settle(eq(claimed),any(),any())).thenReturn(true); + doThrow(new IllegalArgumentException("invalid configuration")).when(runner) + .run(eq(claimed),anyString(),anyBoolean()); + supervisor.tick(); + verify(coordinator).settle(eq(claimed),isA(SegmentOutcome.Blocked.class),eq(now)); + } + + @Test void retryableProviderFailureStopsNewClaimsDuringGlobalBackoff() { + properties.setProviderFailureGlobalBackoffSeconds(300); + when(runner.run(eq(claimed),anyString(),anyBoolean())) + .thenThrow(new java.io.UncheckedIOException(new java.io.IOException("provider unavailable"))); + + supervisor.tick(); + supervisor.tick(); + + verify(coordinator,times(1)).claim(candidate,goal,now); + verify(coordinator,times(1)).settle(eq(claimed),isA(SegmentOutcome.Retry.class),eq(now)); + } + + @Test void retryOutcomeFromUnavailableEvaluationAlsoStartsGlobalBackoff() { + properties.setProviderFailureGlobalBackoffSeconds(300); + when(runner.run(eq(claimed),anyString(),anyBoolean())) + .thenReturn(new SegmentOutcome.Retry("evaluation","evaluation_unavailable")); + + supervisor.tick(); + supervisor.tick(); + + verify(coordinator,times(1)).claim(candidate,goal,now); + } + + @Test void approvalAndStopNeverBecomeAutomaticRetry() { + when(runner.run(eq(claimed),anyString(),anyBoolean())) + .thenReturn(new SegmentOutcome.AwaitApproval("approval_required")); + supervisor.tick(); + verify(coordinator).settle(eq(claimed),isA(SegmentOutcome.AwaitApproval.class),eq(now)); + reset(runner,coordinator); + when(coordinator.claim(candidate,goal,now)).thenReturn(claimed); + when(coordinator.markRunning(claimed,now)).thenReturn(true); + when(coordinator.settle(eq(claimed),any(),any())).thenReturn(true); + when(runner.run(eq(claimed),anyString(),anyBoolean())).thenReturn(new SegmentOutcome.Cancelled("stopped")); + supervisor.tick(); + verify(coordinator).settle(eq(claimed),isA(SegmentOutcome.Cancelled.class),eq(now)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java index 0a4cd348..e254b499 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalEvaluationServiceTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.mockito.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.UserMessage; @@ -95,6 +96,25 @@ class GoalEvaluationServiceTest { // ==================== Pre-flight guards ==================== + @Test + void persistentVerdictReceivesPriorVerifiedEvidenceOutsideConversationWindow() { + GoalEntity g = goalWithCriteria(); + g.setPersistentExecution(true); + g.setCriteria("[{\"id\":\"C1\",\"text\":\"DNS configured\",\"passed\":true,\"evidence\":\"verified DNS checkpoint\"}," + + "{\"id\":\"C2\",\"text\":\"TLS enabled\",\"passed\":false,\"evidence\":\"\"}]"); + stubChatResponse("{\"criterionVerdicts\":[{\"id\":\"C2\",\"passed\":true,\"evidence\":\"TLS handshake verified\"}],\"summary\":\"done\"}"); + + GoalEvaluationResult result = svc.evaluate(g,List.of(),"TLS handshake verified"); + + ArgumentCaptor request = ArgumentCaptor.forClass(Prompt.class); + verify(chatModel).call(request.capture()); + String prompt = request.getValue().getContents(); + assertTrue(prompt.contains("verified DNS checkpoint"), "persistent evaluation must retain prior evidence after history truncation"); + assertTrue(prompt.contains("passed=true")); + assertTrue(prompt.contains("contradicts")); + assertTrue(result.completed(), "a new verified step can complete previously verified work without repeating it"); + } + @Test void nullGoal_returnsFallback_withoutTouchingProviders() { GoalEvaluationResult r = svc.evaluate(null, List.of(), "anything"); diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java index a9fc0f91..44a5c396 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalFollowupServiceTest.java @@ -10,7 +10,8 @@ import vip.mate.goal.model.GoalStatus; import java.time.LocalDateTime; import java.util.Optional; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; +import vip.mate.goal.model.GoalContinuationDecision.Action; /** * Covers the follow-up gating conditions. Every negative case must @@ -135,4 +136,148 @@ class GoalFollowupServiceTest { assertTrue(out.get().contains("missing X")); assertTrue(out.get().toLowerCase().contains("next concrete step")); } + private GoalEntity persistentGoal() { + GoalEntity goal = goal(true); + goal.setPersistentExecution(true); + goal.setTurnBudget(0); + goal.setLlmCallBudget(0); + return goal; + } + + @Test + void persistentUnlimitedBudgetsContinue() { + assertEquals(Action.CONTINUE, svc.decide(persistentGoal(), + res(0.6, GoalEvaluationResult.DECISION_CONTINUE), LocalDateTime.now()).action()); + } + + @Test + void persistentMayUseLastTurn_andEntireCallBudget() { + GoalEntity goal = persistentGoal(); + goal.setTurnBudget(6); + goal.setLlmCallBudget(35); + var result = res(0.6, GoalEvaluationResult.DECISION_CONTINUE); + assertEquals(Action.CONTINUE, svc.decide(goal, result, LocalDateTime.now()).action()); + goal.setTurnsUsed(6); + assertEquals(Action.BUDGET_LIMITED, svc.decide(goal, result, LocalDateTime.now()).action()); + goal.setTurnsUsed(5); + goal.setEvalLlmCallsUsed(5); + assertEquals(Action.BUDGET_LIMITED, svc.decide(goal, result, LocalDateTime.now()).action()); + } + + @Test + void cooldownReturnsExactDeadline_andContinuesAtDeadline() { + GoalEntity goal = persistentGoal(); + LocalDateTime now = LocalDateTime.of(2026, 8, 26, 12, 0); + goal.setFollowupCooldownSeconds(60); + goal.setLastFollowupAt(now.minusSeconds(10)); + var result = res(0.6, GoalEvaluationResult.DECISION_CONTINUE); + var deferred = svc.decide(goal, result, now); + assertEquals(Action.DEFER, deferred.action()); + assertEquals(now.plusSeconds(50), deferred.nextRunAt()); + assertNotNull(deferred.prompt()); + assertEquals(Action.CONTINUE, svc.decide(goal, result, now.plusSeconds(50)).action()); + } + + @Test + void fallbackIsRetry_andNeverSuccessfulCompletion() { + LocalDateTime now = LocalDateTime.of(2026, 8, 26, 12, 0); + var decision = svc.decide(persistentGoal(), GoalEvaluationResult.fallback("network"), now); + assertEquals(Action.RETRY, decision.action()); + assertNotNull(decision.nextRunAt()); + assertTrue(decision.nextRunAt().isAfter(now)); + assertTrue(decision.reason().contains("network")); + assertTrue(svc.maybeBuildFollowup(persistentGoal(), GoalEvaluationResult.fallback("network")).isEmpty()); + } + + @Test + void inactiveAndDisabledGoalsNeverContinue() { + GoalEntity goal = persistentGoal(); + var result = res(0.6, GoalEvaluationResult.DECISION_CONTINUE); + LocalDateTime now = LocalDateTime.now(); + for (GoalStatus status : new GoalStatus[]{GoalStatus.PAUSED, GoalStatus.ABANDONED, GoalStatus.EXHAUSTED}) { + goal.setStatus(status); + assertEquals(Action.DISABLED, svc.decide(goal, result, now).action()); + } + goal.setStatus(GoalStatus.COMPLETED); + assertEquals(Action.COMPLETE, svc.decide(goal, result, now).action()); + goal.setStatus(GoalStatus.ACTIVE); + goal.setAutoFollowupEnabled(false); + assertEquals(Action.DISABLED, svc.decide(goal, result, now).action()); + goal.setAutoFollowupEnabled(true); + properties.setEnabled(false); + assertEquals(Action.DISABLED, svc.decide(goal, result, now).action()); + } + + @Test + void authoritativeCompletionWinsAtBudgetLimit() { + GoalEntity goal = persistentGoal(); + goal.setTurnBudget(5); + goal.setCriteria("[{\"id\":\"C1\",\"text\":\"deployed\",\"passed\":true,\"evidence\":\"verified\"}]"); + var result = new GoalEvaluationResult(1, "verified", GoalEvaluationResult.DECISION_COMPLETED, + true, "stub", 1, 0, java.util.List.of(), null); + assertEquals(Action.COMPLETE, svc.decide(goal, result, LocalDateTime.now()).action()); + } + + @Test + void promptPreservesObjectiveAndRemainingChecklist_andGuardsSideEffects() { + GoalEntity goal = persistentGoal(); + goal.setDescription("Deploy the original blog"); + goal.setExitCriteria("Public URL responds"); + goal.setCriteria("[{\"id\":\"C1\",\"text\":\"already satisfied\",\"passed\":true,\"evidence\":\"verified\"}," + + "{\"id\":\"C2\",\"text\":\"verify production\",\"passed\":false}]"); + String prompt = svc.decide(goal, res(0.6, GoalEvaluationResult.DECISION_CONTINUE), + LocalDateTime.now()).prompt(); + assertTrue(prompt.contains("ship")); + assertTrue(prompt.contains("Deploy the original blog")); + assertTrue(prompt.contains("Public URL responds")); + assertTrue(prompt.contains("verify production")); + assertFalse(prompt.contains("already satisfied")); + assertTrue(prompt.contains("original objective")); + assertTrue(prompt.contains("authoritative state")); + assertTrue(prompt.contains("async handles")); + assertTrue(prompt.contains("side effects")); + } + + @Test + void promptHasBoundedSize_evenForLargeGoalAndChecklist() { + GoalEntity goal = persistentGoal(); + goal.setTitle("T".repeat(10000)); + goal.setDescription("D".repeat(10000)); + goal.setExitCriteria("E".repeat(10000)); + goal.setCriteria("[{\"id\":\"C1\",\"text\":\"" + "C".repeat(20000) + "\",\"passed\":false}]"); + String prompt = svc.decide(goal, res(0.6, GoalEvaluationResult.DECISION_CONTINUE), + LocalDateTime.now()).prompt(); + assertTrue(prompt.length() <= 12000); + assertTrue(prompt.contains("authoritative state")); + assertTrue(prompt.contains("next concrete step")); + } + @Test + void passedCriterionWithoutEvidenceRemainsInPersistentPrompt() { + GoalEntity goal = persistentGoal(); + goal.setCriteria("[{\"id\":\"C1\",\"text\":\"verify deployed endpoint\",\"passed\":true,\"evidence\":\" \"}]"); + var decision = svc.decide(goal, res(1, GoalEvaluationResult.DECISION_CONTINUE), LocalDateTime.now()); + assertEquals(Action.CONTINUE, decision.action()); + assertTrue(decision.prompt().contains("verify deployed endpoint")); + } + @Test + void persistentCompletionDecisionRetriesWithoutAuthoritativeEvidence() { + GoalEntity goal = persistentGoal(); + goal.setCriteria("[{\"id\":\"C1\",\"text\":\"deployed\",\"passed\":true,\"evidence\":\"\"}]"); + var result = new GoalEvaluationResult(1, "claimed complete", GoalEvaluationResult.DECISION_COMPLETED, + true, "stub", 1, 0, java.util.List.of(), null); + var decision = svc.decide(goal, result, LocalDateTime.now()); + assertEquals(Action.RETRY, decision.action()); + assertEquals("completion_not_verified", decision.reason()); + assertTrue(decision.prompt().contains("deployed")); + } + @Test + void persistentPromptExplainsEssentialInputBoundary() { + String prompt = svc.decide(persistentGoal(), res(0.5, GoalEvaluationResult.DECISION_CONTINUE), + LocalDateTime.now()).prompt(); + assertTrue(prompt.contains("waitForGoalInput")); + assertTrue(prompt.contains("essential input")); + assertTrue(prompt.contains("permission")); + assertTrue(prompt.contains("difficulty")); + assertTrue(prompt.contains("time")); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalRecoveryServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalRecoveryServiceTest.java new file mode 100644 index 00000000..95af80c4 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalRecoveryServiceTest.java @@ -0,0 +1,93 @@ +package vip.mate.goal.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; +import vip.mate.channel.web.ConversationInputQueueStore; +import vip.mate.goal.model.GoalAttempt; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalStatus; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class GoalRecoveryServiceTest { + JdbcTemplate jdbc; + GoalAttemptStore attempts; + GoalContinuationStore continuations; + ConversationInputQueueStore inputs; + GoalService goals=mock(GoalService.class); + GoalRunCoordinator coordinator; + GoalRecoveryService recovery; + GoalEntity goal; + LocalDateTime now=LocalDateTime.of(2026,8,27,2,0); + + @BeforeEach void setup() { + JdbcDataSource ds=new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:"+ UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1"); + new ResourceDatabasePopulator(new ClassPathResource("db/migration/h2/V120__agent_goal.sql"), + new ClassPathResource("db/migration/h2/V188__goal_continuation.sql"), + new ClassPathResource("db/migration/h2/V189__goal_attempt_and_input_queue.sql")).execute(ds); + jdbc=new JdbcTemplate(ds);attempts=new GoalAttemptStore(jdbc);continuations=new GoalContinuationStore(jdbc); + inputs=new ConversationInputQueueStore(jdbc,new ObjectMapper()); + coordinator=new GoalRunCoordinator(continuations,attempts,goals,new vip.mate.goal.config.GoalProperties()); + recovery=new GoalRecoveryService(attempts,continuations,inputs,goals); + jdbc.update(""" + INSERT INTO mate_agent_goal(id,conversation_id,agent_id,workspace_id,created_by,title, + description,status,persistent_execution,auto_followup_enabled,create_time,update_time) + VALUES(1,'conv',2,3,'alice','goal','objective','active',TRUE,TRUE,?,?) + """,now,now); + goal=new GoalEntity();goal.setId(1L);goal.setConversationId("conv");goal.setAgentId(2L); + goal.setWorkspaceId(3L);goal.setCreatedBy("alice");goal.setStatus(GoalStatus.ACTIVE); + goal.setPersistentExecution(true);goal.setAutoFollowupEnabled(true); + when(goals.getById(1L)).thenReturn(goal); + continuations.discover(now); + } + + @Test void classifiesCheckpointRecoveryMatrix() { + assertEquals(GoalRecoveryService.RecoveryDecision.RETRY_SAFE,recovery.classify(attempt("claimed","safe",null))); + assertEquals(GoalRecoveryService.RecoveryDecision.BLOCK_UNCERTAIN_SIDE_EFFECT, + recovery.classify(attempt("tool_started","uncertain",null))); + assertEquals(GoalRecoveryService.RecoveryDecision.RECONCILE_MESSAGE, + recovery.classify(attempt("message_saved","resolved",42L))); + assertEquals(GoalRecoveryService.RecoveryDecision.RESUME_FROM_EVIDENCE, + recovery.classify(attempt("tool_completed","resolved",null))); + } + + @Test void expiredSafeAttemptRequeuesAndReleasesClaimedInputWithParentLink() { + var old=coordinator.claim(continuations.get(1L),goal,now); + assertTrue(coordinator.markRunning(old,now)); + var queued=inputs.enqueue("conv",2L,"alice","follow up",List.of(),now); + assertTrue(inputs.claimNext("conv",old.attempt().id(),now).isPresent()); + assertEquals(1,recovery.recoverExpired(now.plusSeconds(61))); + assertEquals("retryable",attempts.get(old.attempt().id()).state()); + assertEquals("retry",continuations.get(1L).state()); + assertEquals(1,inputs.countQueued("conv")); + var next=coordinator.claim(continuations.get(1L),goal,now.plusSeconds(61)); + assertEquals(old.attempt().id(),next.attempt().parentAttemptId()); + assertEquals(queued.id(),inputs.listQueued("conv").getFirst().id()); + } + + @Test void uncertainToolAttemptBlocksInsteadOfReplaying() { + var old=coordinator.claim(continuations.get(1L),goal,now); + assertTrue(coordinator.markRunning(old,now)); + assertTrue(coordinator.checkpoint(old,"uncertain","tool_started",null,now.plusSeconds(1))); + assertEquals(1,recovery.recoverExpired(now.plusSeconds(61))); + assertEquals("blocked",attempts.get(old.attempt().id()).state()); + assertEquals("blocked",continuations.get(1L).state()); + verify(goals).pause(1L,"alice"); + } + + private GoalAttempt attempt(String checkpoint,String safety,Long messageId) { + return new GoalAttempt("a",1L,"conv",null,"continuation","running","lease",now, + null,messageId,safety,checkpoint,null,null,now,null,now,now); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalRunCoordinatorTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalRunCoordinatorTest.java new file mode 100644 index 00000000..f8d2b212 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalRunCoordinatorTest.java @@ -0,0 +1,90 @@ +package vip.mate.goal.service; + +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; +import vip.mate.goal.config.GoalProperties; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalStatus; +import vip.mate.goal.model.SegmentOutcome; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class GoalRunCoordinatorTest { + JdbcTemplate jdbc; + GoalContinuationStore continuations; + GoalAttemptStore attempts; + GoalService goals=mock(GoalService.class); + GoalProperties properties=new GoalProperties(); + GoalRunCoordinator coordinator; + LocalDateTime now=LocalDateTime.of(2026,8,27,1,0); + GoalEntity goal; + + @BeforeEach void setup() { + JdbcDataSource ds=new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:"+ UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1"); + new ResourceDatabasePopulator(new ClassPathResource("db/migration/h2/V120__agent_goal.sql"), + new ClassPathResource("db/migration/h2/V188__goal_continuation.sql"), + new ClassPathResource("db/migration/h2/V189__goal_attempt_and_input_queue.sql")).execute(ds); + jdbc=new JdbcTemplate(ds);continuations=new GoalContinuationStore(jdbc);attempts=new GoalAttemptStore(jdbc); + coordinator=new GoalRunCoordinator(continuations,attempts,goals,properties); + jdbc.update(""" + INSERT INTO mate_agent_goal(id,conversation_id,agent_id,workspace_id,created_by,title, + description,status,persistent_execution,auto_followup_enabled,create_time,update_time) + VALUES(1,'conv',2,3,'alice','goal','objective','active',TRUE,TRUE,?,?) + """,now,now); + goal=new GoalEntity();goal.setId(1L);goal.setConversationId("conv");goal.setAgentId(2L); + goal.setWorkspaceId(3L);goal.setCreatedBy("alice");goal.setStatus(GoalStatus.ACTIVE); + goal.setPersistentExecution(true);goal.setAutoFollowupEnabled(true); + when(goals.getById(1L)).thenReturn(goal); + continuations.discover(now); + } + + @Test void claimBindsAttemptAndStaleSettlementCannotOverwriteNewProjection() { + var claim=coordinator.claim(continuations.get(1L),goal,now); + assertNotNull(claim); + assertEquals("claimed",attempts.get(claim.attempt().id()).state()); + assertEquals(claim.attempt().id(),continuations.get(1L).currentAttemptId()); + assertTrue(coordinator.markRunning(claim,now)); + assertTrue(coordinator.settle(claim,new SegmentOutcome.Continue("unfinished"),now)); + assertEquals("queued",continuations.get(1L).state()); + assertEquals("succeeded",attempts.get(claim.attempt().id()).state()); + assertFalse(coordinator.settle(claim,new SegmentOutcome.Complete("late"),now.plusSeconds(1))); + } + + @Test void retryAndBlockedOutcomesHaveExplicitTerminalAttemptStates() { + var retry=coordinator.claim(continuations.get(1L),goal,now); + assertTrue(coordinator.markRunning(retry,now)); + assertTrue(coordinator.settle(retry,new SegmentOutcome.Retry("provider","timeout"),now)); + assertEquals("retryable",attempts.get(retry.attempt().id()).state()); + var due=continuations.get(1L); + var blocked=coordinator.claim(due,goal,due.nextRunAt()); + assertTrue(coordinator.markRunning(blocked,due.nextRunAt())); + assertTrue(coordinator.settle(blocked,new SegmentOutcome.Blocked("tool","review"),due.nextRunAt())); + assertEquals("blocked",attempts.get(blocked.attempt().id()).state()); + assertEquals("blocked",continuations.get(1L).state()); + } + + @Test void continuationUsesTheLargerOfGlobalAndGoalCooldowns() { + properties.setMinimumContinuationIntervalSeconds(300); + goal.setFollowupCooldownSeconds(0); + var first=coordinator.claim(continuations.get(1L),goal,now); + assertTrue(coordinator.markRunning(first,now)); + assertTrue(coordinator.settle(first,new SegmentOutcome.Continue("unfinished"),now)); + assertEquals(now.plusSeconds(300),continuations.get(1L).nextRunAt()); + + LocalDateTime secondStart=now.plusSeconds(300); + goal.setFollowupCooldownSeconds(600); + var second=coordinator.claim(continuations.get(1L),goal,secondStart); + assertTrue(coordinator.markRunning(second,secondStart)); + assertTrue(coordinator.settle(second,new SegmentOutcome.Continue("unfinished"),secondStart)); + assertEquals(secondStart.plusSeconds(600),continuations.get(1L).nextRunAt()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalSegmentRunnerTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalSegmentRunnerTest.java new file mode 100644 index 00000000..31f8d4f6 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalSegmentRunnerTest.java @@ -0,0 +1,313 @@ +package vip.mate.goal.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import vip.mate.agent.AgentService; +import vip.mate.agent.context.GoalContinuationContext; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.runtime.ConversationTurnGate; +import vip.mate.approval.ApprovalWorkflowService; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.channel.web.ConversationInputQueueStore; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.SegmentOutcome; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.model.MessageContentPart; +import vip.mate.workspace.conversation.model.MessageEntity; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class GoalSegmentRunnerTest { + AgentService agents=mock(AgentService.class); + ConversationService conversations=mock(ConversationService.class); + ApprovalWorkflowService approvals=mock(ApprovalWorkflowService.class); + ChatStreamTracker streams=new ChatStreamTracker(new ObjectMapper()); + ConversationInputQueueStore inputQueue=mock(ConversationInputQueueStore.class); + ConcurrentLinkedQueue durableInputs=new ConcurrentLinkedQueue<>(); + java.util.concurrent.atomic.AtomicLong inputIds=new java.util.concurrent.atomic.AtomicLong(); + ConversationTurnGate gate=new ConversationTurnGate(); + GoalEntity goal=new GoalEntity(); + GoalSegmentRunner runner=new GoalSegmentRunner(agents,conversations,approvals,streams,new ObjectMapper(),gate,inputQueue); + + @BeforeEach void setup() { + goal.setId(1L);goal.setConversationId("conv");goal.setAgentId(2L);goal.setWorkspaceId(3L);goal.setCreatedBy("alice"); + ConversationEntity conv=new ConversationEntity(); + conv.setConversationId("conv");conv.setAgentId(2L);conv.setWorkspaceId(3L);conv.setUsername("alice"); + when(conversations.findByConversationId("conv")).thenReturn(conv); + AgentEntity agent=new AgentEntity();agent.setEnabled(true);agent.setRuntimeType("native"); + when(agents.getAgent(2L)).thenReturn(agent); + when(inputQueue.enqueue(anyString(),anyLong(),anyString(),anyString(),nullable(List.class),any())) + .thenAnswer(inv -> { + var now=LocalDateTime.now(); + var input=new ConversationInputQueueStore.QueuedInput(inputIds.incrementAndGet(), + inv.getArgument(0),inv.getArgument(1),inv.getArgument(2),inv.getArgument(3), + inv.getArgument(4),"queued",null,null,null,now,now); + durableInputs.add(input); + return input; + }); + when(inputQueue.claimNext(anyString(),anyString(),any())).thenAnswer(inv -> { + var input=durableInputs.poll(); + if(input==null) return java.util.Optional.empty(); + return java.util.Optional.of(new ConversationInputQueueStore.QueuedInput(input.id(),input.conversationId(), + input.agentId(),input.createdBy(),input.message(),input.contentParts(),"claimed", + inv.getArgument(1),input.persistedMessageId(),null,input.createdAt(),LocalDateTime.now())); + }); + when(inputQueue.bindMessage(anyLong(),anyString(),anyLong(),any())).thenReturn(true); + when(inputQueue.consume(anyLong(),anyString(),any())).thenReturn(true); + when(inputQueue.release(anyLong(),anyString(),any())).thenReturn(true); + when(inputQueue.countQueued(anyString())).thenAnswer(inv -> durableInputs.size()); + MessageEntity savedUser=new MessageEntity();savedUser.setId(77L); + when(conversations.saveMessage(eq("conv"),eq("user"),anyString(),nullable(List.class),eq("queued"))) + .thenReturn(savedUser); + } + + private void enqueue(String message,List parts) { + inputQueue.enqueue("conv",2L,"alice",message,parts,LocalDateTime.now()); + } + + @Test void persistsStreamedResultAndUsage() { + when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) + .thenReturn(Flux.just(new AgentService.StreamDelta("actual output",null), + AgentService.StreamDelta.event("finish_reason",Map.of("reason","normal")))); + var result=runner.run(goal,"continue",false); + assertEquals("normal",result.finishReason()); + verify(conversations).saveMessage(eq("conv"),eq("assistant"),eq("actual output"),anyList(),eq("completed"), + anyInt(),anyInt(),anyInt(),anyInt(),anyInt(),anyString(),anyString(),anyString()); + assertFalse(streams.isRunning("conv")); + assertNotNull(gate.tryAcquire("conv")); + } + + @Test void rejectsChangedConversationIdentityBeforeAnyModelCall() { + goal.setWorkspaceId(99L); + assertThrows(IllegalStateException.class,()->runner.run(goal,"continue",false)); + verify(agents,never()).chatStructuredStream(any(),any(),any(),any(),any(),any()); + } + + @Test void busyConversationIsNotRegisteredOrMutated() { + var user=gate.tryAcquire("conv"); + assertThrows(vip.mate.exception.MateClawException.class,()->runner.run(goal,"continue",false)); + verifyNoInteractions(conversations); + user.close(); + } + + @Test void drainsUserInputAcceptedDuringBackgroundTurn() { + var calls=new java.util.concurrent.atomic.AtomicInteger(); + when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) + .thenAnswer(inv -> Flux.defer(() -> { + boolean autonomous = calls.incrementAndGet()==1; + assertTrue(GoalContinuationContext.active()); + assertEquals(autonomous, GoalContinuationContext.explicitPrompt()); + if(autonomous) enqueue("new user instruction",null); + return Flux.just(new AgentService.StreamDelta("output",null), + AgentService.StreamDelta.event("finish_reason",Map.of("reason","normal"))); + })); + runner.run(goal,"continue",false); + assertEquals(2,calls.get()); + assertEquals(0,inputQueue.countQueued("conv")); + verify(agents).chatStructuredStream(eq(2L),eq("new user instruction"),eq("conv"),eq("alice"),isNull(),any()); + verify(conversations).saveMessage("conv","user","new user instruction",null,"queued"); + } + + @Test void workerCancellationPersistsPartialEvidenceAndReleasesAdmission() throws Exception { + var subscribed=new java.util.concurrent.CountDownLatch(1); + var toolCancelled=new java.util.concurrent.atomic.AtomicBoolean(); + when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) + .thenReturn(Flux.concat(Flux.just(new AgentService.StreamDelta("partial evidence",null)), + Flux.never().doOnSubscribe(s -> { + streams.registerCancellationHook("conv",()->toolCancelled.set(true)); + subscribed.countDown(); + }))); + var failure=new java.util.concurrent.atomic.AtomicReference(); + var result=new java.util.concurrent.atomic.AtomicReference(); + Thread worker=Thread.ofVirtual().start(() -> { + try { result.set(runner.run(goal,"continue",false)); } catch(Throwable error) { failure.set(error); } + }); + assertTrue(subscribed.await(3,java.util.concurrent.TimeUnit.SECONDS)); + runner.cancel(1L); + worker.join(3000); + assertFalse(worker.isAlive()); + assertTrue(toolCancelled.get(),"escaped tool process must be cancelled as well as the stream"); + // Cancellation may finish the Flux before the worker receives its interrupt. + // Both paths must preserve evidence and report a stopped/interrupted outcome. + if (failure.get()==null) assertEquals("stopped",result.get().finishReason()); + verify(conversations).saveMessage(eq("conv"),eq("assistant"),eq("partial evidence"),anyList(), + argThat(status -> "interrupted".equals(status) || "stopped".equals(status)), + anyInt(),anyInt(),anyInt(),anyInt(),anyInt(),anyString(),anyString(),anyString()); + assertNotNull(gate.tryAcquire("conv")); + } + + @Test void cancellationDoesNotInterruptDatabasePersistence() throws Exception { + var saving = new CountDownLatch(1); + var release = new CountDownLatch(1); + var interrupted = new AtomicBoolean(); + when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) + .thenReturn(Flux.just(new AgentService.StreamDelta("checkpoint", null))); + when(conversations.saveMessage(eq("conv"),eq("assistant"),anyString(),anyList(),anyString(), + anyInt(),anyInt(),anyInt(),anyInt(),anyInt(),anyString(),anyString(),anyString())) + .thenAnswer(inv -> { + saving.countDown(); + try { release.await(3, TimeUnit.SECONDS); } + catch (InterruptedException error) { interrupted.set(true); } + return null; + }); + Thread worker = Thread.ofVirtual().start(() -> runner.run(goal,"continue",false)); + assertTrue(saving.await(3,TimeUnit.SECONDS)); + runner.cancel(1L); + release.countDown(); + worker.join(3000); + assertFalse(worker.isAlive()); + assertFalse(interrupted.get(), "cancellation must not close embedded database channels by interrupting I/O"); + } + + @Test void shutdownPersistsQueuedInputWithAttachments() throws Exception { + var ready = new CountDownLatch(1); + MessageContentPart attachment = new MessageContentPart(); + attachment.setType("file"); + attachment.setPath("test-evidence.txt"); + var parts = List.of(attachment); + when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) + .thenReturn(Flux.never().doOnSubscribe(s -> ready.countDown())); + Thread worker = Thread.ofVirtual().start(() -> runner.run(goal,"continue",false)); + assertTrue(ready.await(3,TimeUnit.SECONDS)); + enqueue("accepted steering",parts); + runner.cancelAll(); + worker.join(3000); + assertFalse(worker.isAlive()); + verify(conversations,never()).saveMessage("conv","user","accepted steering",parts,"queued"); + assertEquals(1,inputQueue.countQueued("conv")); + } + + @Test void shutdownRejectsLateWorkerAdmissionWithoutStartingModel() { + when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) + .thenReturn(Flux.just(AgentService.StreamDelta.event("finish_reason",Map.of("reason","normal")))); + runner.cancelAll(); + assertEquals("stopped", runner.run(goal,"continue",false).finishReason()); + verify(agents,never()).chatStructuredStream(any(),any(),any(),any(),any(),any()); + } + + @Test void externalInterruptIsRestoredOnlyAfterCheckpointPersistence() throws Exception { + var ready = new CountDownLatch(1); + var persisted = new AtomicBoolean(); + var interruptRestored = new AtomicBoolean(); + when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) + .thenReturn(Flux.concat(Flux.just(new AgentService.StreamDelta("partial",null)), + Flux.never().doOnSubscribe(s -> ready.countDown()))); + when(conversations.saveMessage(eq("conv"),eq("assistant"),anyString(),anyList(),anyString(), + anyInt(),anyInt(),anyInt(),anyInt(),anyInt(),anyString(),anyString(),anyString())) + .thenAnswer(inv -> { + assertFalse(Thread.currentThread().isInterrupted()); + persisted.set(true); + return null; + }); + Thread worker = Thread.ofVirtual().start(() -> { + try { runner.run(goal,"continue",false); } + catch (IllegalStateException expected) { interruptRestored.set(Thread.currentThread().isInterrupted()); } + }); + assertTrue(ready.await(3,TimeUnit.SECONDS)); + worker.interrupt(); + worker.join(3000); + assertFalse(worker.isAlive()); + assertTrue(persisted.get()); + assertTrue(interruptRestored.get()); + } + + @Test void permanentFailurePersistsAcceptedQueuedInput() { + when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) + .thenReturn(Flux.defer(() -> { + enqueue("user instruction",null); + return Flux.error(new IllegalArgumentException("bad config")); + })); + assertThrows(IllegalArgumentException.class,()->runner.run(goal,"continue",false)); + verify(conversations,never()).saveMessage("conv","user","user instruction",null,"queued"); + assertEquals(1,inputQueue.countQueued("conv")); + } + + @Test void userSteeringPreservesInterruptedStatusThenRunsQueuedInput() throws Exception { + var subscribed=new java.util.concurrent.CountDownLatch(1); + var calls=new java.util.concurrent.atomic.AtomicInteger(); + when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) + .thenAnswer(inv -> calls.incrementAndGet()==1 + ? Flux.concat(Flux.just(new AgentService.StreamDelta("partial",null)), + Flux.never().doOnSubscribe(s -> subscribed.countDown())) + : Flux.just(new AgentService.StreamDelta("answer to steering",null))); + var failure=new java.util.concurrent.atomic.AtomicReference(); + Thread worker=Thread.ofVirtual().start(() -> { + try { runner.run(goal,"continue",false); } catch(Throwable error) { failure.set(error); } + }); + assertTrue(subscribed.await(3,java.util.concurrent.TimeUnit.SECONDS)); + enqueue("new instruction",null); + assertTrue(streams.requestInterrupt("conv","",2L,false)); + worker.join(3000); + assertFalse(worker.isAlive()); + assertNull(failure.get()); + assertEquals(2,calls.get()); + verify(conversations).saveMessage(eq("conv"),eq("assistant"),eq("partial"),anyList(),eq("interrupted"), + anyInt(),anyInt(),anyInt(),anyInt(),anyInt(),anyString(),anyString(),anyString()); + } + + @Test void goalCancellationDoesNotKillQueuedInteractiveWork() throws Exception { + var entered=new java.util.concurrent.CountDownLatch(1); + var finish=reactor.core.publisher.Sinks.one(); + var calls=new java.util.concurrent.atomic.AtomicInteger(); + when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) + .thenAnswer(inv -> { + if(calls.incrementAndGet()==1) { + enqueue("new question",null); + return Flux.just(AgentService.StreamDelta.event("finish_reason",Map.of("reason","normal"))); + } + return finish.asMono().flux().doOnSubscribe(s -> entered.countDown()); + }); + var failure=new java.util.concurrent.atomic.AtomicReference(); + Thread worker=Thread.ofVirtual().start(() -> { + try { runner.run(goal,"continue",false); } catch(Throwable error) { failure.set(error); } + }); + assertTrue(entered.await(3,java.util.concurrent.TimeUnit.SECONDS)); + runner.cancel(1L); + finish.tryEmitValue(AgentService.StreamDelta.event("finish_reason",Map.of("reason","normal"))); + worker.join(3000); + assertFalse(worker.isAlive()); + assertNull(failure.get()); + } + + @Test void explicitStopLatchesAcrossQueuedInputRegistrationGap() throws Exception { + var saving=new java.util.concurrent.CountDownLatch(1); + var release=new java.util.concurrent.CountDownLatch(1); + var calls=new java.util.concurrent.atomic.AtomicInteger(); + when(agents.chatStructuredStream(eq(2L),anyString(),eq("conv"),eq("alice"),isNull(),any())) + .thenAnswer(inv -> { + calls.incrementAndGet(); + enqueue("new question",null); + return Flux.just(AgentService.StreamDelta.event("finish_reason",Map.of("reason","normal"))); + }); + when(conversations.saveMessage("conv","user","new question",null,"queued")).thenAnswer(inv -> { + saving.countDown(); + boolean interrupted=false; + while(true) { + try { if(release.await(3,java.util.concurrent.TimeUnit.SECONDS)) break; else throw new AssertionError("release timeout"); } + catch(InterruptedException ignored) { interrupted=true; } + } + if(interrupted) Thread.currentThread().interrupt(); + return null; + }); + Thread worker=Thread.ofVirtual().start(() -> { + try { runner.run(goal,"continue",false); } catch(RuntimeException expected) { } + }); + assertTrue(saving.await(3,java.util.concurrent.TimeUnit.SECONDS)); + runner.stopConversation("conv"); + release.countDown(); + worker.join(3000); + assertFalse(worker.isAlive()); + assertEquals(1,calls.get(),"no queued model request may start after explicit Stop"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java index ee39d3cd..e7797bc5 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java @@ -55,6 +55,7 @@ class GoalServiceTest { @Mock private AuditEventService auditEventService; private GoalServiceImpl service; + private GoalProperties properties; @BeforeAll static void initTableInfo() { @@ -68,7 +69,7 @@ class GoalServiceTest { @BeforeEach void setUp() { - GoalProperties properties = new GoalProperties(); + properties = new GoalProperties(); service = new GoalServiceImpl(goalMapper, eventMapper, properties, auditEventService, new ObjectMapper()); } @@ -122,8 +123,9 @@ class GoalServiceTest { assertNotNull(created); assertEquals("alice", created.getCreatedBy()); assertEquals(GoalStatus.ACTIVE, created.getStatus()); - assertEquals(20, created.getTurnBudget()); - assertEquals(200, created.getLlmCallBudget()); + assertTrue(created.getPersistentExecution()); + assertEquals(0, created.getTurnBudget()); + assertEquals(0, created.getLlmCallBudget()); verify(eventMapper, times(1)).insert(any(GoalEventEntity.class)); verify(auditEventService).record(eq("goal.created"), eq("goal"), anyString(), anyString(), anyString(), any()); @@ -162,12 +164,235 @@ class GoalServiceTest { @Test void create_rejectsNonPositiveBudget() { GoalCreateRequest r = validReq(); + r.setPersistentExecution(false); r.setTurnBudget(0); MateClawException ex = assertThrows(MateClawException.class, () -> service.create(r, "alice")); assertEquals(400, ex.getCode()); } + @Test + void create_persistenceDefaultCanBeDisabled_andExplicitOptInWins() { + properties.setDefaultPersistentExecution(false); + GoalEntity legacy = service.create(validReq(), "alice"); + assertFalse(legacy.getPersistentExecution()); + assertEquals(20, legacy.getTurnBudget()); + assertEquals(200, legacy.getLlmCallBudget()); + GoalCreateRequest req = validReq(); + req.setPersistentExecution(true); + GoalEntity persistent = service.create(req, "alice"); + assertTrue(persistent.getPersistentExecution()); + assertEquals(0, persistent.getTurnBudget()); + assertEquals(0, persistent.getLlmCallBudget()); + assertTrue(service.toResponse(persistent).getPersistentExecution()); + } + + @Test + void create_explicitLegacyRetainsDefaults() { + GoalCreateRequest req = validReq(); + req.setPersistentExecution(false); + GoalEntity goal = service.create(req, "alice"); + assertFalse(goal.getPersistentExecution()); + assertEquals(20, goal.getTurnBudget()); + assertEquals(200, goal.getLlmCallBudget()); + } + + @Test + void create_persistentAcceptsZero_andHonorsPositiveBudgets() { + GoalCreateRequest req = validReq(); + req.setTurnBudget(0); + req.setLlmCallBudget(7); + GoalEntity goal = service.create(req, "alice"); + assertEquals(0, goal.getTurnBudget()); + assertEquals(7, goal.getLlmCallBudget()); + req.setTurnBudget(-1); + assertEquals(400, assertThrows(MateClawException.class, + () -> service.create(req, "alice")).getCode()); + req.setTurnBudget(1); + req.setLlmCallBudget(-1); + assertEquals(400, assertThrows(MateClawException.class, + () -> service.create(req, "alice")).getCode()); + } + + @Test + void persistentZeroBudgetsAreUnlimited_butLegacyZeroIsExhausted() { + GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); + goal.setPersistentExecution(true); + goal.setTurnBudget(0); + goal.setLlmCallBudget(0); + goal.setTurnsUsed(999); + goal.setAgentLlmCallsUsed(999); + assertFalse(service.isBudgetExhausted(goal)); + goal.setPersistentExecution(false); + assertTrue(service.isBudgetExhausted(goal)); + } + + @Test + void persistentPositiveBudgetsRemainBinding() { + GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); + goal.setPersistentExecution(true); + goal.setTurnBudget(0); + goal.setLlmCallBudget(10); + goal.setAgentLlmCallsUsed(9); + assertFalse(service.isBudgetExhausted(goal)); + goal.setEvalLlmCallsUsed(1); + assertTrue(service.isBudgetExhausted(goal)); + assertEquals("llm_call_budget", service.exhaustionReason(goal)); + goal.setTurnBudget(3); + goal.setTurnsUsed(3); + assertEquals("turn_budget", service.exhaustionReason(goal)); + } + + @Test + void persistentBudgetExhaustionPauses_withResumableReason() { + GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); + goal.setPersistentExecution(true); + goal.setTurnsUsed(20); + when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.PAUSED)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + GoalEntity result = service.markExhausted(1L, "turn_budget"); + assertEquals(GoalStatus.PAUSED, result.getStatus()); + ArgumentCaptor event = ArgumentCaptor.forClass(GoalEventEntity.class); + verify(eventMapper).insert(event.capture()); + assertEquals("paused", event.getValue().getEventType()); + assertTrue(event.getValue().getDetailJson().contains("turn_budget")); + ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(goalMapper).update(any(), update.capture()); + assertTrue(update.getValue().getSqlSet().contains("progress_summary")); + assertTrue(update.getValue().getParamNameValuePairs().values().stream() + .anyMatch(value -> String.valueOf(value).contains("turn_budget"))); + } + + @Test + void resumePersistentRequiresBudgetHeadroom_andAllowsRaisedBudget() { + GoalEntity goal = persisted(1L, GoalStatus.PAUSED); + goal.setPersistentExecution(true); + goal.setTurnsUsed(20); + when(goalMapper.selectById(1L)).thenReturn(goal); + assertEquals(409, assertThrows(MateClawException.class, + () -> service.resume(1L, "alice")).getCode()); + verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); + goal.setTurnBudget(21); + when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.ACTIVE)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + assertEquals(GoalStatus.ACTIVE, service.resume(1L, "alice").getStatus()); + } + + @Test + void updateUsesFreshMode_forZeroBudget_andDoesNotReplaceOmittedBudgets() { + GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); + goal.setPersistentExecution(true); + when(goalMapper.selectById(1L)).thenReturn(goal); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + GoalUpdateRequest req = new GoalUpdateRequest(); + req.setTurnBudget(0); + service.update(1L, req, "alice"); + ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(goalMapper).update(any(), update.capture()); + assertTrue(setsProperty(update.getValue(), "turnBudget"), update.getValue().getSqlSet()); + assertFalse(setsProperty(update.getValue(), "llmCallBudget")); + assertFalse(setsProperty(update.getValue(), "persistentExecution")); + } + + @Test + void updateModeValidatesCombinedState_beforeWriting() { + GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); + goal.setPersistentExecution(true); + goal.setTurnBudget(0); + goal.setLlmCallBudget(0); + when(goalMapper.selectById(1L)).thenReturn(goal); + GoalUpdateRequest req = new GoalUpdateRequest(); + req.setPersistentExecution(false); + assertEquals(400, assertThrows(MateClawException.class, + () -> service.update(1L, req, "alice")).getCode()); + verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); + req.setTurnBudget(10); + req.setLlmCallBudget(100); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + service.update(1L, req, "alice"); + ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(goalMapper).update(any(), update.capture()); + assertTrue(setsProperty(update.getValue(), "persistentExecution"), update.getValue().getSqlSet()); + } + + @Test + void persistentCompletionRequiresFreshPassedCriteriaWithEvidence() { + GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); + goal.setPersistentExecution(true); + when(goalMapper.selectById(1L)).thenReturn(goal); + for (String criteria : new String[]{null, "[]", + "[{\"id\":\"C1\",\"text\":\"deploy\",\"passed\":false,\"evidence\":\"attempted\"}]", + "[{\"id\":\"C1\",\"text\":\"deploy\",\"passed\":true,\"evidence\":\" \"}]"}) { + goal.setCriteria(criteria); + MateClawException error = assertThrows(MateClawException.class, + () -> service.markCompleted(1L, null)); + assertEquals(409, error.getCode()); + } + verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); + } + + @Test + void persistentCompletionCannotOverridePause() { + GoalEntity goal = verifiedPersistentGoal(GoalStatus.PAUSED); + when(goalMapper.selectById(1L)).thenReturn(goal); + assertEquals(409, assertThrows(MateClawException.class, + () -> service.markCompleted(1L, null)).getCode()); + verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); + } + + @Test + void persistentCompletionPreservesVerifiedChecklist() { + GoalEntity goal = verifiedPersistentGoal(GoalStatus.ACTIVE); + when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.COMPLETED)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + assertEquals(GoalStatus.COMPLETED, service.markCompleted(1L, null).getStatus()); + ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(goalMapper).update(any(), update.capture()); + assertFalse(update.getValue().getSqlSet().contains("criteria=")); + } + + @Test + void persistentCompletionRechecksEvidenceAfterCasMiss() { + GoalEntity old = verifiedPersistentGoal(GoalStatus.ACTIVE); + GoalEntity fresh = verifiedPersistentGoal(GoalStatus.ACTIVE); + fresh.setVersion(1); + fresh.setCriteria("[{\"id\":\"C1\",\"text\":\"new requirement\",\"passed\":false,\"evidence\":\"\"}]"); + when(goalMapper.selectById(1L)).thenReturn(old, fresh); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0); + assertEquals(409, assertThrows(MateClawException.class, + () -> service.markCompleted(1L, null)).getCode()); + verify(goalMapper, times(1)).update(any(), any(LambdaUpdateWrapper.class)); + } + + @Test + void persistentResumePublishesSignalOnlyAfterSuccessfulTransition() { + var publisher = org.mockito.Mockito.mock(org.springframework.context.ApplicationEventPublisher.class); + service.setApplicationEventPublisher(publisher); + GoalEntity goal = verifiedPersistentGoal(GoalStatus.PAUSED); + goal.setTurnsUsed(20); + when(goalMapper.selectById(1L)).thenReturn(goal); + assertThrows(MateClawException.class, () -> service.resume(1L, "alice")); + verify(publisher, never()).publishEvent(any(Object.class)); + goal.setTurnBudget(21); + when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.ACTIVE)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + service.resume(1L, "alice"); + verify(publisher).publishEvent(new GoalExecutionSignal.Resume(1L)); + } + + private boolean setsProperty(LambdaUpdateWrapper update, String property) { + String column = TableInfoHelper.getTableInfo(GoalEntity.class).getFieldList().stream() + .filter(field -> property.equals(field.getProperty())).findFirst().orElseThrow().getColumn(); + return update.getSqlSet().contains(column + "="); + } + + private GoalEntity verifiedPersistentGoal(GoalStatus status) { + GoalEntity goal = persisted(1L, status); + goal.setPersistentExecution(true); + goal.setCriteria("[{\"id\":\"C1\",\"text\":\"deploy\",\"passed\":true,\"evidence\":\"HTTP 200 verified\"}]"); + return goal; + } + // ==================== state transitions ==================== @Test @@ -454,6 +679,7 @@ class GoalServiceTest { copy.setCreatedBy(g.getCreatedBy()); copy.setTitle(g.getTitle()); copy.setStatus(newStatus); + copy.setPersistentExecution(g.getPersistentExecution()); copy.setTurnBudget(g.getTurnBudget()); copy.setTurnsUsed(g.getTurnsUsed()); copy.setLlmCallBudget(g.getLlmCallBudget()); @@ -465,4 +691,90 @@ class GoalServiceTest { copy.setUpdateTime(LocalDateTime.now()); return copy; } + @Test + void waitForInputPausesActivePersistentGoal_andRecordsReason() { + GoalEntity goal = verifiedPersistentGoal(GoalStatus.ACTIVE); + when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.PAUSED)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + GoalEntity paused = service.waitForInput(1L, " Need production hostname from the owner ", "alice"); + assertEquals(GoalStatus.PAUSED, paused.getStatus()); + ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(goalMapper).update(any(), update.capture()); + assertTrue(setsProperty(update.getValue(), "progressSummary")); + assertTrue(update.getValue().getParamNameValuePairs().containsValue( + "Waiting for input: Need production hostname from the owner")); + assertTrue(update.getValue().getParamNameValuePairs().containsValue(GoalStatus.PAUSED)); + ArgumentCaptor event = ArgumentCaptor.forClass(GoalEventEntity.class); + verify(eventMapper).insert(event.capture()); + assertEquals("paused", event.getValue().getEventType()); + assertTrue(event.getValue().getDetailJson().contains("Need production hostname from the owner")); + verify(auditEventService).record(eq("goal.waiting_input"), eq("goal"), eq("1"), + anyString(), anyString(), any()); + } + + @Test + void waitForInputRejectsBlankReasonBeforeAccessingGoal() { + for (String reason : new String[]{null, "", " "}) { + assertEquals(400, assertThrows(MateClawException.class, + () -> service.waitForInput(1L, reason, "alice")).getCode()); + } + verify(goalMapper, never()).selectById(any()); + } + + @Test + void waitForInputRechecksActiveStateAfterCasConflict() { + GoalEntity active = verifiedPersistentGoal(GoalStatus.ACTIVE); + GoalEntity paused = statusFlipped(active, GoalStatus.PAUSED); + when(goalMapper.selectById(1L)).thenReturn(active, paused); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0); + assertEquals(409, assertThrows(MateClawException.class, + () -> service.waitForInput(1L, "Need deployment approval", "alice")).getCode()); + verify(goalMapper, times(1)).update(any(), any(LambdaUpdateWrapper.class)); + verify(eventMapper, never()).insert(any(GoalEventEntity.class)); + } + + @Test + void waitForInputRejectsLegacyAndTerminalGoals() { + GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); + when(goalMapper.selectById(1L)).thenReturn(goal); + assertEquals(409, assertThrows(MateClawException.class, + () -> service.waitForInput(1L, "Need deployment approval", "alice")).getCode()); + goal.setPersistentExecution(true); + goal.setStatus(GoalStatus.COMPLETED); + assertEquals(409, assertThrows(MateClawException.class, + () -> service.waitForInput(1L, "Need deployment approval", "alice")).getCode()); + verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); + } + + @Test + void waitForInputBoundsPersistedReason() { + GoalEntity goal = verifiedPersistentGoal(GoalStatus.ACTIVE); + when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.PAUSED)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + service.waitForInput(1L, "Missing permission: " + "x".repeat(10000), "alice"); + ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(goalMapper).update(any(), update.capture()); + update.getValue().getSqlSet(); + String summary = ((java.util.Map) update.getValue().getParamNameValuePairs()).values().stream() + .filter(value -> value instanceof String && ((String) value).startsWith("Waiting for input: ")) + .map(String::valueOf).findFirst().orElseThrow(); + assertTrue(summary.length() <= 2048); + } + + @Test + void lateEvaluationAccountsUsageWithoutOverwritingPersistentPauseReason() { + GoalEntity active = verifiedPersistentGoal(GoalStatus.ACTIVE); + GoalEntity paused = statusFlipped(active, GoalStatus.PAUSED); + paused.setProgressSummary("Waiting for input: Need deployment approval"); + when(goalMapper.selectById(1L)).thenReturn(active, paused, paused); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + GoalEvaluationResult evaluation = new GoalEvaluationResult(0.4, "More work needed", + GoalEvaluationResult.DECISION_CONTINUE, false, "stub", 1, 0, java.util.List.of(), null); + service.recordEvaluation(1L, evaluation, 3, 1); + ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(goalMapper).update(any(), update.capture()); + assertFalse(setsProperty(update.getValue(), "progressSummary")); + assertTrue(update.getValue().getSqlSet().contains("agent_llm_calls_used = agent_llm_calls_used + 3")); + assertTrue(update.getValue().getSqlSet().contains("eval_llm_calls_used = eval_llm_calls_used + 1")); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/interop/a2a/A2aJsonRpcControllerTest.java b/mateclaw-server/src/test/java/vip/mate/interop/a2a/A2aJsonRpcControllerTest.java new file mode 100644 index 00000000..8a83efdf --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/interop/a2a/A2aJsonRpcControllerTest.java @@ -0,0 +1,142 @@ +package vip.mate.interop.a2a; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; +import java.util.Map; +import java.time.Duration; + +import static org.hamcrest.Matchers.not; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class A2aJsonRpcControllerTest { + + private MockMvc mvc; + private A2aExecutionBridge bridge; + + @BeforeEach + void setUp() { + ObjectMapper objectMapper = new ObjectMapper(); + bridge = mock(A2aExecutionBridge.class); + A2aTaskStore store = new A2aTaskStore(100, Duration.ofMinutes(5)); + A2aProperties properties = new A2aProperties(); + properties.setEnabled(true); + properties.setCallTimeoutMs(1000); + A2aJsonRpcController rpc = new A2aJsonRpcController(objectMapper, properties, store, bridge); + A2aAgentCardService cardService = mock(A2aAgentCardService.class); + when(cardService.publicCard(any())).thenReturn(Map.of( + "name", "MateClaw", + "supportsAuthenticatedExtendedCard", true)); + when(cardService.authenticatedCard(any(), any())).thenReturn(Map.of( + "name", "MateClaw", + "skills", List.of(Map.of("id", "agent-1", "name", "Agent")))); + A2aAgentCardController card = new A2aAgentCardController(cardService); + mvc = MockMvcBuilders.standaloneSetup(rpc, card).build(); + } + + @Test + void rejectsBooleanJsonRpcId() throws Exception { + mvc.perform(post("/api/a2a") + .contentType("application/json") + .principal(auth()) + .content(""" + {"jsonrpc":"2.0","id":true,"method":"tasks/get","params":{"id":"task-1"}} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.error.code").value(-32600)); + } + + @Test + void anonymousCardOmitsSkillsAndAdvertisesExtendedCard() throws Exception { + mvc.perform(get("/api/a2a/card")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.supportsAuthenticatedExtendedCard").value(true)) + .andExpect(jsonPath("$.skills").doesNotExist()); + } + + @Test + void authenticatedCardIncludesSkills() throws Exception { + mvc.perform(get("/api/a2a/card").principal(auth())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.skills[0].id").value("agent-1")); + } + + @Test + void sendCreatesTaskAndDuplicateRpcIdReturnsSameSnapshot() throws Exception { + when(bridge.executeBlocking(any())).thenReturn(new A2aExecutionBridge.ExecutionResult("hello", true)); + String body = """ + {"jsonrpc":"2.0","id":"rpc-1","method":"message/send","params":{ + "message":{"messageId":"m1","taskId":"task-1","parts":[{"kind":"text","text":"hi"}], + "metadata":{"skillId":"1"}}, + "configuration":{"blocking":true} + }} + """; + + mvc.perform(post("/api/a2a").contentType("application/json").principal(auth()).content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.id").value("task-1")) + .andExpect(jsonPath("$.result.status.state").value("completed")); + + mvc.perform(post("/api/a2a").contentType("application/json").principal(auth()).content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.id").value("task-1")) + .andExpect(jsonPath("$.result.status.state").value("completed")); + } + + @Test + void duplicateTaskIdWithDifferentRpcIdIsRejected() throws Exception { + when(bridge.executeBlocking(any())).thenReturn(new A2aExecutionBridge.ExecutionResult("hello", true)); + String first = """ + {"jsonrpc":"2.0","id":"rpc-1","method":"message/send","params":{ + "message":{"messageId":"m1","taskId":"task-1","parts":[{"kind":"text","text":"hi"}], + "metadata":{"skillId":"1"}} + }} + """; + String second = first.replace("\"rpc-1\"", "\"rpc-2\""); + + mvc.perform(post("/api/a2a").contentType("application/json").principal(auth()).content(first)) + .andExpect(status().isOk()); + mvc.perform(post("/api/a2a").contentType("application/json").principal(auth()).content(second)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.error.code").value(-32009)); + } + + @Test + void cancelTransitionsActiveTaskToCanceled() throws Exception { + when(bridge.executeBlocking(any())).thenReturn(new A2aExecutionBridge.ExecutionResult("hello", false)); + String send = """ + {"jsonrpc":"2.0","id":"rpc-1","method":"message/send","params":{ + "message":{"messageId":"m1","taskId":"task-1","parts":[{"kind":"text","text":"hi"}], + "metadata":{"skillId":"1"}}, + "configuration":{"blocking":true} + }} + """; + mvc.perform(post("/api/a2a").contentType("application/json").principal(auth()).content(send)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.status.state", not("completed"))); + + mvc.perform(post("/api/a2a").contentType("application/json").principal(auth()) + .content(""" + {"jsonrpc":"2.0","id":"rpc-2","method":"tasks/cancel","params":{"id":"task-1"}} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.status.state").value("canceled")); + } + + private static UsernamePasswordAuthenticationToken auth() { + UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken("alice", null, List.of()); + token.setDetails(1L); + return token; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/interop/a2a/A2aPeerAdapterTest.java b/mateclaw-server/src/test/java/vip/mate/interop/a2a/A2aPeerAdapterTest.java new file mode 100644 index 00000000..559dafcd --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/interop/a2a/A2aPeerAdapterTest.java @@ -0,0 +1,100 @@ +package vip.mate.interop.a2a; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class A2aPeerAdapterTest { + + private HttpServer server; + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(0); + } + } + + @Test + void blocksPrivateNetworkTargetsByDefault() { + A2aPeerAdapter adapter = new A2aPeerAdapter(new ObjectMapper(), A2aPeerAdapter.Policy.defaults()); + + assertThrows(IllegalArgumentException.class, () -> + adapter.sendBlocking("http://127.0.0.1:8642/api/a2a", "hi", null, null, Map.of())); + } + + @Test + void refusesRedirects() throws Exception { + int port = startServer(exchange -> { + exchange.getResponseHeaders().add("Location", "https://example.com/api/a2a"); + exchange.sendResponseHeaders(302, -1); + exchange.close(); + }); + A2aPeerAdapter adapter = new A2aPeerAdapter(new ObjectMapper(), + new A2aPeerAdapter.Policy(Duration.ofSeconds(2), 1024 * 1024, true)); + + assertThrows(IOException.class, () -> + adapter.sendBlocking("http://127.0.0.1:" + port + "/api/a2a", "hi", null, null, Map.of())); + } + + @Test + void parsesSseFramesUsingEventBoundaries() throws Exception { + int port = startServer(exchange -> { + byte[] body = """ + event: artifact-update + data: hello + data: world + + """.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "text/event-stream"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + A2aPeerAdapter adapter = new A2aPeerAdapter(new ObjectMapper(), + new A2aPeerAdapter.Policy(Duration.ofSeconds(2), 1024 * 1024, true)); + + A2aPeerAdapter.PeerResult result = adapter.stream( + "http://127.0.0.1:" + port + "/api/a2a", "hi", null, null, Map.of()); + + assertEquals(1, result.frames().size()); + assertEquals("hello\nworld", result.frames().getFirst().data()); + } + + @Test + void truncatesOversizedResponses() throws Exception { + int port = startServer(exchange -> { + byte[] body = "0123456789".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + A2aPeerAdapter adapter = new A2aPeerAdapter(new ObjectMapper(), + new A2aPeerAdapter.Policy(Duration.ofSeconds(2), 5, true)); + + A2aPeerAdapter.PeerResult result = adapter.sendBlocking( + "http://127.0.0.1:" + port + "/api/a2a", "hi", null, null, Map.of()); + + assertTrue(result.truncated()); + assertEquals(5, result.body().length()); + } + + private int startServer(HttpHandler handler) throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/api/a2a", handler); + server.start(); + return server.getAddress().getPort(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/interop/a2a/A2aTaskStoreTest.java b/mateclaw-server/src/test/java/vip/mate/interop/a2a/A2aTaskStoreTest.java new file mode 100644 index 00000000..3a3ea788 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/interop/a2a/A2aTaskStoreTest.java @@ -0,0 +1,81 @@ +package vip.mate.interop.a2a; + +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class A2aTaskStoreTest { + + @Test + void putIfAbsentRejectsDuplicateTaskIdInSameTenant() { + MutableClock clock = new MutableClock(); + A2aTaskStore store = new A2aTaskStore(10, Duration.ofMinutes(5), clock); + A2aTask first = A2aTask.submitted("task-1", "ctx-1", "tenant-a"); + A2aTask duplicate = A2aTask.submitted("task-1", "ctx-2", "tenant-a"); + + assertTrue(store.putIfAbsent("tenant-a", first)); + assertFalse(store.putIfAbsent("tenant-a", duplicate)); + + assertEquals("ctx-1", store.get("tenant-a", "task-1").orElseThrow().contextId()); + } + + @Test + void duplicateJsonRpcIdReturnsStoredSnapshot() { + MutableClock clock = new MutableClock(); + A2aTaskStore store = new A2aTaskStore(10, Duration.ofMinutes(5), clock); + Map snapshot = Map.of("taskId", "task-1", "status", "submitted"); + + assertTrue(store.rememberRpcSnapshot("tenant-a", "rpc-1", snapshot)); + assertFalse(store.rememberRpcSnapshot("tenant-a", "rpc-1", Map.of("taskId", "other"))); + + assertEquals(snapshot, store.rpcSnapshot("tenant-a", "rpc-1").orElseThrow()); + } + + @Test + void sweepRemovesExpiredTerminalTasksAndKeepsActiveTasks() { + MutableClock clock = new MutableClock(); + A2aTaskStore store = new A2aTaskStore(10, Duration.ofSeconds(30), clock); + A2aTask done = A2aTask.submitted("done", "ctx", "tenant").withStatus("completed", "ok", true); + A2aTask active = A2aTask.submitted("active", "ctx", "tenant").withStatus("working", null, false); + + assertTrue(store.putIfAbsent("tenant", done)); + assertTrue(store.putIfAbsent("tenant", active)); + clock.advance(Duration.ofSeconds(31)); + + assertEquals(1, store.sweepExpired()); + assertTrue(store.get("tenant", "done").isEmpty()); + assertTrue(store.get("tenant", "active").isPresent()); + } + + private static final class MutableClock extends Clock { + private Instant now = Instant.parse("2026-08-19T00:00:00Z"); + + void advance(Duration duration) { + now = now.plus(duration); + } + + @Override + public ZoneOffset getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now; + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/interop/a2a/SseFramesTest.java b/mateclaw-server/src/test/java/vip/mate/interop/a2a/SseFramesTest.java new file mode 100644 index 00000000..47722dc1 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/interop/a2a/SseFramesTest.java @@ -0,0 +1,60 @@ +package vip.mate.interop.a2a; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SseFramesTest { + + @Test + void parsesSingleDataFrameAtBlankLine() { + List frames = SseFrames.parse("event: artifact-update\ndata: {\"x\":1}\n\n"); + + assertEquals(1, frames.size()); + assertEquals("artifact-update", frames.getFirst().event()); + assertEquals("{\"x\":1}", frames.getFirst().data()); + } + + @Test + void joinsMultiLineDataWithNewlines() { + List frames = SseFrames.parse("event: message\ndata: first\ndata: second\n\n"); + + assertEquals("first\nsecond", frames.getFirst().data()); + } + + @Test + void ignoresCommentHeartbeatFrames() { + List frames = SseFrames.parse(": heartbeat\n\n"); + + assertTrue(frames.isEmpty()); + } + + @Test + void flushesTrailingFrameWithoutFinalBlankLine() { + List frames = SseFrames.parse("data: tail"); + + assertEquals(1, frames.size()); + assertEquals("message", frames.getFirst().event()); + assertEquals("tail", frames.getFirst().data()); + } + + @Test + void preservesEventBoundaryAcrossMultipleFrames() { + List frames = SseFrames.parse(""" + event: status-update + data: working + + event: artifact-update + data: one + data: two + + """); + + assertEquals(2, frames.size()); + assertEquals("working", frames.get(0).data()); + assertEquals("one\ntwo", frames.get(1).data()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceEnableTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceEnableTest.java index f6de6abf..28b4cf1d 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceEnableTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceEnableTest.java @@ -14,6 +14,7 @@ import vip.mate.llm.failover.AvailableProviderPool; import vip.mate.llm.failover.ProviderHealthProperties; import vip.mate.llm.failover.ProviderHealthTracker; import vip.mate.llm.failover.ProviderInitProbe; +import vip.mate.llm.model.CreateCustomProviderRequest; import vip.mate.llm.model.EnableResult; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.model.ModelProviderEntity; @@ -112,6 +113,36 @@ class ModelProviderServiceEnableTest { verify(eventPublisher, never()).publishEvent(any()); } + @Test + @DisplayName("createCustomProvider with an existing disabled custom row re-enables and updates it") + void createCustomProviderReEnablesDisabledCustomProvider() { + ModelProviderEntity existing = providerEntity("custom-openai", false); + existing.setIsCustom(true); + existing.setName("Old name"); + when(providerMapper.selectById("custom-openai")).thenReturn(existing); + when(modelConfigService.listModelsByProvider("custom-openai")).thenReturn(new ArrayList<>()); + + CreateCustomProviderRequest request = new CreateCustomProviderRequest(); + request.setId("custom-openai"); + request.setName("New name"); + request.setDefaultBaseUrl("https://new.example.com/v1"); + request.setApiKeyPrefix("mk-"); + request.setProtocol("openai-compatible"); + request.setRequireApiKey(true); + + service.createCustomProvider(request); + + assertTrue(existing.getEnabled(), "disabled custom provider should be visible again"); + assertEquals("New name", existing.getName()); + assertEquals("https://new.example.com/v1", existing.getBaseUrl()); + assertEquals("mk-", existing.getApiKeyPrefix()); + verify(providerMapper).updateById(existing); + verify(providerMapper, never()).insert(any(ModelProviderEntity.class)); + ArgumentCaptor evtCap = ArgumentCaptor.forClass(ModelConfigChangedEvent.class); + verify(eventPublisher).publishEvent(evtCap.capture()); + assertEquals("provider-enabled", evtCap.getValue().reason()); + } + @Test @DisplayName("setEnabled(false) when provider's model is current default: auto-switches and reports new") void disableSwitchesDefault() { diff --git a/mateclaw-server/src/test/java/vip/mate/skill/usage/SkillUsageServiceActivityBubbleTest.java b/mateclaw-server/src/test/java/vip/mate/skill/usage/SkillUsageServiceActivityBubbleTest.java index 0e94e902..e6b97a32 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/usage/SkillUsageServiceActivityBubbleTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/usage/SkillUsageServiceActivityBubbleTest.java @@ -9,12 +9,15 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DuplicateKeyException; import vip.mate.skill.lifecycle.SkillLifecycleService; import vip.mate.skill.repository.SkillUsageStatMapper; import vip.mate.skill.runtime.model.ResolvedSkill; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -48,7 +51,6 @@ class SkillUsageServiceActivityBubbleTest { @Test void recordLoadedBubblesActivityToLifecycle() { ResolvedSkill skill = ResolvedSkill.builder().id(7L).name("demo").build(); - when(mapper.selectOne(any())).thenReturn(null); service.recordLoaded(skill, 1L, "conv-1", "SKILL.md", 100); @@ -60,4 +62,17 @@ class SkillUsageServiceActivityBubbleTest { service.recordLoaded(null, 1L, "conv-1", "SKILL.md", 100); verify(lifecycleService, never()).bumpActivity(any()); } + + @Test + void recordLoadedRetriesAtomicUpdateWhenParallelInsertWins() { + ResolvedSkill skill = ResolvedSkill.builder().id(7L).name("demo").build(); + when(mapper.update(any(), any())).thenReturn(0, 1); + doThrow(new DuplicateKeyException("parallel insert")) + .when(mapper).insert(any(SkillUsageStatEntity.class)); + + service.recordLoaded(skill, 1L, "conv-1", "SKILL.md", 100); + + verify(mapper, times(2)).update(any(), any()); + verify(lifecycleService).bumpActivity(7L); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java index c20b313c..35c71f2e 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java @@ -10,6 +10,7 @@ import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskCommentEntity; import vip.mate.team.model.TeamTaskStatus; import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageEntity; import java.util.List; @@ -19,6 +20,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.ArgumentCaptor.forClass; import static org.mockito.Mockito.*; /** @@ -173,6 +175,26 @@ class TeamDispatchServiceTest { verify(eventChannel).publishTaskEvent(any(), eq("team_task_retrying"), any()); } + @Test + @DisplayName("a clarifying question is requeued instead of being reported as completed") + void settleClarifyingQuestionRequeues() { + TeamTaskEntity running = task(1L, MEMBER_A); + running.setStatus(TeamTaskStatus.IN_PROGRESS); + running.setDispatchCount(1); + when(taskService.getTask(1L)).thenReturn(running); + when(taskService.requeueUnusableResult(1L, + "member asked for clarification instead of producing a result")) + .thenReturn(true); + + service.settleOutcome(running, "您希望我如何处理?"); + + verify(taskService).requeueUnusableResult(1L, + "member asked for clarification instead of producing a result"); + verify(taskService, never()).completeTask(any(), any(), anyString()); + verify(announceService, never()).announceTaskSettled(any()); + verify(eventChannel).publishTaskEvent(any(), eq("team_task_retrying"), any()); + } + @Test @DisplayName("an unusable third result fails instead of bypassing the circuit breaker") void settleFallbackFailsAfterDispatchBudget() { @@ -193,6 +215,40 @@ class TeamDispatchServiceTest { verify(announceService).announceTaskSettled(failed); } + @Test + @DisplayName("a second empty member result fails without starting a third long run") + void settleEmptyResultFailsAfterSingleRecoveryAttempt() { + TeamTaskEntity running = task(1L, MEMBER_A); + running.setStatus(TeamTaskStatus.IN_PROGRESS); + running.setDispatchCount(TeamDispatchService.MAX_RESPONSE_FAILURE_DISPATCHES); + TeamTaskEntity failed = task(1L, MEMBER_A); + failed.setStatus(TeamTaskStatus.FAILED); + failed.setReason("member produced no result"); + when(taskService.getTask(1L)).thenReturn(running, failed); + when(taskService.failTask(1L, "member produced no result")).thenReturn(true); + + service.settleOutcome(running, ""); + + verify(taskService, never()).requeueUnusableResult(any(), anyString()); + verify(taskService).failTask(1L, "member produced no result"); + verify(eventChannel).publishTaskEvent(any(), eq("team_task_failed"), any()); + verify(announceService).announceTaskSettled(failed); + } + + @Test + @DisplayName("automatic retry tells the worker why its previous attempt was rejected") + void retryDispatchContentIncludesPreviousFailure() { + TeamTaskEntity retry = task(1L, MEMBER_A); + retry.setDispatchCount(2); + retry.setReason("member produced no result"); + + String content = service.buildDispatchContent(retry); + + assertTrue(content.contains("[Retry feedback]")); + assertTrue(content.contains("member produced no result")); + assertTrue(content.contains("do not repeat the same empty or fallback response")); + } + @Test @DisplayName("a declared deliverable task without an attachment is requeued") void settleMissingDeliverableRequeues() { @@ -211,6 +267,28 @@ class TeamDispatchServiceTest { verify(taskService, never()).completeTask(any(), any(), anyString()); } + @Test + @DisplayName("a returnDirect generated-file link satisfies a declared deliverable task") + void settleGeneratedFileLinkAttachesDeliverable() { + TeamTaskEntity running = task(1L, MEMBER_A); + running.setStatus(TeamTaskStatus.IN_PROGRESS); + running.setMetadata("{\"deliverableRequired\":true}"); + TeamTaskEntity completed = task(1L, MEMBER_A); + completed.setStatus(TeamTaskStatus.COMPLETED); + when(taskService.getTask(1L)).thenReturn(running, completed); + when(taskService.listDeliverables(running)).thenReturn(List.of()); + when(taskService.completeTask(eq(1L), isNull(), anyString())).thenReturn(List.of()); + + String reply = "文档已生成:[report.docx](/api/v1/files/generated/file-123)(链接 7 天内有效)。"; + service.settleOutcome(running, reply); + + verify(taskService).addDeliverable(1L, MEMBER_A, "report.docx", + "/api/v1/files/generated/file-123"); + verify(taskService, never()).requeueUnusableResult(any(), anyString()); + verify(taskService).completeTask(1L, null, reply); + verify(announceService).announceTaskSettled(completed); + } + @Test @DisplayName("a long-running checkpoint tracker stays active until its terminal round") void settleParksCheckpointTracker() { @@ -315,6 +393,35 @@ class TeamDispatchServiceTest { eq("team_worker")); } + @Test + @DisplayName("member dispatch envelope carries lead conversation upload paths") + void runTaskCarriesLeadConversationUploadPaths() { + TeamTaskEntity assigned = task(1L, MEMBER_A); + assigned.setDescription("请根据刚上传的需求.docx拆解任务"); + assigned.setStatus(TeamTaskStatus.IN_PROGRESS); + TeamTaskEntity done = task(1L, MEMBER_A); + done.setStatus(TeamTaskStatus.COMPLETED); + MessageEntity uploadTurn = new MessageEntity(); + uploadTurn.setRole("user"); + uploadTurn.setContentParts("[{\"type\":\"file\"}]"); + when(conversationService.listRecentMessages("lead-conv", TeamDispatchService.LEAD_ATTACHMENT_CONTEXT_MESSAGES)) + .thenReturn(List.of(uploadTurn)); + when(conversationService.renderMessageContent(uploadTurn, true)) + .thenReturn("请看附件\n[附件] 需求.docx(路径: /workspace/uploads/lead-conv/需求.docx)"); + when(taskService.getTask(1L)).thenReturn(assigned, done, done); + when(taskService.completeTask(eq(1L), isNull(), anyString())).thenReturn(List.of()); + when(agentService.chatWithUsage(eq(MEMBER_A), anyString(), anyString())) + .thenReturn(AgentService.ChatResult.contentOnly("all done")); + + service.runTask(TEAM_ID, assigned); + + var prompt = forClass(String.class); + verify(agentService).chatWithUsage(eq(MEMBER_A), prompt.capture(), startsWith("team-task-")); + assertTrue(prompt.getValue().contains("[Lead conversation attachments]")); + assertTrue(prompt.getValue().contains("需求.docx")); + assertTrue(prompt.getValue().contains("/workspace/uploads/lead-conv/需求.docx")); + } + @Test @DisplayName("an interrupted run whose task was cancelled produces no failed event") void interruptedCancelledRunStaysSilent() { diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunViewFactoryTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunViewFactoryTest.java index b8600c0a..7f6ed5df 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunViewFactoryTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamRunViewFactoryTest.java @@ -69,6 +69,18 @@ class TeamRunViewFactoryTest { assertEquals(full.outcomeQuality(), summary.outcomeQuality()); } + @Test + void fallbackSummaryDoesNotCreateUnactionableAttentionItem() { + TeamRunEntity fallbackRun = run("{\"summaryQuality\":\"fallback\"}"); + TeamTaskEntity completed = task(101L, 201L, null); + + TeamRunView view = TeamRunViewFactory.create(fallbackRun, TeamRunStatus.COMPLETED, + new TeamRunView.Progress(1, 1, 0, 0, 100), List.of(completed), true); + + assertEquals("fallback", view.outcomeQuality()); + assertEquals(List.of(), view.attentionItems()); + } + @Test void aggregatesRunOnlyDeliverables() { TeamRunEntity run = run("{\"deliverables\":[{\"name\":\"report.pdf\"," diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java index 5eb17afe..a8839b16 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java @@ -542,6 +542,22 @@ class TeamTaskServiceTest { assertEquals("/api/v1/files/generated/abc", files.get(0).url()); } + @Test + @DisplayName("addDeliverable is idempotent for the same generated URL") + void addDeliverableIgnoresDuplicateUrl() { + TeamTaskEntity running = task(5L, TeamTaskStatus.IN_PROGRESS); + running.setOwnerAgentId(MEMBER_ID); + running.setMetadata("{\"deliverables\":[{\"name\":\"report.docx\"," + + "\"url\":\"/api/v1/files/generated/abc\"}]}"); + when(taskMapper.selectById(5L)).thenReturn(running); + + service.addDeliverable(5L, MEMBER_ID, "report-again.docx", + "/api/v1/files/generated/abc"); + + verify(taskMapper, never()).update(isNull(), any()); + verify(eventMapper, never()).insert(any(TeamTaskEventEntity.class)); + } + @Test @DisplayName("deliverable guards: external URL, non-owner, terminal task and overflow are rejected") void addDeliverableGuards() { diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamWorkerConversationGovernanceServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamWorkerConversationGovernanceServiceTest.java index 7ef5d688..59741d30 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamWorkerConversationGovernanceServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamWorkerConversationGovernanceServiceTest.java @@ -4,10 +4,13 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.service.AuthService; import vip.mate.team.model.TeamRunEntity; import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.repository.TeamRunMapper; import vip.mate.team.repository.TeamTaskMapper; +import vip.mate.workspace.core.service.WorkspaceService; import vip.mate.workspace.conversation.model.ConversationEntity; import vip.mate.workspace.conversation.repository.ConversationMapper; @@ -21,6 +24,8 @@ class TeamWorkerConversationGovernanceServiceTest { @Mock private TeamTaskMapper taskMapper; @Mock private TeamRunMapper runMapper; @Mock private ConversationMapper conversationMapper; + @Mock private AuthService authService; + @Mock private WorkspaceService workspaceService; @Test void returnsVerifiedCanonicalContextOnlyWhenRequestedLinkageMatches() { @@ -102,8 +107,39 @@ class TeamWorkerConversationGovernanceServiceTest { assertThat(service().resolve("team-task-legacy-no-parent", 77L, 501L)).isPresent(); } + @Test + void allowsWorkspaceAdminToReadVerifiedWorkerTranscript() { + TeamTaskEntity task = task(501L, 77L, "worker-conversation"); + when(conversationMapper.selectOne(any())).thenReturn(conversation( + "worker-conversation", 30L, 41L, "lead-conversation", "team_worker")); + when(taskMapper.selectOne(any())).thenReturn(task); + when(runMapper.selectById(77L)).thenReturn(run(77L, 20L, "lead-conversation")); + when(authService.findByUsername("workspace-admin")).thenReturn(user(900L, "user")); + when(workspaceService.hasPermissionCached(30L, 900L, "viewer")).thenReturn(true); + + assertThat(service().canReadTranscript("worker-conversation", 77L, 501L, + "workspace-admin")).isTrue(); + } + + @Test + void rejectsWorkerTranscriptReadWhenRouteLinkageOrWorkspaceMembershipDoesNotMatch() { + TeamTaskEntity task = task(501L, 77L, "worker-conversation"); + when(conversationMapper.selectOne(any())).thenReturn(conversation( + "worker-conversation", 30L, 41L, "lead-conversation", "team_worker")); + when(taskMapper.selectOne(any())).thenReturn(task); + when(runMapper.selectById(77L)).thenReturn(run(77L, 20L, "lead-conversation")); + when(authService.findByUsername("outsider")).thenReturn(user(901L, "user")); + when(workspaceService.hasPermissionCached(30L, 901L, "viewer")).thenReturn(false); + + assertThat(service().canReadTranscript("worker-conversation", 88L, 501L, + "outsider")).isFalse(); + assertThat(service().canReadTranscript("worker-conversation", 77L, 501L, + "outsider")).isFalse(); + } + private TeamWorkerConversationGovernanceService service() { - return new TeamWorkerConversationGovernanceService(taskMapper, runMapper, conversationMapper); + return new TeamWorkerConversationGovernanceService(taskMapper, runMapper, conversationMapper, + authService, workspaceService); } private static TeamTaskEntity task(Long id, Long runId, String conversationId) { @@ -135,4 +171,11 @@ class TeamWorkerConversationGovernanceServiceTest { conversation.setConversationKind(kind); return conversation; } + + private static UserEntity user(long id, String role) { + UserEntity user = new UserEntity(); + user.setId(id); + user.setRole(role); + return user; + } } diff --git a/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java b/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java index ff6395f4..30d8d1eb 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java @@ -295,6 +295,24 @@ class TeamTasksToolTest { assertTrue(captor.getValue().isRequireApproval()); } + @Test + @DisplayName("file-producing create task records a required deliverable contract") + void createInfersRequiredDeliverableFromDescription() { + callerIs(LEAD_ID); + when(runService.requireRun(RUN_ID, WORKSPACE_ID)) + .thenReturn(run(TEAM_ID, CONV, TeamRunStatus.PLANNING)); + when(taskService.createTask(any())).thenReturn(task(53L, TeamTaskStatus.PENDING)); + + tool.team_tasks("create", null, String.valueOf(RUN_ID), null, null, + "生成报告", "请生成最终 DOCX 文件", String.valueOf(MEMBER_ID), null, null, + null, null, null, null, null, null, null, null, null); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(TeamTaskCreateCommand.class); + verify(taskService).createTask(captor.capture()); + assertTrue(captor.getValue().getMetadata().contains("\"deliverableRequired\":true")); + } + @Test @DisplayName("create requires an explicit run id") void createRequiresRunId() { diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java index 39fc2adf..1fdef1d7 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java @@ -78,6 +78,7 @@ class DelegateAgentToolDenyListTest { // Memory writers (canonical Spring AI tool method names — do not include // any speculative names that would silently no-op). assertThat(defaults).contains("remember", "remember_structured", "forget_structured"); + assertThat(defaults).contains("waitForGoalInput"); // Shell stays out by design — see comment on DEFAULT_CHILD_DENIED_TOOLS. assertThat(defaults).doesNotContain("execute_shell_command"); } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DocxRenderToolReturnDirectTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DocxRenderToolReturnDirectTest.java new file mode 100644 index 00000000..b60e44e9 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DocxRenderToolReturnDirectTest.java @@ -0,0 +1,37 @@ +package vip.mate.tool.builtin; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.chat.model.ToolContext; +import vip.mate.tool.ToolInputValidationException; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DocxRenderToolReturnDirectTest { + + @Test + void docxRenderToolsReturnGeneratedFileDirectly() throws Exception { + assertReturnDirect("renderDocx", String.class, String.class, String.class, ToolContext.class); + assertReturnDirect("renderDocxFromFile", String.class, String.class, String.class, ToolContext.class); + assertReturnDirect("renderDocxFromFiles", java.util.List.class, String.class, String.class, ToolContext.class); + } + + @Test + void blankMarkdownIsARecoverableInputFailureNotADirectSuccess() { + DocxRenderTool tool = new DocxRenderTool(null, null); + + ToolInputValidationException error = assertThrows(ToolInputValidationException.class, + () -> tool.renderDocx(" ", "report", "A4", null)); + + assertTrue(error.getMessage().contains("markdown must not be blank")); + } + + private static void assertReturnDirect(String methodName, Class... parameterTypes) throws Exception { + Tool tool = DocxRenderTool.class + .getMethod(methodName, parameterTypes) + .getAnnotation(Tool.class); + + assertTrue(tool.returnDirect(), methodName + " must stop the tool loop after producing a download link"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/FileMutationToolContextTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/FileMutationToolContextTest.java new file mode 100644 index 00000000..e52bb2a4 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/FileMutationToolContextTest.java @@ -0,0 +1,75 @@ +package vip.mate.tool.builtin; + +import cn.hutool.json.JSONUtil; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.i18n.I18nService; +import vip.mate.tool.guard.WorkspacePathGuard; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Regression coverage for issue #617: file mutation tools must use the + * explicit Spring AI ToolContext instead of relying on legacy thread-local + * workspace state. + */ +class FileMutationToolContextTest { + + @AfterEach + void tearDown() { + WorkspacePathGuard.setDefaultRoot(null); + ToolExecutionContext.clear(); + } + + @Test + @DisplayName("write_file resolves relative paths against ToolContext workspace root (#617)") + void writeFileUsesToolContextWorkspaceRoot(@TempDir Path tempDir) throws Exception { + Path defaultRoot = Files.createDirectory(tempDir.resolve("default-root")); + Path contextRoot = Files.createDirectory(tempDir.resolve("context-root")); + WorkspacePathGuard.setDefaultRoot(defaultRoot.toString()); + WriteFileTool tool = new WriteFileTool(i18n()); + + String result = tool.write_file( + "deck.md", + "# Deck", + ChatOrigin.web("conv-617", "alice", 1L, contextRoot.toString()).toToolContext()); + + assertThat(JSONUtil.parseObj(result).getBool("error", false)).isFalse(); + assertThat(contextRoot.resolve("deck.md")).hasContent("# Deck"); + assertThat(defaultRoot.resolve("deck.md")).doesNotExist(); + } + + @Test + @DisplayName("edit_file resolves relative paths against ToolContext workspace root (#617)") + void editFileUsesToolContextWorkspaceRoot(@TempDir Path tempDir) throws Exception { + Path defaultRoot = Files.createDirectory(tempDir.resolve("default-root")); + Path contextRoot = Files.createDirectory(tempDir.resolve("context-root")); + WorkspacePathGuard.setDefaultRoot(defaultRoot.toString()); + Files.writeString(contextRoot.resolve("deck.md"), "old title", StandardCharsets.UTF_8); + EditFileTool tool = new EditFileTool(i18n()); + + String result = tool.edit_file( + "deck.md", + "old", + "new", + false, + ChatOrigin.web("conv-617", "alice", 1L, contextRoot.toString()).toToolContext()); + + assertThat(JSONUtil.parseObj(result).getBool("error", false)).isFalse(); + assertThat(contextRoot.resolve("deck.md")).hasContent("new title"); + assertThat(defaultRoot.resolve("deck.md")).doesNotExist(); + } + + private static I18nService i18n() { + return mock(I18nService.class, inv -> + "msg".equals(inv.getMethod().getName()) ? inv.getArgument(0) : null); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java index d98653d6..0e9dcf20 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java @@ -178,4 +178,57 @@ class GoalManagementToolTest { // total = agent(12) + eval(2) assertTrue(result.contains("\"totalLlmCallsUsed\":14")); } + @Test + void waitForGoalInputRequiresReasonAndBoundContext() { + ToolContext ctx = ctxWith("conv-1", 10L, "alice"); + assertTrue(tool.waitForGoalInput(" ", ctx).contains("reason")); + assertTrue(tool.waitForGoalInput("Need hostname", null).contains("bound conversation")); + verify(goalService, never()).findActiveByConversation(anyString()); + verify(goalService, never()).waitForInput(any(), anyString(), anyString()); + } + + @Test + void waitForGoalInputRequiresEnabledPersistentActiveGoal() { + ToolContext ctx = ctxWith("conv-1", 10L, "alice"); + properties.setEnabled(false); + assertTrue(tool.waitForGoalInput("Need hostname", ctx).contains("disabled")); + verify(goalService, never()).findActiveByConversation(anyString()); + properties.setEnabled(true); + GoalEntity goal = goal(GoalStatus.ACTIVE); + when(goalService.findActiveByConversation("conv-1")).thenReturn(goal); + assertTrue(tool.waitForGoalInput("Need hostname", ctx).contains("persistent")); + goal.setPersistentExecution(true); + goal.setStatus(GoalStatus.PAUSED); + assertTrue(tool.waitForGoalInput("Need hostname", ctx).contains("active goal")); + verify(goalService, never()).waitForInput(any(), anyString(), anyString()); + } + + @Test + void waitForGoalInputPausesBoundGoal_andBroadcastsUpdatedState() { + GoalEntity active = goal(GoalStatus.ACTIVE); + active.setPersistentExecution(true); + GoalEntity paused = goal(GoalStatus.PAUSED); + paused.setPersistentExecution(true); + paused.setProgressSummary("Waiting for input: Need the deployment hostname"); + when(goalService.findActiveByConversation("conv-1")).thenReturn(active); + when(goalService.waitForInput(123L, "Need the deployment hostname", "alice")).thenReturn(paused); + when(goalService.toResponse(paused)).thenReturn(new vip.mate.goal.model.GoalResponse()); + String result = tool.waitForGoalInput(" Need the deployment hostname ", ctxWith("conv-1", 10L, "alice")); + assertTrue(result.contains("\"status\":\"paused\"")); + assertTrue(result.contains("Need the deployment hostname")); + verify(streamTracker).broadcastObject(eq("conv-1"), eq("goal_updated"), any()); + verify(goalService, never()).markCompleted(any(), any()); + } + + @Test + void waitForGoalInputReturnsErrorIfStateChangedBeforePause() { + GoalEntity active = goal(GoalStatus.ACTIVE); + active.setPersistentExecution(true); + when(goalService.findActiveByConversation("conv-1")).thenReturn(active); + when(goalService.waitForInput(123L, "Need hostname", "alice")) + .thenThrow(new MateClawException("err.goal.bad_transition", 409, "Goal no longer active")); + assertTrue(tool.waitForGoalInput("Need hostname", ctxWith("conv-1", 10L, "alice")).contains("Goal no longer active")); + verify(streamTracker, never()).broadcastObject(anyString(), anyString(), any()); + } + } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCachePersistenceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCachePersistenceTest.java index f929f1d9..73085d24 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCachePersistenceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCachePersistenceTest.java @@ -38,6 +38,26 @@ class GeneratedFileCachePersistenceTest { entry.mimeType()); } + @Test + @DisplayName("workspace ownership metadata persists and gates lookup") + void ownershipPersistsAndGatesLookup(@TempDir Path dir) { + GeneratedFileCache first = new GeneratedFileCache(dir); + byte[] bytes = "workspace-b".getBytes(StandardCharsets.UTF_8); + String id = first.put(bytes, "b.csv", "text/csv", + new GeneratedFileCache.Owner(20L, 30L, "conv-b")); + + GeneratedFileCache afterRestart = new GeneratedFileCache(dir); + GeneratedFileCache.Entry entry = afterRestart.get(id).orElse(null); + + assertNotNull(entry, "persisted entry must be reloaded from disk after restart"); + assertEquals(20L, entry.workspaceId()); + assertEquals(30L, entry.ownerUserId()); + assertEquals("conv-b", entry.conversationId()); + assertTrue(afterRestart.getForWorkspace(id, 20L).isPresent()); + assertTrue(afterRestart.getForWorkspace(id, 10L).isEmpty(), + "a file generated in workspace B must not resolve under workspace A"); + } + @Test @DisplayName("unknown id returns empty") void unknownIdEmpty(@TempDir Path dir) { diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileControllerTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileControllerTest.java new file mode 100644 index 00000000..89b326fc --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileControllerTest.java @@ -0,0 +1,64 @@ +package vip.mate.tool.document; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.TestingAuthenticationToken; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.service.AuthService; +import vip.mate.workspace.core.service.WorkspaceService; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class GeneratedFileControllerTest { + + @Test + @DisplayName("download is forbidden when current workspace does not match file workspace") + void forbiddenWhenWorkspaceDoesNotMatch(@TempDir Path dir) { + GeneratedFileCache cache = new GeneratedFileCache(dir); + String id = cache.put("secret".getBytes(StandardCharsets.UTF_8), "b.txt", "text/plain", + new GeneratedFileCache.Owner(20L, 30L, "conv-b")); + AuthService authService = mock(AuthService.class); + WorkspaceService workspaceService = mock(WorkspaceService.class); + when(authService.findByUsername("alice")).thenReturn(user(30L, "user")); + + GeneratedFileController controller = new GeneratedFileController(cache, authService, workspaceService); + ResponseEntity response = controller.download(id, 10L, + new TestingAuthenticationToken("alice", "pw")); + + assertEquals(403, response.getStatusCode().value()); + } + + @Test + @DisplayName("download succeeds when current workspace matches and user can view it") + void allowedWhenWorkspaceMatches(@TempDir Path dir) { + GeneratedFileCache cache = new GeneratedFileCache(dir); + String id = cache.put("ok".getBytes(StandardCharsets.UTF_8), "b.txt", "text/plain", + new GeneratedFileCache.Owner(20L, 30L, "conv-b")); + AuthService authService = mock(AuthService.class); + WorkspaceService workspaceService = mock(WorkspaceService.class); + when(authService.findByUsername("alice")).thenReturn(user(30L, "user")); + when(workspaceService.hasPermissionCached(20L, 30L, "viewer")).thenReturn(true); + + GeneratedFileController controller = new GeneratedFileController(cache, authService, workspaceService); + ResponseEntity response = controller.download(id, 20L, + new TestingAuthenticationToken("alice", "pw")); + + assertEquals(200, response.getStatusCode().value()); + } + + private static UserEntity user(Long id, String role) { + UserEntity user = new UserEntity(); + user.setId(id); + user.setUsername("alice"); + user.setRole(role); + user.setEnabled(true); + return user; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/WorkspaceArtifactSurfacerTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/WorkspaceArtifactSurfacerTest.java index 66934476..4eec1954 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/document/WorkspaceArtifactSurfacerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/document/WorkspaceArtifactSurfacerTest.java @@ -80,6 +80,22 @@ class WorkspaceArtifactSurfacerTest { } } + @Test + @DisplayName("Files modified before the run do not surface even when mtime is close to run start") + void ignoresFilesModifiedBeforeRunStart() throws Exception { + tmp = Files.createTempDirectory("artifacts-"); + cacheDir = Files.createTempDirectory("cache-"); + GeneratedFileCache cache = new GeneratedFileCache(cacheDir); + + long runStart = System.currentTimeMillis(); + Path foreign = Files.write(tmp.resolve("foreign.csv"), "tenant,secret\nb,1\n".getBytes()); + Files.setLastModifiedTime(foreign, FileTime.fromMillis(runStart - 500L)); + + List links = WorkspaceArtifactSurfacer.collect(cache, tmp, runStart, null); + + assertTrue(links.isEmpty(), "pre-existing files from another run/workspace must not surface: " + links); + } + @Test @DisplayName("Null / non-existent working dir and null cache are safe no-ops") void edgeCasesAreSafe() throws Exception { diff --git a/mateclaw-server/src/test/java/vip/mate/tool/mcp/service/McpServerServiceListToolsTest.java b/mateclaw-server/src/test/java/vip/mate/tool/mcp/service/McpServerServiceListToolsTest.java index 4a46e86e..2f0911cb 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/mcp/service/McpServerServiceListToolsTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/mcp/service/McpServerServiceListToolsTest.java @@ -7,6 +7,8 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.ContextClosedEvent; import vip.mate.exception.MateClawException; import vip.mate.tool.mcp.model.McpServerEntity; import vip.mate.tool.mcp.model.McpToolDescriptor; @@ -51,6 +53,9 @@ class McpServerServiceListToolsTest { @Mock private McpClientManager mcpClientManager; + @Mock + private ApplicationEventPublisher eventPublisher; + @InjectMocks private McpServerService service; @@ -135,4 +140,16 @@ class McpServerServiceListToolsTest { // the wire payload but the Java value is preserved through the mapping. assertTrue(result.get(0).description() == null); } + + @Test + @DisplayName("application shutdown ignores MCP process-exit reconnect events") + void shutdownDoesNotReconnectExitedStdioServer() { + service.onContextClosed(org.mockito.Mockito.mock(ContextClosedEvent.class)); + + service.onConnectionLost(new vip.mate.tool.mcp.event.McpConnectionLostEvent( + 7L, "stdio-process-exited")); + + verify(mcpServerMapper, never()).selectById(7L); + verify(mcpClientManager, never()).replace(org.mockito.ArgumentMatchers.any()); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/service/AvailableToolServiceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/service/AvailableToolServiceTest.java index db891774..f358b95f 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/service/AvailableToolServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/service/AvailableToolServiceTest.java @@ -3,6 +3,9 @@ package vip.mate.tool.service; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; +import vip.mate.tool.ToolRegistry; import vip.mate.tool.mcp.model.McpServerEntity; import vip.mate.tool.mcp.runtime.McpToolNameResolver; import vip.mate.tool.mcp.service.McpServerService; @@ -26,15 +29,18 @@ class AvailableToolServiceTest { private ToolService toolService; private McpServerService mcpServerService; + private ToolRegistry toolRegistry; private AvailableToolService service; @BeforeEach void setUp() { toolService = mock(ToolService.class); mcpServerService = mock(McpServerService.class); - service = new AvailableToolService(toolService, mcpServerService); + toolRegistry = mock(ToolRegistry.class); + service = new AvailableToolService(toolService, mcpServerService, toolRegistry); when(toolService.listEnabledTools()).thenReturn(List.of()); when(mcpServerService.listEnabled()).thenReturn(List.of()); + when(toolRegistry.listAvailablePluginTools()).thenReturn(List.of()); } @Test @@ -50,6 +56,27 @@ class AvailableToolServiceTest { assertEquals(Set.of("builtin", "mcp"), sources); } + @Test + @DisplayName("plugin-registered callbacks appear as bindable agent picker tools") + void includesPluginRegisteredCallbacks() { + ToolCallback pluginTool = pluginCallback("custom_invoice_lookup", "Look up invoices from a plugin"); + when(toolRegistry.listAvailablePluginTools()).thenReturn(List.of(pluginTool)); + + List out = service.listAvailable(); + + assertEquals(1, out.size()); + AvailableToolDTO dto = out.get(0); + assertEquals("plugin", dto.getSource()); + assertEquals("plugin#custom_invoice_lookup", dto.getRowId()); + assertEquals("custom_invoice_lookup", dto.getName()); + assertEquals("custom_invoice_lookup", dto.getRawName()); + assertEquals("Look up invoices from a plugin", dto.getDescription()); + assertEquals("Plugin tools", dto.getGroup()); + assertEquals("plugin", dto.getGroupId()); + assertTrue(dto.isAvailable()); + assertFalse(dto.isStale()); + } + @Test @DisplayName("MCP entry name equals McpToolNameResolver.prefixedName(serverId, raw)") void mcpNameMatchesResolver() { @@ -221,4 +248,14 @@ class AvailableToolServiceTest { s.setToolsCacheJson(cacheJson); return s; } + + private static ToolCallback pluginCallback(String name, String description) { + ToolCallback callback = mock(ToolCallback.class); + when(callback.getToolDefinition()).thenReturn(ToolDefinition.builder() + .name(name) + .description(description) + .inputSchema("{}") + .build()); + return callback; + } } diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceE2ETest.java b/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceE2ETest.java index 51a97209..4ff1903d 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceE2ETest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceE2ETest.java @@ -88,6 +88,17 @@ class WikiPageTypeProfileServiceE2ETest { } } + @Test + void reservedPageType_isRejectedOnSave() { + try { + service.saveProfile(SEQ.incrementAndGet(), "bad", + "{\"pageTypes\":{\"system\":{\"label\":\"System\"},\"concept\":{\"label\":\"Concept\"}}}"); + org.junit.jupiter.api.Assertions.fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("reserved pageType")); + } + } + @Test void validateProfileJson_reportsIssues() { assertTrue(service.validateProfileJson(EPISODE_JSON).isEmpty()); @@ -103,4 +114,15 @@ class WikiPageTypeProfileServiceE2ETest { "{\"pageTypes\":{\"x\":{\"schema\":{\"f\":{\"type\":\"banana\"}}}}}"); assertTrue(badType.stream().anyMatch(s -> s.contains("unknown field type"))); } + + @Test + void validateProfileJson_rejectsReservedPageTypesAndFallback() { + List reservedType = service.validateProfileJson( + "{\"pageTypes\":{\"system\":{\"label\":\"System\"},\"concept\":{\"label\":\"Concept\"}}}"); + assertTrue(reservedType.stream().anyMatch(s -> s.contains("reserved pageType"))); + + List reservedFallback = service.validateProfileJson( + "{\"fallbackType\":\"synthesis\",\"pageTypes\":{\"concept\":{\"label\":\"Concept\"}}}"); + assertTrue(reservedFallback.stream().anyMatch(s -> s.contains("reserved fallbackType"))); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceTest.java index 2bebde45..ad49291f 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceTest.java @@ -81,6 +81,22 @@ class WikiPageTypeProfileServiceTest { assertEquals("concept", service.normalizePageType(1L, null)); } + @Test + void normalizePageType_rejectsReservedProfileTypesEvenWhenPersisted() { + WikiPageTypeProfileEntity row = new WikiPageTypeProfileEntity(); + row.setKbId(1L); + row.setEnabled(1); + row.setConfigJson("{\"fallbackType\":\"synthesis\",\"pageTypes\":{" + + "\"system\":{\"label\":\"System\"}," + + "\"synthesis\":{\"label\":\"Synthesis\"}," + + "\"concept\":{\"label\":\"Concept\"}}}"); + when(mapper.selectOne(any())).thenReturn(row); + + assertEquals("concept", service.normalizePageType(1L, "system")); + assertEquals("concept", service.normalizePageType(1L, "synthesis")); + assertEquals("concept", service.normalizePageType(1L, "unknown")); + } + @Test void describeForPrompt_defaultProfile_listsBuiltInTypes() { when(mapper.selectOne(any())).thenReturn(null); diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceOwnershipWorkspaceTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceOwnershipWorkspaceTest.java index 97b9a121..73213b5a 100644 --- a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceOwnershipWorkspaceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceOwnershipWorkspaceTest.java @@ -26,7 +26,6 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -42,9 +41,11 @@ import static org.mockito.Mockito.when; * endpoints did not — an asymmetry that becomes a cross-workspace breach once * workspaces are untrusted isolation boundaries. * - *

    Post-fix behavior: shared conversations are visible only to members of - * their own workspace (plus global admins, plus the legacy escape hatches for - * pre-workspace rows and anonymous permitAll reconnects). + *

    Post-fix behavior: shared conversations are visible only to global admins + * (plus the legacy escape hatches for pre-workspace rows and anonymous + * permitAll reconnects). Issue #616 intentionally rejects same-workspace + * ordinary members too, because workspace membership is not conversation + * ownership. * *

    Pure-Mockito (no Spring context) so the test stays fast and isolated. * @@ -101,17 +102,16 @@ class ConversationServiceOwnershipWorkspaceTest { } // ------------------------------------------------------------------ - // 2. System conv, same-workspace member → allowed + // 2. System conv, same-workspace member → rejected (#616) // ------------------------------------------------------------------ @Test - @DisplayName("system conv in requester's workspace: member passes") - void systemConvSameWorkspaceMember() { + @DisplayName("system conv in requester's workspace: member rejected (#616)") + void systemConvSameWorkspaceMemberRejected() { when(conversationMapper.selectOne(any())).thenReturn(conv(SYSTEM_CONV, "system", WS_TENANT_A)); - when(workspaceService.hasPermissionCached(WS_TENANT_A, ALICE_USER_ID, "viewer")) - .thenReturn(true); - assertThat(service.isConversationOwner(SYSTEM_CONV, "alice")).isTrue(); + assertThat(service.isConversationOwner(SYSTEM_CONV, "alice")).isFalse(); + verify(workspaceService, never()).hasPermissionCached(anyLong(), anyLong(), anyString()); } // ------------------------------------------------------------------ @@ -122,11 +122,9 @@ class ConversationServiceOwnershipWorkspaceTest { @DisplayName("system conv in another workspace: non-member rejected (#344)") void systemConvCrossWorkspaceRejected() { when(conversationMapper.selectOne(any())).thenReturn(conv(SYSTEM_CONV, "system", WS_TENANT_A)); - // Bob is not a member of tenant A. - when(workspaceService.hasPermissionCached(WS_TENANT_A, BOB_USER_ID, "viewer")) - .thenReturn(false); assertThat(service.isConversationOwner(SYSTEM_CONV, "bob")).isFalse(); + verify(workspaceService, never()).hasPermissionCached(anyLong(), anyLong(), anyString()); } // ------------------------------------------------------------------ @@ -189,12 +187,11 @@ class ConversationServiceOwnershipWorkspaceTest { @DisplayName("webchat conv: invisible to a JWT user even when they share the workspace") void webchatConvInvisibleToJwtUser() { when(conversationMapper.selectOne(any())).thenReturn(conv(WEBCHAT_CONV, "webchat:vA", WS_TENANT_A)); - when(workspaceService.hasPermissionCached(WS_TENANT_A, ALICE_USER_ID, "viewer")) - .thenReturn(true); - // Alice is a member of the conv's workspace, but the conv is owned by - // "webchat:vA" — not "system" — so the final OR-clause returns false. + // Alice may be a member of the conv's workspace, but the conv is owned + // by "webchat:vA" and workspace membership is not ownership. assertThat(service.isConversationOwner(WEBCHAT_CONV, "alice")).isFalse(); + verify(workspaceService, never()).hasPermissionCached(anyLong(), anyLong(), anyString()); } @Test @@ -219,15 +216,12 @@ class ConversationServiceOwnershipWorkspaceTest { } @Test - @DisplayName("system conv + same-workspace member that the workspace service lost track of: rejected") + @DisplayName("system conv + ordinary member: rejected without membership lookup (#616)") void systemConvMemberCacheMiss() { when(conversationMapper.selectOne(any())).thenReturn(conv(SYSTEM_CONV, "system", WS_TENANT_A)); - // Membership cache returns false even though we'd expect this user to - // be a member — defense in depth: when in doubt, deny. - when(workspaceService.hasPermissionCached(eq(WS_TENANT_A), eq(ALICE_USER_ID), eq("viewer"))) - .thenReturn(false); assertThat(service.isConversationOwner(SYSTEM_CONV, "alice")).isFalse(); + verify(workspaceService, never()).hasPermissionCached(anyLong(), anyLong(), anyString()); } // ------------------------------------------------------------------ diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceWebchatVisibilityTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceWebchatVisibilityTest.java index b37e54e3..3a48631e 100644 --- a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceWebchatVisibilityTest.java +++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceWebchatVisibilityTest.java @@ -79,8 +79,8 @@ class ConversationServiceWebchatVisibilityTest { } @Test - @DisplayName("lenient list, non-admin: excludes webchat principals (no 'webchat:%' param)") - void lenientListNonAdminExcludesWebchat() { + @DisplayName("lenient list, non-admin: excludes shared principals (#616)") + void lenientListNonAdminExcludesSharedPrincipals() { when(authService.findByUsername("alice")).thenReturn(user("member")); ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); @@ -88,10 +88,12 @@ class ConversationServiceWebchatVisibilityTest { service.listConversations("alice", 1L, true); - // The malformed-id guard still emits a NOT LIKE, so we assert on the - // param value instead of the LIKE keyword. + // Members should only see their own rows: not webchat, and not + // workspace-wide system rows such as IM/cron conversations. + assertThat(captor.getValue().getTargetSql()).containsIgnoringCase("username"); assertThat(captor.getValue().getParamNameValuePairs().values()) - .doesNotContain("webchat:%"); + .contains("alice") + .doesNotContain("system", "webchat:%"); } @Test @@ -126,7 +128,7 @@ class ConversationServiceWebchatVisibilityTest { @Test @DisplayName("page query, non-admin: excludes webchat principals") - void pageNonAdminExcludesWebchat() { + void pageNonAdminExcludesSharedPrincipals() { when(authService.findByUsername("alice")).thenReturn(user("member")); ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); @@ -135,8 +137,10 @@ class ConversationServiceWebchatVisibilityTest { service.pageConversations("alice", 1L, 1, 20, null); + assertThat(captor.getValue().getTargetSql()).containsIgnoringCase("username"); assertThat(captor.getValue().getParamNameValuePairs().values()) - .doesNotContain("webchat:%"); + .contains("alice") + .doesNotContain("system", "webchat:%"); } @Test diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/controller/ConversationControllerBatchDeleteTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/controller/ConversationControllerBatchDeleteTest.java index ad7b65be..7798d987 100644 --- a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/controller/ConversationControllerBatchDeleteTest.java +++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/controller/ConversationControllerBatchDeleteTest.java @@ -8,6 +8,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.core.Authentication; import vip.mate.channel.web.ChatStreamTracker; import vip.mate.common.result.R; +import vip.mate.team.service.TeamWorkerConversationGovernanceService; import vip.mate.workspace.conversation.ConversationService; import java.util.ArrayList; @@ -25,13 +26,14 @@ class ConversationControllerBatchDeleteTest { @Mock private ConversationService conversationService; @Mock private ChatStreamTracker streamTracker; + @Mock private TeamWorkerConversationGovernanceService teamWorkerGovernanceService; @Mock private Authentication authentication; private ConversationController controller; @BeforeEach void setUp() { - controller = new ConversationController(conversationService, streamTracker); + controller = new ConversationController(conversationService, streamTracker, teamWorkerGovernanceService); when(authentication.getName()).thenReturn("alice"); } diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/controller/ConversationControllerTeamWorkerTranscriptTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/controller/ConversationControllerTeamWorkerTranscriptTest.java new file mode 100644 index 00000000..b8e5d86e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/controller/ConversationControllerTeamWorkerTranscriptTest.java @@ -0,0 +1,65 @@ +package vip.mate.workspace.conversation.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.common.result.R; +import vip.mate.team.service.TeamWorkerConversationGovernanceService; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ConversationControllerTeamWorkerTranscriptTest { + + @Mock private ConversationService conversationService; + @Mock private ChatStreamTracker streamTracker; + @Mock private TeamWorkerConversationGovernanceService teamWorkerGovernanceService; + @Mock private Authentication authentication; + + private ConversationController controller; + + @BeforeEach + void setUp() { + controller = new ConversationController(conversationService, streamTracker, teamWorkerGovernanceService); + when(authentication.getName()).thenReturn("workspace-admin"); + } + + @Test + void listMessagesAllowsVerifiedTeamWorkerTranscriptForNonOwner() { + when(conversationService.isConversationOwner("worker-conversation", "workspace-admin")) + .thenReturn(false); + when(teamWorkerGovernanceService.canReadTranscript("worker-conversation", 77L, 501L, + "workspace-admin")).thenReturn(true); + when(conversationService.listMessageViews("worker-conversation")).thenReturn(List.of()); + + R result = controller.listMessages("worker-conversation", null, null, 77L, 501L, + authentication); + + assertEquals(200, result.getCode()); + assertEquals(List.of(), result.getData()); + } + + @Test + void listMessagesRejectsNonOwnerWhenWorkerTranscriptIsNotVerified() { + when(conversationService.isConversationOwner("ordinary-conversation", "workspace-admin")) + .thenReturn(false); + when(teamWorkerGovernanceService.canReadTranscript("ordinary-conversation", 77L, 501L, + "workspace-admin")).thenReturn(false); + + R result = controller.listMessages("ordinary-conversation", null, null, 77L, 501L, + authentication); + + assertEquals(403, result.getCode()); + verify(conversationService, never()).listMessageViews("ordinary-conversation"); + } +} diff --git a/mateclaw-ui/package.json b/mateclaw-ui/package.json index c57cc48c..28ece50e 100644 --- a/mateclaw-ui/package.json +++ b/mateclaw-ui/package.json @@ -1,6 +1,6 @@ { "name": "mateclaw-ui", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "description": "MateClaw - Personal AI Assistant Web Console", diff --git a/mateclaw-ui/src/App.vue b/mateclaw-ui/src/App.vue index 7e645364..c6281b04 100644 --- a/mateclaw-ui/src/App.vue +++ b/mateclaw-ui/src/App.vue @@ -21,6 +21,7 @@ import { useThemeStore } from '@/stores/useThemeStore' import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore' import { useGlobalWikilinkClick } from '@/composables/useGlobalWikilinkClick' import { useGlobalFileDownloadClick } from '@/composables/useGlobalFileDownloadClick' +import { useGlobalGeneratedImageBlob } from '@/composables/useGlobalGeneratedImageBlob' import McConfirmHost from '@/components/common/McConfirmHost.vue' import FilePreviewDialog from '@/components/chat/preview/FilePreviewDialog.vue' @@ -42,6 +43,7 @@ useGlobalWikilinkClick() // expired/missing file degrades to a toast instead of a full-page navigation // to the backend's 404 JSON, which would otherwise replace the whole SPA. useGlobalFileDownloadClick() +useGlobalGeneratedImageBlob() const { t } = useI18n() diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index b3c9a68d..0844dee2 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -96,6 +96,8 @@ export async function fetchAuthenticatedBlob(fileUrl: string): Promise { const token = localStorage.getItem('token') const headers: Record = {} if (token) headers.Authorization = `Bearer ${token}` + const workspaceId = localStorage.getItem('mc-workspace-id') + if (workspaceId) headers['X-Workspace-Id'] = workspaceId const response = await fetch(fileUrl, { headers }) if (!response.ok) throw new Error(`Fetch failed: ${response.status}`) return response.blob() @@ -204,7 +206,7 @@ export const conversationApi = { */ page: (params: { page?: number; size?: number; keyword?: string }) => http.get('/conversations/page', { params }), - listMessages: (conversationId: string, params?: { beforeId?: number; limit?: number }) => + listMessages: (conversationId: string, params?: { beforeId?: number; limit?: number; runId?: string; taskId?: string }) => http.get(`/conversations/${encId(conversationId)}/messages`, { params }), getStatus: (conversationId: string) => http.get(`/conversations/${encId(conversationId)}/status`), @@ -439,6 +441,7 @@ export interface LiveSnapshot { export const liveApi = { snapshot: () => http.get<{ data: LiveSnapshot }>('/admin/agent-runtime/snapshot'), + dshDiagnostics: () => http.get<{ data: Record }>('/admin/agent-runtime/dsh/diagnostics'), stop: (conversationId: string) => http.post(`/admin/agent-runtime/runs/${encodeURIComponent(conversationId)}/stop`), recycle: (conversationId: string) => @@ -521,8 +524,8 @@ export const toolApi = { list: () => http.get('/tools'), listEnabled: () => http.get('/tools/enabled'), /** - * Unified picker source for the agent edit tool tab — returns built-in - * tools plus every MCP-discovered tool grouped by server. The `name` + * Unified picker source for the agent edit tool tab — returns built-in, + * channel, plugin, and MCP-discovered tools. The `name` * field is what gets saved into mate_agent_tool.tool_name. */ listAvailable: () => http.get('/tools/available'), @@ -753,6 +756,17 @@ export const settingsApi = { getSearchProviders: () => http.get('/settings/search-providers'), } +// ==================== DeepSeek Harness runtime ==================== +export const dshApi = { + status: () => http.get('/admin/dsh/status'), + saveConfig: (data: Record) => http.put('/admin/dsh/config', data), + install: () => http.post('/admin/dsh/install'), + verify: () => http.post('/admin/dsh/verify'), + testConnection: () => http.post('/admin/dsh/test-connection'), + enable: () => http.post('/admin/dsh/enable'), + disable: () => http.post('/admin/dsh/disable'), +} + // ==================== Global outbound proxy ==================== export const proxyApi = { get: () => http.get('/settings/proxy'), @@ -1775,6 +1789,8 @@ export interface Goal { description: string exitCriteria?: string | null status: 'active' | 'paused' | 'completed' | 'abandoned' | 'exhausted' + /** Durable continuation; zero budgets are unlimited only in this mode. */ + persistentExecution?: boolean turnBudget: number turnsUsed: number llmCallBudget: number @@ -1810,6 +1826,7 @@ export const goalApi = { title: string description?: string exitCriteria?: string + persistentExecution?: boolean turnBudget?: number llmCallBudget?: number autoFollowupEnabled?: boolean diff --git a/mateclaw-ui/src/components/channels/ChannelEditModal.vue b/mateclaw-ui/src/components/channels/ChannelEditModal.vue index 1ba35c29..740470ee 100644 --- a/mateclaw-ui/src/components/channels/ChannelEditModal.vue +++ b/mateclaw-ui/src/components/channels/ChannelEditModal.vue @@ -472,6 +472,7 @@ import { extractChannelFields, extractRenderConfig, parseConfigJson, + supportsChannelMessageFilter, type AccessControlValue, type RenderConfigValue, } from '@/utils/channelConfigJson' @@ -633,13 +634,8 @@ const needsWebhookUrl = computed(() => { return true }) -// Browser-rendered channels stream structured message parts over SSE and let -// the client decide what to draw (thinking panel, tool cards). They never go -// through the adapter's outbound text render path, which is the only place the -// message-filter config is read — so the controls would be inert there. -const BROWSER_RENDERED_TYPES = ['web', 'webchat'] const supportsMessageFilter = computed( - () => !BROWSER_RENDERED_TYPES.includes(form.value.channelType || ''), + () => supportsChannelMessageFilter(form.value.channelType), ) const isLocalhost = computed(() => { diff --git a/mateclaw-ui/src/components/chat/ChatInput.vue b/mateclaw-ui/src/components/chat/ChatInput.vue index 47253025..b1221f3c 100644 --- a/mateclaw-ui/src/components/chat/ChatInput.vue +++ b/mateclaw-ui/src/components/chat/ChatInput.vue @@ -205,7 +205,7 @@ class="action-btn send-btn" :class="sendBtnClass" :disabled="props.streamPhase === 'interrupting' || (!canSend && !loading)" - :title="props.streamPhase === 'interrupting' ? t('chat.streamInterrupting') : undefined" + :title="sendButtonTitle" @click="handleSubmit" > @@ -222,7 +222,10 @@

    - -
    - - {{ status === 'interrupted' ? $t('chat.interrupted') : $t('chat.stopped') }} -
    -
    @@ -228,6 +222,18 @@ + +
    + + {{ status === 'interrupted' ? $t('chat.interrupted') : $t('chat.stopped') }} +
    + -
    -
    - +
    + +
    + {{ t(initialLoadingCopy.title) }} + {{ t(initialLoadingCopy.hint) }}
    +
    + + diff --git a/mateclaw-ui/src/views/Settings/Layout.vue b/mateclaw-ui/src/views/Settings/Layout.vue index c0870df8..40114f4d 100644 --- a/mateclaw-ui/src/views/Settings/Layout.vue +++ b/mateclaw-ui/src/views/Settings/Layout.vue @@ -97,6 +97,12 @@ const sections = computed(() => [ label: t('settings.sections.system'), icon: '', }, + { + id: 'dsh', + path: '/settings/dsh', + label: t('settings.sections.dsh', 'DeepSeek Harness'), + icon: '', + }, { id: 'image', path: '/settings/image', diff --git a/mateclaw-ui/src/views/Wiki/__tests__/failureOpen.test.ts b/mateclaw-ui/src/views/Wiki/__tests__/failureOpen.test.ts new file mode 100644 index 00000000..5fc9a01d --- /dev/null +++ b/mateclaw-ui/src/views/Wiki/__tests__/failureOpen.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' +import { openWikiFailureItem } from '../utils/failureOpen' + +describe('openWikiFailureItem', () => { + it('switches to the owning workspace before opening a cross-workspace KB', async () => { + const calls: string[] = [] + const workspaceStore = { + currentWorkspaceId: 'ws-a', + switchWorkspace: vi.fn(async (id: string) => { + calls.push(`switch:${id}`) + }), + } + const wikiStore = { + selectKB: vi.fn(async (id: string, mode: 'browse' | 'manage') => { + calls.push(`select:${id}:${mode}`) + }), + } + + await openWikiFailureItem( + { kbId: 'kb-2', workspaceId: 'ws-b' }, + { workspaceStore, wikiStore }, + ) + + expect(calls).toEqual(['switch:ws-b', 'select:kb-2:browse']) + }) + + it('opens directly when the failure item belongs to the active workspace', async () => { + const workspaceStore = { + currentWorkspaceId: 'ws-a', + switchWorkspace: vi.fn(), + } + const wikiStore = { + selectKB: vi.fn(async () => {}), + } + + await openWikiFailureItem( + { kbId: 'kb-1', workspaceId: 'ws-a' }, + { workspaceStore, wikiStore }, + ) + + expect(workspaceStore.switchWorkspace).not.toHaveBeenCalled() + expect(wikiStore.selectKB).toHaveBeenCalledWith('kb-1', 'browse') + }) +}) diff --git a/mateclaw-ui/src/views/Wiki/components/WikiFailureCenter.vue b/mateclaw-ui/src/views/Wiki/components/WikiFailureCenter.vue index a5dded70..35ac860a 100644 --- a/mateclaw-ui/src/views/Wiki/components/WikiFailureCenter.vue +++ b/mateclaw-ui/src/views/Wiki/components/WikiFailureCenter.vue @@ -16,7 +16,7 @@
    -
    +
    {{ t(`wiki.status.${it.processingStatus}`) }}
    @@ -25,7 +25,7 @@
    {{ friendly(it) }}
    - +
    @@ -36,7 +36,7 @@ import { ref, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import { wikiApi, type WikiFailureItem } from '@/api/index' -defineEmits<{ (e: 'open', kbId: string): void }>() +defineEmits<{ (e: 'open', item: WikiFailureItem): void }>() const { t } = useI18n() const items = ref([]) diff --git a/mateclaw-ui/src/views/Wiki/index.vue b/mateclaw-ui/src/views/Wiki/index.vue index 190258e0..4ee755e7 100644 --- a/mateclaw-ui/src/views/Wiki/index.vue +++ b/mateclaw-ui/src/views/Wiki/index.vue @@ -48,18 +48,21 @@ import { ref, reactive, watch, onMounted, computed } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import { useWikiStore, type WikiKB } from '@/stores/useWikiStore' -import { wikiApi } from '@/api/index' +import { useWorkspaceStore } from '@/stores/useWorkspaceStore' +import { wikiApi, type WikiFailureItem } from '@/api/index' import { mcConfirm } from '@/components/common/useConfirm' import { mcToast } from '@/composables/useMcToast' import WikiLibrary from './components/WikiLibrary.vue' import WikiWorkspace from './components/WikiWorkspace.vue' import WikiFailureCenter from './components/WikiFailureCenter.vue' +import { openWikiFailureItem } from './utils/failureOpen' const route = useRoute() const router = useRouter() const { t } = useI18n() const store = useWikiStore() +const workspaceStore = useWorkspaceStore() // The cross-KB failure center spans every workspace, so it is admin-only — // mirrors the gate on the backing endpoint. @@ -95,10 +98,8 @@ async function enterKB(id: number) { await store.selectKB(id, 'browse') } -// The failure center emits a Snowflake kbId as a string — keep it a string end -// to end (snowflake-precision-ok) and let the store cast satisfy its signature. -async function openFromFailureCenter(kbId: string) { - await store.selectKB(kbId as unknown as number, 'browse') +async function openFromFailureCenter(item: WikiFailureItem) { + await openWikiFailureItem(item, { workspaceStore, wikiStore: store }) } async function enterKBManage(id: number) { diff --git a/mateclaw-ui/src/views/Wiki/utils/failureOpen.ts b/mateclaw-ui/src/views/Wiki/utils/failureOpen.ts new file mode 100644 index 00000000..dee0a9f8 --- /dev/null +++ b/mateclaw-ui/src/views/Wiki/utils/failureOpen.ts @@ -0,0 +1,27 @@ +export interface WikiFailureOpenItem { + kbId: string + workspaceId?: string | null +} + +export interface WikiFailureOpenDeps { + workspaceStore: { + currentWorkspaceId: string | null + switchWorkspace: (id: string) => Promise | void + } + wikiStore: { + // The wiki store is still typed as number in places, but Snowflake KB IDs + // are passed as strings at runtime to avoid precision loss. + selectKB: (id: any, mode: 'browse' | 'manage') => Promise | void + } +} + +export async function openWikiFailureItem( + item: WikiFailureOpenItem, + deps: WikiFailureOpenDeps, +) { + const targetWorkspaceId = item.workspaceId || null + if (targetWorkspaceId && deps.workspaceStore.currentWorkspaceId !== targetWorkspaceId) { + await deps.workspaceStore.switchWorkspace(targetWorkspaceId) + } + await deps.wikiStore.selectKB(item.kbId, 'browse') +} diff --git a/mateclaw-ui/src/views/__tests__/chatConsoleRouteHydration.test.ts b/mateclaw-ui/src/views/__tests__/chatConsoleRouteHydration.test.ts index b5f2f080..96bdcbe8 100644 --- a/mateclaw-ui/src/views/__tests__/chatConsoleRouteHydration.test.ts +++ b/mateclaw-ui/src/views/__tests__/chatConsoleRouteHydration.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { buildChatRouteQuery, + decideConversationResume, readLegacyWorkerRouteContext, readTeamRunRouteQuery, resolveConversationAgentSelection, @@ -134,3 +135,44 @@ describe('legacy worker route context', () => { expect(readLegacyWorkerRouteContext('team-task-legacy', { taskId: fullQuery.taskId })).toBeNull() }) }) + +describe('decideConversationResume', () => { + it('refreshes history when returning to the same inactive conversation', () => { + expect(decideConversationResume({ + currentConversationId: 'conv-long-task', + targetConversationId: 'conv-long-task', + snapshotStreamStatus: 'idle', + liveStreamStatus: 'idle', + })).toEqual({ + shouldResetLocalStream: false, + shouldRefreshMessages: true, + shouldReconnectStream: false, + }) + }) + + it('refreshes persisted history before reconnecting a running conversation', () => { + expect(decideConversationResume({ + currentConversationId: 'conv-long-task', + targetConversationId: 'conv-long-task', + snapshotStreamStatus: 'idle', + liveStreamStatus: 'running', + })).toEqual({ + shouldResetLocalStream: false, + shouldRefreshMessages: true, + shouldReconnectStream: true, + }) + }) + + it('trusts live idle status over a stale running sidebar snapshot', () => { + expect(decideConversationResume({ + currentConversationId: 'conv-long-task', + targetConversationId: 'conv-long-task', + snapshotStreamStatus: 'running', + liveStreamStatus: 'idle', + })).toEqual({ + shouldResetLocalStream: false, + shouldRefreshMessages: true, + shouldReconnectStream: false, + }) + }) +}) diff --git a/pom.xml b/pom.xml index f3133720..2477b883 100644 --- a/pom.xml +++ b/pom.xml @@ -23,7 +23,7 @@ - 2.1.0 + 2.2.0 21