release: v2.2.0

This commit is contained in:
mateaix 2026-08-29 13:53:25 +08:00
parent 35bfdfcffd
commit 9ac8ee34f0
288 changed files with 14107 additions and 671 deletions

1
.gitignore vendored
View File

@ -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)

View File

@ -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",

View File

@ -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;

View File

@ -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<String> args = endpointService.parseArgs(endpoint);
Map<String, String> 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

View File

@ -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<String> parseStringList(String json) {
if (json == null || json.isBlank()) return Collections.emptyList();
try {

View File

@ -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)

View File

@ -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<String> 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<String, String, String> 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<String, String, String> invoke) {
safeRegister(conversationId, agentId);
try {
if (!memoryProperties.isLifecycleMediatorEnabled()) {
@ -646,11 +703,26 @@ public class AgentService {
private <T> Flux<T> withLifecycleFlux(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, Flux<T>> invoke,
Function<T, String> 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 <T> Flux<T> invokeWithLifecycleFlux(Long agentId, String message, String conversationId,
java.util.function.BiFunction<String, String, Flux<T>> invoke,
Function<T, String> 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();
}
/**

View File

@ -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

View File

@ -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,

View File

@ -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<Boolean> 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> T call(Supplier<T> action) { return call(true, action); }
/** Queued user input keeps normal attachment reconstruction within the same worker. */
public static <T> T call(boolean explicitPrompt, Supplier<T> 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); }
}
}

View File

@ -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;

View File

@ -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<AssistantMessage.ToolCall> toolCalls,
String conversationId, String agentId,
boolean isReplay, String requesterId,
String workspaceBasePath,
ChatOrigin origin,
Set<String> 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<PreparedToolCall> preparedCalls = new ArrayList<>();
ApprovalBarrier barrier = null;
Set<String> 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

View File

@ -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<String> extractLoadedSkillNames(List<AssistantMessage.ToolCall> toolCalls) {
public static Set<String> extractLoadedSkillNames(List<AssistantMessage.ToolCall> toolCalls) {
if (toolCalls == null || toolCalls.isEmpty()) {
return Set.of();
}

View File

@ -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<String, Object> stateSafeGoal(GoalResponse goal) {
if (goal == null) {
return Map.of();
}
Map<String, Object> 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<Map<String, Object>> criteria = new ArrayList<>();
if (goal.getCriteria() != null) {
goal.getCriteria().forEach(criterion -> {
if (criterion == null) return;
Map<String, Object> 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

View File

@ -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<String> 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<ToolCallback> filterLongFormArtifactTools(String userMessage,
List<ToolCallback> 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<AssistantMessage.ToolCall> 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("</memory-context>");
return memoryEnd >= 0
? userMessage.substring(memoryEnd + "</memory-context>".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)

View File

@ -623,7 +623,9 @@ public class PlanGenerationNode implements NodeAction {
+ "相互独立的步骤请不要标注前置,以便并行执行。\n"
+ "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。\n"
+ "4. 若用户要求编号轮次、检查点区间或连续跟踪,必须包含一个专门的共享跟踪步骤,"
+ "明确区间、证据格式和完成条件;不要只把轮次要求埋在普通交付步骤中。"));
+ "明确区间、证据格式和完成条件;不要只把轮次要求埋在普通交付步骤中。\n"
+ "5. 不要创建专门的‘最终汇总/总结/验收’成员步骤;系统会在所有任务结束后自动汇总。"
+ "把必要的自检和验收标准写进实际产出步骤,避免为了复述结果增加串行任务。"));
} else {
List<AgentEntity> delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId);
if (!delegatable.isEmpty()) {

View File

@ -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<String> 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<String> 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));
}

View File

@ -140,6 +140,11 @@ public final class PlanStateAccessor {
return state.value(WORKING_CONTEXT, "");
}
@SuppressWarnings("unchecked")
public Set<String> loadedSkills() {
return state.<Set<String>>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<String> names) {
return put(MateClawStateKeys.LOADED_SKILLS, names);
}
// ---- 阶段标记写入共享键 MateClawStateKeys.CURRENT_PHASE----
public OutputBuilder currentPhase(String phase) {
return put(MateClawStateKeys.CURRENT_PHASE, phase);

View File

@ -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);

View File

@ -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";

View File

@ -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;

View File

@ -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<RunSnapshot> rawRuns = streamTracker.getAllSnapshot();
Set<Long> agentIds = rawRuns.stream()
.map(RunSnapshot::agentId)
.filter(java.util.Objects::nonNull)
.collect(Collectors.toSet());
for (var rec : subagentRegistry.allActive()) {
Collection<SubagentRegistry.SubagentRecord> rawSubagents = subagentRegistry.allActive();
for (var rec : rawSubagents) {
if (rec.agentId() != null) agentIds.add(rec.agentId());
}
Map<Long, AgentEntity> agentInfo = resolveAgents(agentIds);
Map<String, Long> 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<SubagentCard> subCards = subagentRegistry.allActive().stream()
List<SubagentCard> 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<RunSnapshot> 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<Long, AgentEntity> agentInfo,
Long workspaceId) {
if (workspaceId == null) {
return true;
}
AgentEntity agent = agentId == null ? null : agentInfo.get(agentId);
return agent != null && workspaceId.equals(agent.getWorkspaceId());
}
}

View File

@ -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<AgentRuntimeAggregator.RuntimeSnapshot> snapshot(Authentication auth) {
public R<AgentRuntimeAggregator.RuntimeSnapshot> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> sweep(Authentication auth) {
public R<Map<String, Object>> 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<String> 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<String, Object> detail) {
try {

View File

@ -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<String, Permit> owners = new ConcurrentHashMap<>();
private final ThreadLocal<Permit> 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> T withPermit(Permit permit, java.util.function.Supplier<T> 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);
}
}
}

View File

@ -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<String, Object> 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<String, Object> with(Map<String, Object> source, String key, Object value) {
Map<String, Object> result = new LinkedHashMap<>(source);
result.put(key, value);
return result;
}
private static String text(RuntimeEvent event, Map<String, Object> data) {
Object delta = data.get("delta");
return delta != null ? String.valueOf(delta) : event.text() == null ? "" : event.text();
}
private static Map<String, Object> rename(Map<String, Object> source, String from, String to,
String secondFrom, String secondTo) {
Map<String, Object> result = new LinkedHashMap<>(source);
copyIfPresent(result, from, to);
copyIfPresent(result, secondFrom, secondTo);
return result;
}
private static void copyIfPresent(Map<String, Object> data, String from, String to) {
if (!data.containsKey(to) && data.containsKey(from)) data.put(to, data.get(from));
}
}

View File

@ -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<AgentService.StreamDelta> adapt(Flux<RuntimeEvent> events) {
if (events == null) return Flux.empty();
return events.map(RuntimeEventProjector::project);
}
}

