mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
release: v2.2.0
This commit is contained in:
parent
35bfdfcffd
commit
84968688fa
1
.gitignore
vendored
1
.gitignore
vendored
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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); }
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package vip.mate.agent.runtime.contract;
|
||||
|
||||
public record RuntimeCapabilities(
|
||||
boolean supportsCancellation,
|
||||
boolean supportsApprovals,
|
||||
boolean supportsSubagents,
|
||||
boolean supportsContextUsage
|
||||
) {}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {}
|
||||
@ -0,0 +1,11 @@
|
||||
package vip.mate.agent.runtime.dsh;
|
||||
|
||||
public interface DshProcessHandle {
|
||||
boolean isAlive();
|
||||
|
||||
void destroy();
|
||||
|
||||
void destroyForcibly();
|
||||
|
||||
boolean awaitExit(long millis);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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) { }
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package vip.mate.agent.runtime.dsh;
|
||||
|
||||
public enum DshToolDecision {
|
||||
ALLOW,
|
||||
APPROVAL,
|
||||
DENY
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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) { }
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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()); }
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
@ -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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 "
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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]";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package vip.mate.interop.a2a;
|
||||
|
||||
public interface A2aExecutionBridge {
|
||||
|
||||
ExecutionResult executeBlocking(A2aExecutionRequest request);
|
||||
|
||||
record ExecutionResult(String text, boolean terminal) {
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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
Loading…
Reference in New Issue
Block a user