package vip.mate.agent; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import reactor.core.publisher.Flux; import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ChatOriginHolder; import vip.mate.agent.event.AgentLifecycleEvent; import vip.mate.agent.model.AgentEntity; import vip.mate.agent.progress.ProgressLedgerService; import vip.mate.agent.repository.AgentMapper; import vip.mate.exception.MateClawException; import vip.mate.llm.chatmodel.ThinkingLevelHolder; import vip.mate.llm.event.ModelConfigChangedEvent; import vip.mate.memory.MemoryProperties; import vip.mate.memory.lifecycle.MemoryLifecycleMediator; import vip.mate.memory.lifecycle.TurnContext; import vip.mate.memory.service.MemoryRecallTracker; import vip.mate.team.event.TeamChangedEvent; import vip.mate.workspace.conversation.model.ConversationEntity; import vip.mate.workspace.conversation.repository.ConversationMapper; import java.util.List; import java.util.Locale; import java.util.Map; import java.nio.file.Path; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import java.util.function.Supplier; /** * Agent 业务服务 *

* 负责 Agent 的 CRUD 管理和运行时实例管理。 * 构建逻辑委托给 {@link AgentGraphBuilder}。 * * @author MateClaw Team */ @Slf4j @Service @RequiredArgsConstructor public class AgentService { private final AgentMapper agentMapper; private final AgentGraphBuilder agentGraphBuilder; private final MemoryRecallTracker memoryRecallTracker; private final MemoryLifecycleMediator lifecycleMediator; private final MemoryProperties memoryProperties; private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver; /** Read-only lookup of a conversation's pinned model. Mapper (not service) * to keep this a leaf dependency with no risk of a bean cycle. */ private final ConversationMapper conversationMapper; /** Field-injected publisher for agent_lifecycle trigger events; the * trigger module's bridge listens and forwards into ingest. */ @Autowired(required = false) private ApplicationEventPublisher events; /** * C5: tracks in-flight conversations so {@link vip.mate.agent.runtime.EnvironmentEventRouter} * can push environment-change notifications into the agent's next reasoning * turn. Field-injected (optional) so existing test constructors of * {@code AgentService} don't need to supply it. */ @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 * {@code AgentService} don't need to supply it. */ @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; @Autowired private vip.mate.agent.runtime.dsh.DshConversationHistory dshConversationHistory; @Autowired(required = false) private vip.mate.goal.service.GoalApprovalReplayStream goalApprovalReplay; /** * 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 * variant instead of mutating the one every other conversation shares. * The model key is {@code ""} for the Agent / global-default model. */ private final Map> agentInstances = new ConcurrentHashMap<>(); // ==================== CRUD ==================== public List listAgents() { return agentMapper.selectList(new LambdaQueryWrapper() .orderByDesc(AgentEntity::getCreateTime)); } /** * 按工作区列出 Agent */ public List listAgentsByWorkspace(Long workspaceId) { return listAgentsByWorkspace(workspaceId, null); } /** * 按工作区列出 Agent,可选过滤启用状态。 * * @param enabled non-null restricts the result set to agents whose * {@code enabled} column matches the given value. * Pass {@code true} from chat selectors so disabled * agents disappear from the picker; the admin * management page passes {@code null} to keep * disabled rows visible for re-enabling. */ public List listAgentsByWorkspace(Long workspaceId, Boolean enabled) { LambdaQueryWrapper q = new LambdaQueryWrapper() .eq(AgentEntity::getWorkspaceId, workspaceId); if (enabled != null) { q.eq(AgentEntity::getEnabled, enabled); } return agentMapper.selectList(q.orderByDesc(AgentEntity::getCreateTime)); } public AgentEntity getAgent(Long id) { AgentEntity entity = agentMapper.selectById(id); if (entity == null) { throw new MateClawException("err.agent.not_found", "Agent不存在: " + id); } return entity; } public AgentEntity createAgent(AgentEntity agent) { agent.setEnabled(true); 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"); return agent; } public AgentEntity updateAgent(AgentEntity agent) { // Detect enabled-flag flip so the lifecycle event reflects the // intent rather than every metadata edit. Reading the prior row // is cheap and gives us a clean diff source. AgentEntity prior = agentMapper.selectById(agent.getId()); // Only re-validate uniqueness when the name actually changes — // a pure metadata edit (icon, prompt, ...) shouldn't pay the // SELECT cost or risk a false positive against the row itself. if (prior != null && agent.getName() != null && !agent.getName().equals(prior.getName())) { // Workspace cannot be moved (Controller pins it to prior.workspaceId), // so reuse it for the lookup even if the incoming DTO left it null. if (agent.getWorkspaceId() == null) { agent.setWorkspaceId(prior.getWorkspaceId()); } requireUniqueName(agent, agent.getId()); } if ("dsh".equalsIgnoreCase(agent.getRuntimeType())) { validateDshConfiguration(agent); } agentMapper.updateById(agent); agentInstances.remove(agent.getId()); if (prior != null && prior.getEnabled() != null && !prior.getEnabled().equals(agent.getEnabled())) { publishLifecycle(agent, Boolean.TRUE.equals(agent.getEnabled()) ? "enabled" : "disabled"); } return agent; } /** * Friendly business-code surface for the {@code (workspace_id, name)} * unique index added in V102. * *

The wire shape is the project-wide R<T> envelope: HTTP status * stays 200 (per the convention in {@code R.fail} and the axios * interceptor in {@code mateclaw-ui/src/api/index.ts}); the 409 lives in * the response body's {@code code} field so the front-end can branch * without breaking on an axios error. Without this pre-check the * duplicate save would surface as an opaque * {@code DataIntegrityViolation} stack trace. * * @param excludeId when non-null, skip this row in the lookup so * {@link #updateAgent} doesn't mistake the row for its * own duplicate. */ private void requireUniqueName(AgentEntity agent, Long excludeId) { if (agent.getName() == null || agent.getName().isBlank()) { throw new MateClawException("err.agent.name_required", 400, "Agent 名称不能为空"); } Long workspaceId = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId(); LambdaQueryWrapper q = new LambdaQueryWrapper() .eq(AgentEntity::getWorkspaceId, workspaceId) .eq(AgentEntity::getName, agent.getName()); if (excludeId != null) { q.ne(AgentEntity::getId, excludeId); } Long count = agentMapper.selectCount(q); if (count != null && count > 0) { throw new MateClawException("err.agent.duplicate_name", 409, "工作区内已存在同名 Agent: " + agent.getName()); } } public void deleteAgent(Long id) { AgentEntity prior = agentMapper.selectById(id); agentMapper.deleteById(id); agentInstances.remove(id); if (prior != null) publishLifecycle(prior, "terminated"); } /** * Best-effort publish of an {@link AgentLifecycleEvent}. A publish * failure must never roll back the agent CRUD that just succeeded — * the agent_lifecycle trigger surface is observability, not the * canonical record. */ private void publishLifecycle(AgentEntity agent, String phase) { if (events == null || agent == null) return; try { events.publishEvent(new AgentLifecycleEvent( agent.getWorkspaceId() == null ? 0L : agent.getWorkspaceId(), agent.getId() == null ? 0L : agent.getId(), agent.getName(), phase, System.currentTimeMillis())); } catch (Exception e) { log.warn("[AgentService] lifecycle publish failed for agent {} ({}): {}", agent.getId(), phase, e.getMessage()); } } /** * 清除 Agent 运行时缓存(绑定变更后需调用,使下次对话重新构建 Agent) */ public void invalidateAgentCache(Long agentId) { agentInstances.remove(agentId); } /** * Invalidate the cached agent instance only for shared workspace files that * are baked into the system prompt. Owner-scoped PERSONAL memory rows are * injected per turn, so updating them must not force a cold agent rebuild. */ @org.springframework.context.event.EventListener public void onWorkspaceFileChanged(vip.mate.workspace.document.event.WorkspaceFileChangedEvent event) { if (event.agentId() != null && event.affectsSystemPrompt()) { agentInstances.remove(event.agentId()); } } /** * Invalidate cached agents whenever their team's composition or settings * change. The team context block is baked into the system prompt at build * time, so membership edits would otherwise stay invisible until restart. */ @EventListener public void onTeamChanged(TeamChangedEvent event) { if (event.agentIds() != null) { event.agentIds().forEach(agentInstances::remove); } } // ==================== 运行时入口 ==================== /** * New-user-turn housekeeping: drop auto-recorded ledger entries left * over from the previous turn. They mark past tool calls as DONE, and * the ledger snapshot's "已完成的步骤不要重复执行" instruction would * otherwise stop the agent from re-running status-query tools when the * user repeats a question that needs fresh data. * *

Only the fresh-turn entries ({@code chat} / {@code chatStream} / * {@code chatStructuredStream} / {@code execute}) call this. The * approval-replay entries ({@code chatWithReplay*}) resume the SAME * logical turn after a tool approval and must keep the safety net for * 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; } try { progressLedgerService.clearAutoRecorded(conversationId); } catch (Exception e) { // Ledger housekeeping must never block the chat itself. log.warn("Failed to clear auto-recorded ledger entries for {}: {}", conversationId, e.getMessage()); } } public String chat(Long agentId, String message, String conversationId) { return chat(agentId, message, conversationId, ChatOrigin.EMPTY); } /** * RFC-063r §2.5: preferred entry — accepts the originating * {@link ChatOrigin} so channel binding and workspace context propagate * down to {@code @Tool} methods via Spring AI {@link org.springframework.ai.chat.model.ToolContext}. */ public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) { clearAutoRecordedForNewTurn(conversationId); if (isDshAgent(agentId)) { return collectChatResult(chatStructuredStream(agentId, message, conversationId, "", null, origin != null ? origin : ChatOrigin.EMPTY)).content(); } trackMemoryRecalls(agentId, message, origin); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY); try { return withLifecycleSync(agentId, message, conversationId, (msg, convId) -> agent.chat(msg, convId)); } finally { ChatOriginHolder.clear(); } } /** * Sync chat that also captures token usage and runtime model attribution * from the agent graph's {@code _usage_final} event. Equivalent to * subscribing to {@link #chatStructuredStream} and joining all content * deltas — produces the same assistant text as {@link #chat} but exposes * the usage figures so callers can persist them on the assistant message. * *

Prefer this entry over {@link #chat} for any path that writes the * reply to {@code mate_message} (sync HTTP endpoint, voice WebSocket, * cron task, post-approval replay); the plain {@link #chat} stays as the * thin wrapper for fire-and-forget invocations where usage is not needed. */ public ChatResult chatWithUsage(Long agentId, String message, String conversationId) { return chatWithUsage(agentId, message, conversationId, ChatOrigin.EMPTY); } public ChatResult chatWithUsage(Long agentId, String message, String conversationId, ChatOrigin origin) { return collectChatResult(chatStructuredStream(agentId, message, conversationId, "", null, origin)); } public Flux chatStream(Long agentId, String message, String conversationId) { return chatStream(agentId, message, conversationId, ChatOrigin.EMPTY); } public Flux chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) { clearAutoRecordedForNewTurn(conversationId); if (isDshAgent(agentId)) { return chatStructuredStream(agentId, message, conversationId, "", null, origin != null ? origin : ChatOrigin.EMPTY) .filter(delta -> delta.content() != null) .map(StreamDelta::content); } trackMemoryRecalls(agentId, message, origin); 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. ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY; return Flux.defer(() -> { ChatOriginHolder.set(captured); return withLifecycleFlux(agentId, message, conversationId, (msg, convId) -> agent.chatStream(msg, convId), chunk -> chunk); }).doFinally(signal -> ChatOriginHolder.clear()); } public Flux chatStructuredStream(Long agentId, String message, String conversationId) { return chatStructuredStream(agentId, message, conversationId, "", null, ChatOrigin.EMPTY); } public Flux chatStructuredStream(Long agentId, String message, String conversationId, String requesterId) { return chatStructuredStream(agentId, message, conversationId, requesterId, null, ChatOrigin.EMPTY); } public Flux chatStructuredStream(Long agentId, String message, String conversationId, String requesterId, ChatOrigin origin) { return chatStructuredStream(agentId, message, conversationId, requesterId, null, origin); } public Flux chatStructuredStream(Long agentId, String message, String conversationId, String requesterId, String thinkingLevel) { return chatStructuredStream(agentId, message, conversationId, requesterId, thinkingLevel, ChatOrigin.EMPTY); } public Flux chatStructuredStream(Long agentId, String message, String conversationId, String requesterId, String thinkingLevel, ChatOrigin origin) { clearAutoRecordedForNewTurn(conversationId); trackMemoryRecalls(agentId, message, origin); 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(dshConversationHistory.enrich( convId, message, msg, origin))), connection -> connection.close()), StreamDelta::content) .doFinally(signal -> ThinkingLevelHolder.clear()); } BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); // 设置请求级思考深度(通过 ThreadLocal 传递到 StateGraph 执行) if (thinkingLevel != null && !thinkingLevel.isBlank()) { ThinkingLevelHolder.set(thinkingLevel); } else { // 尝试从 Agent 默认配置读取 AgentEntity entity = getAgent(agentId); if (entity != null && entity.getDefaultThinkingLevel() != null) { ThinkingLevelHolder.set(entity.getDefaultThinkingLevel()); } else { ThinkingLevelHolder.clear(); } } ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY; if (agent instanceof StructuredStreamCapable capable) { return Flux.defer(() -> { ChatOriginHolder.set(captured); return withLifecycleFlux(agentId, message, conversationId, (msg, convId) -> capable.chatStructuredStream(msg, convId, requesterId != null ? requesterId : "") .doFinally(signal -> ThinkingLevelHolder.clear()), StreamDelta::content); }) .doFinally(signal -> ChatOriginHolder.clear()); } // 降级:不支持结构化流的 Agent,包装为纯内容流 ThinkingLevelHolder.clear(); return Flux.defer(() -> { ChatOriginHolder.set(captured); return withLifecycleFlux(agentId, message, conversationId, (msg, convId) -> agent.chatStream(msg, convId) .map(chunk -> new StreamDelta(chunk, null)), StreamDelta::content); }) .doFinally(signal -> ChatOriginHolder.clear()); } public String execute(Long agentId, String goal, String conversationId) { return execute(agentId, goal, conversationId, ChatOrigin.EMPTY); } public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) { clearAutoRecordedForNewTurn(conversationId); trackMemoryRecalls(agentId, goal, origin); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY); try { return withLifecycleSync(agentId, goal, conversationId, (msg, convId) -> agent.execute(msg, convId)); } finally { ChatOriginHolder.clear(); } } /** * 带工具重放的 chat 调用(审批通过后由 ChannelMessageRouter 或 ApprovalController 调用) * * @param agentId Agent ID * @param userMessage 用户消息(如"继续执行已批准的工具") * @param conversationId 会话 ID * @param toolCallPayload 要重放的工具调用 JSON * @return Agent 回复 */ public String chatWithReplay(Long agentId, String userMessage, String conversationId, String toolCallPayload) { return chatWithReplay(agentId, userMessage, conversationId, toolCallPayload, ChatOrigin.EMPTY); } public String chatWithReplay(Long agentId, String userMessage, String conversationId, String toolCallPayload, ChatOrigin origin) { trackMemoryRecalls(agentId, userMessage, origin); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY); try { return withLifecycleSync(agentId, userMessage, conversationId, (msg, convId) -> agent.chatWithReplay(msg, convId, toolCallPayload)); } finally { ChatOriginHolder.clear(); } } /** * Replay-after-approval that also captures token usage and runtime model * attribution. Mirrors {@link #chatWithUsage} for the * approval-resumption path used by {@code ChannelMessageRouter}. */ public ChatResult chatWithReplayWithUsage(Long agentId, String userMessage, String conversationId, String toolCallPayload, ChatOrigin origin) { return collectChatResult(chatWithReplayStream(agentId, userMessage, conversationId, toolCallPayload, "", origin != null ? origin : ChatOrigin.EMPTY)); } /** * Subscribe to a structured stream and collapse it into a single * {@link ChatResult}: append all content deltas, capture the trailing * {@code _usage_final} event for token and model attribution. */ private ChatResult collectChatResult(Flux stream) { return ChatResultCollector.collect(stream); } /** * 带工具重放的流式调用(Web 端审批通过后使用,通过 SSE 推送结果) */ public Flux chatWithReplayStream(Long agentId, String userMessage, String conversationId, String toolCallPayload) { return chatWithReplayStream(agentId, userMessage, conversationId, toolCallPayload, "", ChatOrigin.EMPTY); } public Flux chatWithReplayStream(Long agentId, String userMessage, String conversationId, String toolCallPayload, String requesterId) { return chatWithReplayStream(agentId, userMessage, conversationId, toolCallPayload, requesterId, ChatOrigin.EMPTY); } public Flux chatWithReplayStream(Long agentId, String userMessage, String conversationId, String toolCallPayload, String requesterId, ChatOrigin origin) { trackMemoryRecalls(agentId, userMessage, origin); BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY; if (goalApprovalReplay != null && goalApprovalReplay.applies(captured)) { return Flux.using(() -> acquireTurn(conversationId), permit -> goalApprovalReplay.replay(captured, toolCallPayload, fresh -> { ChatOrigin previous = ChatOriginHolder.get(); ChatOriginHolder.set(fresh); try { return vip.mate.agent.context.GoalContinuationContext.call(true, () -> invokeWithLifecycleFlux(agentId, userMessage, conversationId, (msg, convId) -> agent.chatWithReplayStream(msg, convId, toolCallPayload, requesterId != null ? requesterId : ""), StreamDelta::content)); } finally { if (previous == ChatOrigin.EMPTY) ChatOriginHolder.clear(); else ChatOriginHolder.set(previous); } }), vip.mate.agent.runtime.ConversationTurnGate.Permit::close); } return Flux.defer(() -> { ChatOriginHolder.set(captured); return withLifecycleFlux(agentId, userMessage, conversationId, (msg, convId) -> agent.chatWithReplayStream(msg, convId, toolCallPayload, requesterId != null ? requesterId : ""), StreamDelta::content); }) .doFinally(signal -> ChatOriginHolder.clear()); } public AgentState getAgentState(Long agentId) { Map variants = agentInstances.get(agentId); if (variants == null || variants.isEmpty()) { return AgentState.IDLE; } // An Agent may have several cached graph variants (one per pinned // model). Report the first non-IDLE state so a turn running on any // variant stays visible. for (BaseAgent agent : variants.values()) { AgentState state = agent.getState(); if (state != AgentState.IDLE) { return state; } } return AgentState.IDLE; } // ==================== 缓存管理 ==================== public void refreshAgent(Long agentId) { agentInstances.remove(agentId); log.info("Agent instance cache cleared: {}", agentId); } public void refreshAllAgents() { agentInstances.clear(); log.info("All agent instance caches cleared"); } @EventListener public void onModelConfigChanged(ModelConfigChangedEvent event) { refreshAllAgents(); log.info("Agent caches refreshed after model config change: {}", event.reason()); } @EventListener public void onToolGuardConfigChanged(vip.mate.tool.guard.service.ToolGuardConfigService.ToolGuardConfigChangedEvent event) { refreshAllAgents(); log.info("Agent caches refreshed after tool guard config change (denied tools may have changed)"); } /** * Issue #289: an MCP server connecting / disconnecting / reconnecting * changes the live tool set, but cached agents snapshot their tools at * build time. Clear the cache so the next turn rebuilds against the * current MCP tools instead of replying "from memory" with a stale, * tool-less graph. */ @EventListener public void onMcpServerChanged(vip.mate.tool.mcp.event.McpServerChangedEvent event) { refreshAllAgents(); log.info("Agent caches refreshed after MCP server change: {}", event.reason()); } /** * Listen for MCP connection-loss events and clear the agent cache. * *

Previously this listener was intentionally omitted (the design * doc said "only listen to McpServerChangedEvent, not * McpConnectionLostEvent") because {@link McpServerService} auto-heals * and publishes McpServerChangedEvent on reconnect. However, between * disconnect and reconnect, cached agents still hold the old * {@code AgentToolSet} snapshot whose MCP tool callbacks point at a * dead client — calls either time out (5 min default) or throw. * *

Clearing the cache on disconnect ensures the next agent build * sees the live connection state: {@link McpClientManager} will * either skip the dead server or fall back to {@code lastGoodCallbacks} * with proper error handling, rather than letting the LLM discover * the breakage by timing out. * *

Cost is low: {@code McpServerService} already debounces reconnect * attempts by 10s, and {@code refreshAllAgents} is a Map.clear(). * The subsequent reconnect will fire another McpServerChangedEvent, * which clears the cache again — at most two clears per disconnect * cycle, which is acceptable. */ @EventListener public void onMcpConnectionLost(vip.mate.tool.mcp.event.McpConnectionLostEvent event) { refreshAllAgents(); log.warn("Agent caches refreshed after MCP connection lost: serverId={}, reason={}", event.serverId(), event.reason()); } // ==================== Lifecycle helpers ==================== /** * Wraps a synchronous agent call with lifecycle mediator hooks. * When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior). * * P1-1 fix: prefetchAll result is now prepended to userMessage as <memory-context> block. * P1-4 fix: N/A for sync (no cancel/error signal issue). */ private String withLifecycleSync(Long agentId, String message, String conversationId, java.util.function.BiFunction invoke) { try (var permit = acquireTurn(conversationId)) { return invokeWithLifecycleSync(agentId,message,conversationId,invoke); } } private String invokeWithLifecycleSync(Long agentId, String message, String conversationId, java.util.function.BiFunction invoke) { safeRegister(conversationId, agentId); try { if (!memoryProperties.isLifecycleMediatorEnabled()) { return invoke.apply(message, conversationId); } String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get()); TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey); String memoryContext = lifecycleMediator.beforeLlmCall(ctx); // Inject memory context into the user message (RFC-037 §3.3) String enrichedMessage = injectMemoryContext(message, memoryContext); String result = invoke.apply(enrichedMessage, conversationId); lifecycleMediator.afterLlmCall(ctx, result != null ? result : ""); return result; } finally { safeUnregister(conversationId); } } /** * Wraps a streaming agent call with lifecycle mediator hooks. * When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior). * * P1-1 fix: prefetchAll result is now prepended to userMessage. * P1-4 fix: afterLlmCall only fires on COMPLETE signal, not on cancel/error. */ private Flux withLifecycleFlux(Long agentId, String message, String conversationId, java.util.function.BiFunction> invoke, Function contentExtractor) { return Flux.using(() -> acquireTurn(conversationId), permit -> invokeWithLifecycleFlux(agentId,message,conversationId,invoke,contentExtractor), vip.mate.agent.runtime.ConversationTurnGate.Permit::close); } private vip.mate.agent.runtime.ConversationTurnGate.Permit acquireTurn(String conversationId) { var permit = turnGate.tryAcquire(conversationId); if (permit == null) throw new MateClawException("err.agent.conversation_busy",409,"Conversation is already running"); return permit; } private Flux invokeWithLifecycleFlux(Long agentId, String message, String conversationId, java.util.function.BiFunction> invoke, Function contentExtractor) { boolean goalContinuation = vip.mate.agent.context.GoalContinuationContext.active(); safeRegister(conversationId, agentId); try { if (!memoryProperties.isLifecycleMediatorEnabled()) { return invoke.apply(message, conversationId) .doFinally(s -> safeUnregister(conversationId, goalContinuation)); } String ownerKey = memoryOwnerResolver.resolve(ChatOriginHolder.get()); TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message, ownerKey); String memoryContext = lifecycleMediator.beforeLlmCall(ctx); String enrichedMessage = injectMemoryContext(message, memoryContext); StringBuilder reply = new StringBuilder(); return invoke.apply(enrichedMessage, conversationId) .doOnNext(item -> { String text = contentExtractor.apply(item); if (text != null) { reply.append(text); } }) .doOnComplete(() -> lifecycleMediator.afterLlmCall(ctx, reply.toString())) .doOnError(e -> log.debug("[Memory] Stream error, skipping afterLlmCall: {}", e.getMessage())) .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, goalContinuation); throw e; } } /** C5 helper — null-safe register so tests without the registry don't NPE. */ private void safeRegister(String conversationId, Long agentId) { if (runningConversationRegistry != null) { runningConversationRegistry.register(conversationId, agentId); } } /** 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 trackMemoryRecalls(Long agentId, String message, ChatOrigin origin) { String ownerKey = memoryProperties.isLifecycleMediatorEnabled() ? memoryOwnerResolver.resolve(origin != null ? origin : ChatOrigin.EMPTY) : null; memoryRecallTracker.trackRecalls(agentId, message, ownerKey); } 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(); } /** * Prepend memory-context block to user message if non-empty. * Does not pollute build-time system prompt snapshot. */ private String injectMemoryContext(String message, String memoryContext) { if (memoryContext == null || memoryContext.isBlank()) return message; return memoryContext + "\n\n" + message; } // ==================== 内部方法 ==================== /** * Resolve (and cache) the Agent graph for a conversation, honouring the * conversation's pinned model. Conversations with no pin — IM channels * before issue #183 fix, cron, sub-tasks, or rows not yet created — * resolve to the shared Agent / global-default graph. * *

Defensive normalisation: a half-populated pair (provider but no * model, or vice versa) is treated as unpinned. Without this guard, a * partially-cleared admin UI write could end up cached as a key like * {@code "volcano::"} which {@link #getOrBuildAgent} would then try to * build, only to fail at provider-resolution time on every turn. */ private BaseAgent getOrBuildAgentForConversation(Long agentId, String conversationId) { String provider = null; String modelName = null; if (conversationId != null && !conversationId.isBlank()) { ConversationEntity conv = conversationMapper.selectOne( new LambdaQueryWrapper() .eq(ConversationEntity::getConversationId, conversationId)); if (conv != null) { provider = blankToNull(conv.getModelProvider()); modelName = blankToNull(conv.getModelName()); // Half-populated pair → treat as unpinned. Pinning requires // a complete (provider, model) tuple — see #183 follow-up // hardening so a stale row written by an earlier broken // admin UI release doesn't loop the cache on an invalid key. if (provider == null || modelName == null) { provider = null; modelName = null; } } } return getOrBuildAgent(agentId, provider, modelName); } /** Map empty / whitespace strings to null so the pinned-check is one branch. */ private static String blankToNull(String s) { return (s == null || s.isBlank()) ? null : s; } private BaseAgent getOrBuildAgent(Long agentId) { return getOrBuildAgent(agentId, null, null); } private BaseAgent getOrBuildAgent(Long agentId, String modelProvider, String modelName) { boolean pinned = modelProvider != null && !modelProvider.isBlank() && modelName != null && !modelName.isBlank(); String modelKey = pinned ? modelProvider + "::" + modelName : ""; return agentInstances .computeIfAbsent(agentId, id -> new ConcurrentHashMap<>()) .computeIfAbsent(modelKey, key -> { AgentEntity entity = getAgent(agentId); if (!Boolean.TRUE.equals(entity.getEnabled())) { throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + entity.getName()); } return agentGraphBuilder.build(entity, modelProvider, modelName); }); } // ==================== StreamDelta ==================== public record StreamDelta(String content, String thinking, String eventType, Map eventData, boolean persistenceOnly, boolean segmentOnly, ContentKind kind) { // 兼容构造器(广播+持久化) public StreamDelta(String content, String thinking) { this(content, thinking, null, null, false, false, null); } // 显式 5-参构造器:保留旧调用点对 (content, thinking, eventType, eventData, persistenceOnly) 的兼容 public StreamDelta(String content, String thinking, String eventType, Map eventData, boolean persistenceOnly) { this(content, thinking, eventType, eventData, persistenceOnly, false, null); } // 兼容构造器:kind 出现之前的 6 参 canonical 形态 public StreamDelta(String content, String thinking, String eventType, Map eventData, boolean persistenceOnly, boolean segmentOnly) { this(content, thinking, eventType, eventData, persistenceOnly, segmentOnly, null); } /** 仅用于持久化,不再广播(内容已由 NodeStreamingChatHelper 实时广播过) */ public static StreamDelta persistOnly(String content, String thinking) { return new StreamDelta(content, thinking, null, null, true, false, null); } /** {@link #persistOnly(String, String)} 带内容语义标注的变体。 */ public static StreamDelta persistOnly(String content, String thinking, ContentKind kind) { return new StreamDelta(content, thinking, null, null, true, false, kind); } /** * Final-answer content of the terminal turn. {@code alreadyStreamed} * decides broadcast suppression exactly like the persistOnly/plain * split at the emission sites did before the kind tag existed. */ public static StreamDelta finalAnswer(String content, boolean alreadyStreamed) { return new StreamDelta(content, null, null, null, alreadyStreamed, false, ContentKind.FINAL_ANSWER); } /** * Per-iteration narrative routing for ReasoningNode / SummarizingNode output. * *

The accumulator should: *

* *

Implies {@code persistenceOnly} (no broadcast) at the accumulator * layer, but is a stricter promise: nothing reaches the top-level * persisted content field via this flavor. */ public static StreamDelta segmentOnly(String content, String thinking) { return new StreamDelta(content, thinking, null, null, true, true, null); } /** {@link #segmentOnly(String, String)} 带内容语义标注的变体。 */ public static StreamDelta segmentOnly(String content, String thinking, ContentKind kind) { return new StreamDelta(content, thinking, null, null, true, true, kind); } public static StreamDelta empty() { return new StreamDelta(null, null, null, null, false, false, null); } public static StreamDelta event(String type, Map data) { return new StreamDelta(null, null, type, data, false, false, null); } public boolean isEvent() { return eventType != null; } public boolean hasPayload() { return StringUtils.hasText(content) || StringUtils.hasText(thinking); } public int contentLength() { return content != null ? content.length() : 0; } public int thinkingLength() { return thinking != null ? thinking.length() : 0; } } // ==================== ChatResult ==================== /** * Sync chat result carrying the assistant reply alongside the usage * attribution that the streaming path exposes via the {@code _usage_final} * event. Use this when callers need to persist {@code promptTokens} / * {@code completionTokens} / {@code runtimeModel} / {@code runtimeProvider} * on the assistant message row but cannot subscribe to the structured * stream directly (cron tasks, sync HTTP endpoints, voice WebSocket, * post-approval replays). */ public record ChatResult(String content, int promptTokens, int completionTokens, String runtimeModel, String runtimeProvider, String finishReason) { public ChatResult(String content, int promptTokens, int completionTokens, String runtimeModel, String runtimeProvider) { this(content, promptTokens, completionTokens, runtimeModel, runtimeProvider, null); } public static ChatResult contentOnly(String content) { return new ChatResult(content != null ? content : "", 0, 0, null, null, null); } } }