View File

@ -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<AgentRuntimeProvider> providers) {
return new RuntimeProviderRegistry(providers);
}
@Bean
AgentRuntimeCoordinator agentRuntimeCoordinator(RuntimeProviderRegistry registry,
ObjectMapper objectMapper) {
return new AgentRuntimeCoordinator(registry, objectMapper);
}
}

View File

@ -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<RuntimeEvent> prompt(String message);
Mono<Void> cancel();
Mono<RuntimeContextUsage> contextUsage();
@Override
default void close() {
cancel().block();
}
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -0,0 +1,8 @@
package vip.mate.agent.runtime.contract;
public record RuntimeCapabilities(
boolean supportsCancellation,
boolean supportsApprovals,
boolean supportsSubagents,
boolean supportsContextUsage
) {}

View File

@ -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");
}
}
}

View File

@ -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<String, Object> 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<String, Object> data) {
return new RuntimeEvent(sessionId, sequence, type, text, data, false);
}
public static RuntimeEvent terminal(String sessionId, long sequence, RuntimeEventType type,
Map<String, Object> data) {
return new RuntimeEvent(sessionId, sequence, type, null, data, true);
}
}

View File

@ -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<RuntimeEvent> 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<RuntimeEvent> snapshot() {
return List.copyOf(events);
}
public synchronized boolean terminal() {
return terminal;
}
}

View File

@ -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;
}
}

View File

@ -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<String, AgentRuntimeProvider> providers;
public RuntimeProviderRegistry(List<AgentRuntimeProvider> providers) {
Map<String, AgentRuntimeProvider> registered = new LinkedHashMap<>();
for (AgentRuntimeProvider provider : providers == null ? List.<AgentRuntimeProvider>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);
}
}

View File

@ -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);
}
}

View File

@ -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<String, Object> 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);
}
}

View File

@ -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<Map<String, Object>> 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<String, Object> 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();
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,9 @@
package vip.mate.agent.runtime.dsh;
import java.nio.file.Path;
import java.util.Optional;
@FunctionalInterface
public interface DshBinaryResolver {
Optional<Path> resolve();
}

View File

@ -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));
}
}

View File

@ -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;
}
}

View File

@ -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<String, Object> arguments) {
Map<String, Object> 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<String, Object> 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<String, Object> data) {
Map<String, Object> 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;
}
}

View File

@ -0,0 +1,26 @@
package vip.mate.agent.runtime.dsh;
import java.util.Map;
public record DshBridgeMessage(
String id,
String method,
Map<String, Object> 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<String, Object> 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<String, Object> params) {
return new DshBridgeMessage(null, method, params, null, null, null);
}
}

View File

@ -0,0 +1,15 @@
package vip.mate.agent.runtime.dsh;
import java.util.Set;
public final class DshBridgeMethods {
private static final Set<String> 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);
}
}

View File

@ -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;
}
}

View File

@ -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<String, Object> policy) {
Map<String, Object> 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<String, Object> 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<String, Object> target, String key, Object value) {
if (value != null) target.put(key, value);
}
}

View File

@ -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.
}
}
}

View File

@ -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
) {}

View File

@ -0,0 +1,11 @@
package vip.mate.agent.runtime.dsh;
public interface DshProcessHandle {
boolean isAlive();
void destroy();
void destroyForcibly();
boolean awaitExit(long millis);
}

View File

@ -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);
}

View File

@ -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<String, DshManagedProcess> 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<String> 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) { }
}
}

View File

@ -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.
*
* <p>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.</p>
*/
@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() ? "<empty>" : 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<String, Object> 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<Process> activeProcess = new AtomicReference<>();
AtomicReference<RuntimeContextUsage> latestUsage = new AtomicReference<>(
new RuntimeContextUsage(0, 0, 0));
return new AgentRuntimeConnection() {
@Override
public Flux<RuntimeEvent> 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<Void> cancel() {
return reactor.core.publisher.Mono.fromRunnable(
() -> cancelProcess(activeProcess.get()));
}
@Override
public reactor.core.publisher.Mono<RuntimeContextUsage> 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<AgentService.StreamDelta> 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<AgentService.StreamDelta> stream(AgentEntity agent, String message,
String conversationId, String modelName,
Path workingDirectory,
AtomicReference<Process> processRef,
AtomicReference<RuntimeContextUsage> latestUsage) {
return Flux.<AgentService.StreamDelta>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() ? "<default>" : modelName,
effectiveModelName,
provider == null ? "<missing>" : provider.getProviderId(),
provider != null && provider.getApiKey() != null && !provider.getApiKey().isBlank(),
provider != null && provider.getBaseUrl() != null && !provider.getBaseUrl().isBlank());
List<String> command = commandLine(configuration.executablePath());
ProcessBuilder builder = new ProcessBuilder(command)
.directory(session.workingDirectory().toFile())
.redirectError(ProcessBuilder.Redirect.PIPE);
Map<String, String> environment = builder.environment();
Map<String, String> 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", "<empty>"),
!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("<missing>"));
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<String> commandLine(String commandLine) {
List<String> 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<String, String> childEnvironment(Map<String, String> inherited,
RuntimeSession session,
DshRuntimeConfiguration configuration,
ModelProviderEntity provider) {
Map<String, String> 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<String, String> source, Map<String, String> target, String key) {
if (source == null) return;
putIfPresent(target, key, source.get(key));
}
private static void putIfPresent(Map<String, String> 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("<missing>"),
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("<missing>"),
failure.path("code").asText("<none>"),
failure.path("message").asText("<none>"));
}
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<String, Object> request(String method, String id, Map<String, Object> params) {
return Map.of("jsonrpc", "2.0", "id", id, "method", method, "params", params);
}
private Map<String, Object> 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<String, Object> payload) throws IOException {
writer.write(objectMapper.writeValueAsString(payload));
writer.newLine();
writer.flush();
}
}

View File

@ -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<DshToolDescriptor> fromCallbacks(List<ToolCallback> callbacks) {
LinkedHashMap<String, DshToolDescriptor> 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());
}
}

View File

@ -0,0 +1,7 @@
package vip.mate.agent.runtime.dsh;
public enum DshToolDecision {
ALLOW,
APPROVAL,
DENY
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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<String, ToolCallback> callbacks;
private final DshToolPolicy policy;
private final DshToolPolicyEvaluator policyEvaluator;
public DshToolDispatcher(List<ToolCallback> callbacks, DshToolPolicy policy,
DshToolPolicyEvaluator policyEvaluator) {
Map<String, ToolCallback> 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");
}
}
}

View File

@ -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<String> disabledTools,
Set<String> readTools,
Set<String> editTools,
Set<String> 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);
}
}

View File

@ -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);
}
}

View File

@ -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<String> 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<String> 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<InputStream> 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<String> 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) { }
}
}

View File

@ -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) {
}

View File

@ -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<Map<String, Object>> status() { return R.ok(managementService.status()); }
@Operation(summary = "Save managed DSH runtime configuration")
@PutMapping("/config")
@RequireGlobalAdmin
public R<Map<String, Object>> saveConfig(@RequestBody Map<String, String> values) {
return R.ok(managementService.saveConfig(values));
}
@Operation(summary = "Install the server-selected DSH artifact")
@PostMapping("/install")
@RequireGlobalAdmin
public R<Map<String, Object>> install() throws Exception { return R.ok(managementService.install()); }
@Operation(summary = "Verify DSH runtime configuration")
@PostMapping("/verify")
@RequireGlobalAdmin
public R<Map<String, Object>> verify() { return R.ok(managementService.verify()); }
@Operation(summary = "Test starting the DSH process")
@PostMapping("/test-connection")
@RequireGlobalAdmin
public R<Map<String, Object>> testConnection() { return R.ok(managementService.testConnection()); }
@Operation(summary = "Enable managed DSH runtime")
@PostMapping("/enable")
@RequireGlobalAdmin
public R<Map<String, Object>> enable() { return R.ok(managementService.enable()); }
@Operation(summary = "Disable managed DSH runtime")
@PostMapping("/disable")
@RequireGlobalAdmin
public R<Map<String, Object>> disable() { return R.ok(managementService.disable()); }
}

View File

@ -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<String, Object> 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<String, Object> 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<String, Object> saveConfig(Map<String, String> values) {
configService.save(values);
return status();
}
public Map<String, Object> install() throws Exception {
DshArtifactManifest manifest = installer.loadManifest();
Path executable = installer.install(manifest);
Map<String, String> 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<String, Object> verify() {
Map<String, Object> 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<String, Object> 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<String, Object> enable() {
Map<String, Object> 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<String, Object> 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));
}
}

View File

@ -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;
}
}

View File

@ -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<String, String> managed,
Map<String, String> properties,
Map<String, String> 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<String, String> managed,
Map<String, String> properties,
Map<String, String> 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<String, String> values, String key) {
if (values == null) {
return null;
}
String value = values.get(key);
return value == null || value.isBlank() ? null : value.trim();
}
}

View File

@ -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<String, String> 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<String, String> 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<String, String> managedValues() {
Map<String, String> 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<String, String> 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<String, String> values, String key) {
if (values.containsKey(key)) {
settings.saveString(key, values.get(key), "Managed DeepSeek Harness runtime setting");
}
}
}

View File

@ -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<String, Object> publicStatus() {
Map<String, Object> 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;
}
}

View File

@ -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<MessageContentPart> 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;
}

View File

@ -190,8 +190,8 @@ public class ChatStreamTracker {
/** 等待原因(审批等待时有值) */
volatile String waitingReason;
/** 排队的用户消息队列(支持多条排队消息,按序消费) */
final java.util.Queue<QueuedInput> 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.
* <p>
* Two event categories survive {@code state.done=true}:
* Lifecycle event categories survive {@code state.done=true}:
* <ul>
* <li>{@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.</li>
* <li>{@code "goal_continuation"} durable scheduling is settled after
* the graph segment completes, and remains available on reconnect.</li>
* <li>{@code "async_task_*"} task lifecycle events from
* {@code AsyncTaskService} (image/video/music generation). These
* routinely fire <em>after</em> 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<SseEmitter> 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<SseEmitter> 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,

View File

@ -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<List<MessageContentPart>> 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<MessageContentPart> 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<QueuedInput> claimNext(String conversationId, String attemptId,
LocalDateTime now) {
for (int tries = 0; tries < 8; tries++) {
List<Long> 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<QueuedInput> rows = jdbc.query("""
SELECT * FROM mate_conversation_input_queue WHERE id=?
""", (rs, row) -> read(rs), id);
return rows.isEmpty() ? null : rows.getFirst();
}
public List<QueuedInput> 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<MessageContentPart> 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<MessageContentPart> 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<MessageContentPart> contentParts,
String state,
String claimedByAttemptId,
Long persistedMessageId,
String cancelReason,
LocalDateTime createdAt,
LocalDateTime updatedAt) {}
}

View File

@ -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

View File

@ -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

View File

@ -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<GoalContinuationStore.Continuation> execution(@PathVariable Long id, Authentication auth) {
authorize(id,auth);
return R.ok(store.get(id));
}
@GetMapping("/api/v1/goals/{id}/execution/attempts")
public R<java.util.List<AttemptView>> 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());
}
}
}

View File

@ -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<String> TERMINAL_STATES =
Set.of("succeeded", "retryable", "blocked", "cancelled");
public boolean terminal() {
return TERMINAL_STATES.contains(state);
}
}

View File

@ -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 }
}

View File

@ -8,8 +8,8 @@ import java.util.List;
* Request body for {@code POST /api/v1/goals}.
*
* <p>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.
*
* <p>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;

View File

@ -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. */

View File

@ -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;

View File

@ -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;

View File

@ -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 {}
}

View File

@ -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<GoalAttempt> rows = jdbc.query("""
SELECT * FROM mate_goal_attempt WHERE attempt_id=?
""", (rs, row) -> read(rs), id);
return rows.isEmpty() ? null : rows.getFirst();
}
public List<GoalAttempt> 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<GoalAttempt> 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);
}
}
}

View File

@ -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<Long> 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<Continuation> 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<Continuation> 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()));
}
}

View File

@ -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<Long, GoalRunCoordinator.ClaimedRun> active = new ConcurrentHashMap<>();
private final AtomicReference<LocalDateTime> 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(); }
}
}
}

View File

@ -131,7 +131,9 @@ public class GoalEvaluationService implements Evaluator {
+ "\n\n" + format;
List<Message> 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<GoalCriterion> existing,
List<? extends Message> 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 "

View File

@ -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) {}
}

View File

@ -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:
* <ol>
* <li>{@code allow-auto-followup} runtime hard gate (operator kill
* switch; overrides per-goal flag).</li>
* <li>Per-goal {@code autoFollowupEnabled}.</li>
* <li>Evaluator decision is "continue" (not all criteria passed).</li>
* <li>Cooldown since the last follow-up has elapsed.</li>
* <li>turn_budget has at least one slot left after this turn.</li>
* <li>(agent + eval) LLM calls below 90% of llm_call_budget.</li>
* </ol>
*/
public Optional<String> 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<GoalCriterion> 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<String> 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<GoalCriterion> all = GoalCriteriaCodec.parse(goal.getCriteria(), objectMapper);
List<GoalCriterion> remaining = GoalCriteriaCodec.remaining(all);
boolean persistent = Boolean.TRUE.equals(goal.getPersistentExecution());
List<GoalCriterion> 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]";
}
}

View File

@ -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;
}
}

View File

@ -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) {}
}

View File

@ -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<Long,Worker> 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<ChatStreamTracker.RunHandle> 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<ConversationInputQueueStore.QueuedInput> 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<Throwable> 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);
}
}

View File

@ -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);

View File

@ -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<GoalEntity> 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<GoalEntity> update = baseLockedUpdate(fresh)
.set(GoalEntity::getStatus, GoalStatus.PAUSED)
.set(GoalEntity::getProgressSummary, "Waiting for input: " + boundedReason);
bumpVersionAndTime(update);
return update;
});
Map<String, Object> 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<GoalCriterion> 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<GoalEntity> 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<GoalCriterion> 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<GoalCriterion> 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<GoalEntity> 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<GoalEntity> w = baseLockedUpdate(fresh)
.set(GoalEntity::getStatus, to);
bumpVersionAndTime(w);

View File

@ -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<String, Object> 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<String, Object> wellKnown(HttpServletRequest request) {
return cardService.publicCard(request);
}
}

View File

@ -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<String, Object> publicCard(HttpServletRequest request) {
Map<String, Object> card = baseCard(request);
card.put("supportsAuthenticatedExtendedCard", true);
card.remove("skills");
return card;
}
public Map<String, Object> authenticatedCard(HttpServletRequest request, Long workspaceId) {
long wsId = workspaceId == null ? 1L : workspaceId;
Map<String, Object> card = baseCard(request);
List<Map<String, Object>> skills = new ArrayList<>();
for (AgentEntity agent : agentService.listAgentsByWorkspace(wsId, true)) {
Map<String, Object> 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<String, Object> baseCard(HttpServletRequest request) {
String rpcUrl = externalBaseUrl(request).replaceAll("/+$", "") + "/api/a2a";
Map<String, Object> 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<String> tags(String tags) {
if (tags == null || tags.isBlank()) {
return List.of();
}
List<String> out = new ArrayList<>();
for (String tag : tags.split(",")) {
String trimmed = tag.trim();
if (!trimmed.isBlank()) {
out.add(trimmed);
}
}
return out;
}
}

View File

@ -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 {
}

View File

@ -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<String, Object> cfg = parseConfig(config);
String url = String.valueOf(cfg.getOrDefault("url", "")).trim();
if (url.isBlank()) {
return "Error: config.url is required.";
}
Map<String, String> 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<String, Object> parseConfig(String config) throws Exception {
if (config == null || config.isBlank()) {
return Map.of();
}
return objectMapper.readValue(config, new TypeReference<LinkedHashMap<String, Object>>() {
});
}
private static Map<String, String> headers(Object value) {
if (!(value instanceof Map<?, ?> raw)) {
return Map.of();
}
Map<String, String> 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;
}
}

View File

@ -0,0 +1,9 @@
package vip.mate.interop.a2a;
public interface A2aExecutionBridge {
ExecutionResult executeBlocking(A2aExecutionRequest request);
record ExecutionResult(String text, boolean terminal) {
}
}

View File

@ -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
) {
}

View File

@ -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<Object> 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<String, Object> 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<String, Object> 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<Object> 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<String, Object> handleGet(Object rpcId, String tenant, JsonNode params) {
String taskId = taskId(params);
return store.get(tenant, taskId)
.<Map<String, Object>>map(task -> result(rpcId, task.toMap()))
.orElseGet(() -> error(rpcId, ERR_TASK_NOT_FOUND, "task not found"));
}
private Map<String, Object> 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<String, Object>>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<A2aExecutionBridge.ExecutionResult> 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<String> 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<String, Object> result(Object id, Object result) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("jsonrpc", "2.0");
out.put("id", id);
out.put("result", result);
return out;
}
private static Map<String, Object> error(Object id, int code, String message) {
Map<String, Object> 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
}
}

Some files were not shown because too many files have changed in this diff Show More