mateclaw/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java

1895 lines
117 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package vip.mate.agent;
// PR-0b: DashScope imports moved with the construction code into DashScopeChatModelBuilder.
import com.alibaba.cloud.ai.graph.CompiledGraph;
import com.alibaba.cloud.ai.graph.CompileConfig;
import com.alibaba.cloud.ai.graph.KeyStrategy;
import com.alibaba.cloud.ai.graph.KeyStrategyFactory;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.action.AsyncEdgeAction;
import com.alibaba.cloud.ai.graph.action.AsyncNodeAction;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import vip.mate.agent.graph.StateGraphReActAgent;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
import vip.mate.agent.graph.edge.ObservationDispatcher;
import vip.mate.agent.graph.edge.ReasoningDispatcher;
import vip.mate.agent.graph.lifecycle.ReActLifecycleListener;
import vip.mate.agent.graph.node.*;
import vip.mate.agent.graph.state.MateClawStateAccessor;
import vip.mate.agent.graph.observation.ObservationProcessor;
import vip.mate.agent.graph.plan.StateGraphPlanExecuteAgent;
import vip.mate.agent.graph.plan.edge.PlanGenerationDispatcher;
import vip.mate.agent.graph.plan.edge.StepProgressDispatcher;
import vip.mate.agent.graph.plan.node.*;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.agent.graph.state.MateClawStateKeys;
import vip.mate.agent.binding.service.AgentBindingService;
import vip.mate.agent.model.AgentEntity;
import org.springframework.beans.factory.annotation.Autowired;
import vip.mate.config.GraphObservationProperties;
import vip.mate.config.ReasoningRetentionProperties;
import vip.mate.exception.MateClawException;
import vip.mate.llm.chatmodel.HttpTimeouts;
import vip.mate.llm.chatmodel.OpenAiCompatibleChatModelBuilder;
import vip.mate.llm.chatmodel.ReasoningEffortResolver;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelFamily;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.agent.context.PrefixBudgetPlan;
import vip.mate.agent.context.PrefixBudgetPlanner;
import vip.mate.agent.context.TokenEstimator;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.probe.ModelContextWindowResolver;
import vip.mate.llm.routing.ProviderModelRef;
import vip.mate.llm.routing.ProviderRouter;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.llm.service.ModelProviderService;
import vip.mate.planning.service.PlanningService;
import vip.mate.skill.runtime.SkillCatalogRenderer;
import vip.mate.skill.service.SkillService;
import vip.mate.system.service.SystemSettingService;
import vip.mate.tool.ToolRegistry;
import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
import vip.mate.tool.mcp.runtime.McpProgressContext;
import vip.mate.memory.spi.MemoryManager;
import vip.mate.workspace.document.WorkspaceFileService;
import vip.mate.tool.guard.service.ToolGuardService;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.team.service.TeamContextBuilder;
import vip.mate.team.service.TeamPlanBridge;
import vip.mate.wiki.service.WikiContextService;
import java.lang.reflect.Field;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Agent 图构建器
* <p>
* 纯构建器,不做执行。从 AgentService 中提取出所有 Agent 实例构建逻辑,
* 包括模型创建、图编译、prompt 增强等。
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AgentGraphBuilder {
private final ToolRegistry toolRegistry;
private final AgentBindingService agentBindingService;
private final SkillService skillService;
private final vip.mate.skill.runtime.SkillRuntimeService skillRuntimeService;
private final vip.mate.tool.disclosure.ToolDisclosureService toolDisclosureService;
private final vip.mate.agent.progress.ProgressLedgerService progressLedgerService;
/** Escape hatch: when false, the load_skill meta tool is not advertised. */
@org.springframework.beans.factory.annotation.Value(
"${mateclaw.skill.disclosure.load-skill-tool.enabled:true}")
private boolean loadSkillToolEnabled;
/** Escape hatch: when false, the final answer is sent verbatim without Markdown normalization. */
@org.springframework.beans.factory.annotation.Value(
"${mate.agent.markdown-normalize-enabled:true}")
private boolean markdownNormalizeEnabled;
private final ConversationService conversationService;
private final TeamContextBuilder teamContextBuilder;
private final TeamPlanBridge teamPlanBridge;
private final ModelConfigService modelConfigService;
private final ModelProviderService modelProviderService;
private final ModelContextWindowResolver contextWindowResolver;
private final PrefixBudgetPlanner prefixBudgetPlanner;
private final ToolUsageRecencyTracker toolUsageRecencyTracker;
private final McpProgressContext progressContext;
private final vip.mate.llm.service.ModelCapabilityService modelCapabilityService;
private final ProviderRouter providerRouter;
private final PlanningService planningService;
private final ToolGuardService toolGuardService;
private final vip.mate.tool.guard.service.ToolGuardConfigService toolGuardConfigService;
private final ApprovalWorkflowService approvalService;
private final ChatStreamTracker streamTracker;
private final SystemSettingService systemSettingService;
// PR-0b: dashScopeChatModel + dashScopeConnectionProperties live on DashScopeChatModelBuilder now.
private final RetryTemplate retryTemplate;
private final GraphObservationProperties graphObservationProperties;
private final vip.mate.config.ToolTimeoutProperties toolTimeoutProperties;
private final MemoryManager memoryManager;
private final WorkspaceFileService workspaceFileService;
private final vip.mate.agent.context.ConversationWindowManager conversationWindowManager;
private final vip.mate.llm.chatgpt.ChatGPTResponsesClient chatGPTResponsesClient;
private final WikiContextService wikiContextService;
private final vip.mate.workspace.core.service.WorkspaceService workspaceService;
private final vip.mate.llm.cache.AnthropicCacheOptionsFactory anthropicCacheOptionsFactory;
private final vip.mate.llm.cache.LlmCacheMetricsAggregator llmCacheMetricsAggregator;
private final vip.mate.agent.graph.executor.ToolResultStorage toolResultStorage;
private final vip.mate.tool.ToolConcurrencyRegistry toolConcurrencyRegistry;
private final vip.mate.i18n.I18nService i18nService;
private final vip.mate.llm.failover.ProviderHealthTracker providerHealthTracker;
private final vip.mate.llm.chatmodel.ProviderChatModelFactory chatModelFactory;
private final vip.mate.llm.failover.AvailableProviderPool providerPool;
private final vip.mate.tool.document.GeneratedFileCache generatedFileCache;
/** DashScope-specific construction lives here; only called for the built-in-search log. */
private final vip.mate.llm.chatmodel.DashScopeChatModelBuilder dashScopeBuilder;
private final vip.mate.llm.routing.MultimodalRouter multimodalRouter;
private final vip.mate.llm.routing.MediaCaptionService mediaCaptionService;
private final vip.mate.goal.service.GoalService goalService;
private final vip.mate.goal.service.GoalEvaluationService goalEvaluationService;
private final vip.mate.goal.service.GoalFollowupService goalFollowupService;
private final vip.mate.goal.config.GoalProperties goalProperties;
/** C4: per-conversation environment notification registry, injected into ReasoningNode. */
private final vip.mate.agent.runtime.RunningConversationRegistry runningConversationRegistry;
/**
* Auto-grant resolver wired into the executor so an active
* {@code mate_approval_grant} row can skip {@code createPending()} for matching
* tool calls. Together with {@link #workspaceLookupCache}, these two deps form
* the auto-grant entry point; the executor's null-guard turns the feature off
* cleanly if either is missing.
*/
private final vip.mate.approval.grant.service.ApprovalGrantResolver approvalGrantResolver;
/** Conversation→workspaceId lookup cache; see {@link #approvalGrantResolver}. */
private final vip.mate.approval.grant.WorkspaceLookupCache workspaceLookupCache;
/**
* Optional audit pipeline. Setter injection (rather than a constructor
* parameter) keeps existing constructor-based wiring + tests intact.
* When present, the executor receives it so child-agent denied-tool
* attempts can be recorded.
*/
private vip.mate.audit.service.AuditEventService auditEventService;
@Autowired(required = false)
public void setAuditEventService(vip.mate.audit.service.AuditEventService s) {
this.auditEventService = s;
}
/**
* Reasoning retention policy for ReAct turns. Setter injection so the
* {@code @RequiredArgsConstructor} signature stays stable for the unit
* constructions across the test suite; null in those, where the agent's own
* default (keep every iteration) applies.
*/
private ReasoningRetentionProperties reasoningRetentionProperties;
@Autowired(required = false)
public void setReasoningRetentionProperties(ReasoningRetentionProperties p) {
this.reasoningRetentionProperties = p;
}
/**
* Optional per-step delegation dependencies for the Plan-Execute graph.
* Setter injection (like {@link #auditEventService}) breaks the
* {@code AgentService ⇆ AgentGraphBuilder} construction cycle. Null when not
* wired (legacy / test) — per-step delegation is then simply disabled.
*/
private AgentService agentService;
// @Lazy on the injection point: inject a lazy-resolution proxy so the
// AgentService ⇆ AgentGraphBuilder cycle is broken at bean-creation time
// (the real bean is resolved on first use, when the graph is built).
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setAgentService(@org.springframework.context.annotation.Lazy AgentService agentService) {
this.agentService = agentService;
}
private vip.mate.tool.builtin.DelegateAgentTool delegateAgentTool;
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setDelegateAgentTool(
@org.springframework.context.annotation.Lazy vip.mate.tool.builtin.DelegateAgentTool delegateAgentTool) {
this.delegateAgentTool = delegateAgentTool;
}
/**
* 根据 AgentEntity 构建完整的 Agent 实例(沿用 Agent / 全局默认模型)。
*/
public BaseAgent build(AgentEntity entity) {
return build(entity, null, null);
}
/**
* Resolve the model the runtime should use, honouring the precedence
* <em>conversation pin &gt; Agent model override &gt; global default</em>.
* A conversation pin that no longer resolves to an enabled model (the model
* was disabled or deleted after it was picked) silently degrades to the
* Agent / global default rather than failing the chat.
*/
private ModelConfigEntity resolveRuntimeBaseModel(String modelProvider, String modelName,
String agentModelName) {
if (modelProvider != null && !modelProvider.isBlank()
&& modelName != null && !modelName.isBlank()) {
ModelConfigEntity pinned = modelConfigService.findEnabledModel(modelProvider, modelName);
if (pinned != null) {
return pinned;
}
log.info("Conversation model pin {}/{} is no longer an enabled model — "
+ "falling back to the Agent / global default", modelProvider, modelName);
}
return modelConfigService.resolveModel(agentModelName);
}
/**
* True iff the caller passed a complete (provider, model) pin AND that
* pair resolves to an enabled model row. Used by {@link #build} to decide
* whether the explicit pick should bypass capability-driven routing.
*/
private boolean pinResolvesToEnabledModel(String modelProvider, String modelName) {
if (modelProvider == null || modelProvider.isBlank()
|| modelName == null || modelName.isBlank()) {
return false;
}
try {
return modelConfigService.findEnabledModel(modelProvider, modelName) != null;
} catch (Exception e) {
return false;
}
}
/**
* True when the Agent declared its own modelName and that name resolved to
* a real enabled row (rather than silently falling back to the system default).
*/
private boolean agentModelOverrideResolved(AgentEntity entity, ModelConfigEntity resolved) {
if (entity == null || resolved == null) return false;
String agentModelName = entity.getModelName();
if (agentModelName == null || agentModelName.isBlank()) return false;
return agentModelName.equalsIgnoreCase(resolved.getModelName());
}
/**
* 根据 AgentEntity 构建完整的 Agent 实例。
*
* <p>{@code modelProvider} / {@code modelName} carry an optional
* per-conversation model pin; when both are blank the build falls back to
* the Agent's model override, then the global default.</p>
*/
public BaseAgent build(AgentEntity entity, String modelProvider, String modelName) {
AgentToolSet toolSet = toolRegistry.getEnabledToolSet();
// Move 6 — Permission flattening at build time.
//
// Two layers of tool filtering exist in MateClaw:
// (1) Build-time filter (HERE) — decides which tools the model SEES
// in the tool list. Computed once per agent build; stable for
// the agent's lifecycle unless bindings change.
// (2) Runtime guard (ToolGuardService.evaluate) — decides which
// tools the model can CALL. Runs on every invocation; checks
// workspace boundaries, sensitive paths, credential exposure,
// shell command patterns, and approval workflows. All dynamic
// (depends on tool arguments, not just tool name).
//
// The build-time filter previously ran as 4 separate passes
// (deny → allow → deny → exclude). Move 6 consolidates them into
// a single deny-set + a single allow-set, applied in two passes:
// denied = global denied skill-discovery denied {load_skill if disabled}
// allowed = agent's bound tools (null = global default)
Set<String> deniedTools = new java.util.LinkedHashSet<>(
toolGuardConfigService.getDeniedTools());
deniedTools.addAll(agentBindingService.getSkillDiscoveryDeniedTools(entity.getId()));
if (!loadSkillToolEnabled) {
deniedTools.add("load_skill");
}
toolSet = toolSet.withDeniedToolsFiltered(deniedTools);
// RFC-090 §14.2 — single entry point that merges:
// (a) tools expanded from bound skills' active features, and
// (b) directly bound atomic tools (the Advanced bypass, §9.2 调整 B).
// Three-state semantics: null = no agent-level restriction (use
// global default); non-null (possibly empty) = explicit allowlist.
Set<String> boundTools = agentBindingService.getEffectiveToolNames(entity.getId());
toolSet = toolSet.withAllowedToolsOnly(boundTools); // null = 全局默认
// Resolve the base model with the precedence: per-conversation pin >
// per-Agent model override > global default. resolveRuntimeBaseModel
// looks up enabled-only models and silently degrades an unmatched pin /
// override to the global default, preserving the legacy behaviour for
// Agents and conversations without an explicit choice.
ModelConfigEntity globalDefault;
boolean explicitPinHonoured;
boolean agentOverrideHonoured;
try {
explicitPinHonoured = pinResolvesToEnabledModel(modelProvider, modelName);
globalDefault = resolveRuntimeBaseModel(modelProvider, modelName, entity.getModelName());
agentOverrideHonoured = !explicitPinHonoured
&& agentModelOverrideResolved(entity, globalDefault);
} catch (Exception e) {
throw new MateClawException("err.agent.no_default_model", "无法构建 Agent请先在「设置 → 模型」中配置并启用默认模型");
}
ModelConfigEntity runtimeModel;
if (explicitPinHonoured || agentOverrideHonoured) {
// The caller (admin UI / chat console) handed us a concrete
// (provider, model) pin and it points to an enabled row. Honour
// it verbatim — running providerRouter.selectPrimary here would
// silently swap to a different model whenever a bound skill
// advertised a capability gap, which is exactly the "I switched
// model but the agent kept using the old one" surface. The
// diagnostic below still surfaces capability gaps in the logs
// so operators can see if the pinned model misses a need.
runtimeModel = globalDefault;
} else {
try {
runtimeModel = providerRouter.selectPrimary(entity.getId(), globalDefault);
if (runtimeModel == null) runtimeModel = globalDefault;
} catch (Exception e) {
log.debug("[ProviderRouter] primary selection failed, falling back to global default: {}",
e.getMessage());
runtimeModel = globalDefault;
}
}
// Even after the upgrade, log a WARN when the chosen primary
// still doesn't satisfy needs (e.g. no preferred provider was
// capable). The diagnostic is observability-only.
try {
providerRouter.diagnosePrimary(entity.getId(), runtimeModel);
} catch (Exception e) {
log.debug("[ProviderRouter] diagnostic failed: {}", e.getMessage());
}
ModelProviderEntity provider;
try {
provider = modelProviderService.getProviderConfig(runtimeModel.getProvider());
} catch (Exception e) {
throw new MateClawException("err.agent.model_not_configured", "模型 " + runtimeModel.getModelName()
+ " 的 Provider" + runtimeModel.getProvider() + ")未配置,请检查模型设置");
}
// Safety net: getDefaultModel() already skips unconfigured providers, but guard here
// too so a stale cached model doesn't silently proceed to a broken API call.
if (!modelProviderService.isProviderConfigured(provider.getProviderId())) {
String reason = modelProviderService.getProviderUnavailableReason(provider.getProviderId());
log.warn("Runtime model {}/{} provider not configured ({}); trying fallback",
runtimeModel.getProvider(), runtimeModel.getModelName(), reason);
ModelConfigEntity fallback = findFirstAvailableChatModel();
if (fallback == null) {
throw new MateClawException("err.agent.no_configured_model",
"默认模型 Provider「" + runtimeModel.getProvider() + "」未配置(" + reason
+ "),且找不到其他已配置的 Provider请先在「设置 → 模型」中完成配置");
}
runtimeModel = fallback;
try {
provider = modelProviderService.getProviderConfig(runtimeModel.getProvider());
} catch (Exception e) {
throw new MateClawException("err.agent.model_not_configured", "备用模型 " + runtimeModel.getModelName()
+ " 的 Provider" + runtimeModel.getProvider() + ")获取失败");
}
}
ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel());
// Effective context window: explicit config > local-server probe > null
// (downstream keeps its global-default fallback). Without probing, a
// local 8k/16k model with maxInputTokens unset budgets against the
// 128k global default and the first oversized request fails outright.
Integer effectiveMaxInputTokens = contextWindowResolver.resolveMaxInputTokens(provider, runtimeModel);
// 内置搜索检测DashScope / Kimi但不再移除 WebSearchTool — 两者协同而非互斥
boolean builtinSearchEnabled = false;
Map<String, Object> providerKwargs = modelProviderService.readProviderGenerateKwargs(provider);
if (protocol == ModelProtocol.DASHSCOPE_NATIVE) {
builtinSearchEnabled = dashScopeBuilder.isBuiltinSearchEnabled(runtimeModel, provider);
} else if (OpenAiCompatibleChatModelBuilder.isKimiProvider(provider)
&& Boolean.TRUE.equals(providerKwargs.get("enableSearch"))) {
builtinSearchEnabled = true;
}
if (builtinSearchEnabled) {
// Phase 2: 不再移除 search 工具,改为在 prompt 中设定优先级引导
// 内置搜索作为首选search 工具作为补充/兜底
log.info("内置搜索已开启 (provider={})search 工具保留作为补充通道", provider.getProviderId());
}
// Default 100 if DB row leaves max_iterations null. Negative or zero is an
// explicit opt-in to "no soft cap" — ObservationDispatcher already treats
// maxIterations<=0 as "do not enforce", so the agent runs until the LLM
// emits a final answer (or returnDirect short-circuits). Positive values
// are clamped to the hard ceiling so a misconfigured row can't skip the
// safety net unintentionally.
int rawMaxIter = entity.getMaxIterations() != null ? entity.getMaxIterations() : 100;
int maxIter;
if (rawMaxIter <= 0) {
maxIter = 0;
log.info("Agent {} max_iterations={} → unlimited soft cap (LLM controls termination)",
entity.getId(), rawMaxIter);
} else {
maxIter = Math.min(rawMaxIter, BaseAgent.MAX_ITERATIONS_HARD_CEILING);
if (maxIter != rawMaxIter) {
log.warn("Agent {} max_iterations={} clamped to {} (1..{})",
entity.getId(), rawMaxIter, maxIter, BaseAgent.MAX_ITERATIONS_HARD_CEILING);
}
}
// Prefix injection budget: optional blocks (memory / wiki / skill
// catalog / extension catalog / ledger) share a token budget scaled
// to the model's effective window. The agent's own prompt and the
// tool schemas are never truncated — they are subtracted from the
// budget so the optional blocks absorb the squeeze.
int basePromptTokens = TokenEstimator.estimateTokens(entity.getSystemPrompt());
int toolSchemaTokens = TokenEstimator.estimateToolsTokens(toolSet.callbacks());
PrefixBudgetPlan prefixBudgetPlan = prefixBudgetPlanner.plan(
effectiveMaxInputTokens, basePromptTokens, toolSchemaTokens);
if (basePromptTokens > prefixBudgetPlan.effectiveMaxTokens() / 2) {
log.warn("Agent {} 的身份 prompt 约 {} tokens,已超过模型有效窗口 {} 的一半——"
+ "系统不会截断用户自写的身份 prompt,请自行精简,否则小上下文模型可能无法响应",
entity.getId(), basePromptTokens, prefixBudgetPlan.effectiveMaxTokens());
}
String enhancedPrompt = buildEnhancedPrompt(entity, builtinSearchEnabled, prefixBudgetPlan.memoryTokens());
// Runtime skill-catalog renderer — captures this agent's bound skills,
// effective tool allowlist, model window and workspace; invoked each
// turn by the reasoning / step-execution nodes with the skills loaded
// so far this run so load_skill pins float to the top of the catalog.
SkillCatalogRenderer skillCatalogRenderer = buildSkillCatalogRenderer(
entity, boundTools, effectiveMaxInputTokens);
// Progressive tool catalog is shared by both agent types. ReAct applies
// the split in ReasoningNode; Plan-Execute receives a separate advertised
// set for StepExecutionNode while its executor retains the full scoped
// set, so tool_call can recover a deferred tool in the same action round.
Set<String> autoDemotedTools = Set.of();
if (prefixBudgetPlan.enabled()) {
autoDemotedTools = toolDisclosureService.computeAutoDemotions(
toolSet, prefixBudgetPlan.toolSchemaBudgetTokens());
}
String extensionCatalog = toolDisclosureService.renderExtensionCatalog(
toolSet, effectiveMaxInputTokens, autoDemotedTools);
if (extensionCatalog != null && !extensionCatalog.isBlank()) {
enhancedPrompt = enhancedPrompt + extensionCatalog;
}
// 当前仅支持 DashScope 和 OpenAI-compatible其他协议直接拒绝
if (!supportsStateGraph(protocol)) {
throw new MateClawException("err.agent.protocol_not_supported", "当前不支持协议 " + protocol.getId()
+ ",请切换到 DashScope 或 OpenAI-compatible 模型");
}
BaseAgent agent;
boolean toolCallingEnabled;
if ("plan_execute".equals(entity.getAgentType())) {
agent = buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, entity.getId(),
skillCatalogRenderer, autoDemotedTools);
toolCallingEnabled = true;
log.info("Built StateGraph Plan-Execute agent: {} (maxIterations={}, tools={}, protocol={})",
entity.getName(), maxIter, toolSet.size(), protocol.getId());
} else {
agent = buildReActAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer,
prefixBudgetPlan, autoDemotedTools);
// StateGraph 路径下工具调用由 ActionNode 控制,始终启用
toolCallingEnabled = true;
log.info("Built StateGraph ReAct agent: {} (maxIterations={}, tools={}, protocol={})",
entity.getName(), maxIter, toolSet.size(), protocol.getId());
}
// 设置通用属性
agent.agentId = String.valueOf(entity.getId());
agent.agentName = entity.getName();
agent.systemPrompt = enhancedPrompt;
agent.maxIterations = maxIter;
agent.modelName = runtimeModel.getModelName();
agent.modelCapabilities = modelCapabilityService.resolve(
runtimeModel.getModelName(), runtimeModel.getModalities());
agent.runtimeProviderId = provider != null ? provider.getProviderId() : "";
agent.runtimeModelConfig = runtimeModel;
agent.toolSet = toolSet;
// RFC 48 — wire the goal lookup so buildInitialState can inject
// ACTIVE_GOAL. The node itself stays inert until goalProperties.enabled
// flips true, but tests need findActiveByConversation to work even
// when the runtime path is disabled.
agent.goalService = goalService;
agent.multimodalRouter = multimodalRouter;
agent.mediaCaptionService = mediaCaptionService;
agent.userLocale = resolveLocale();
agent.temperature = runtimeModel.getTemperature();
agent.maxTokens = runtimeModel.getMaxTokens();
agent.maxInputTokens = effectiveMaxInputTokens;
agent.topP = runtimeModel.getTopP();
agent.toolCallingEnabled = toolCallingEnabled;
// Agent-level override takes priority; a relative override is resolved
// under the workspace basePath so admins can express agent directories
// relative to the workspace root (matching the UI hint).
String workspaceBase = null;
if (entity.getWorkspaceId() != null) {
try {
var workspace = workspaceService.getById(entity.getWorkspaceId());
if (workspace != null) {
workspaceBase = workspace.getBasePath();
}
} catch (Exception e) {
log.warn("Failed to lookup workspace basePath for agent {}: {}",
entity.getName(), e.getMessage());
}
}
String resolvedBase;
try {
resolvedBase = resolveAgentBasePath(entity.getWorkspaceBasePath(), workspaceBase);
} catch (IllegalArgumentException e) {
// Override violates the workspace-scoping rule (e.g. admin tried to
// set an absolute path outside the workspace root). Fall back to
// inheriting the workspace basePath so chat stays available, but
// surface the violation in logs so the admin can fix it.
log.warn("Agent {} workspaceBasePath override rejected, falling back to workspace: {}",
entity.getName(), e.getMessage());
resolvedBase = workspaceBase;
}
if (resolvedBase != null && !resolvedBase.isBlank()) {
agent.workspaceBasePath = resolvedBase;
boolean fromOverride = entity.getWorkspaceBasePath() != null
&& !entity.getWorkspaceBasePath().isBlank()
&& resolvedBase.equals(entity.getWorkspaceBasePath());
log.info("Agent {} basePath = {} (source: {})",
entity.getName(), resolvedBase, fromOverride ? "agent-override" : "workspace");
}
log.info("Built agent instance: {} (type={}, protocol={}, tools={}, toolCallingEnabled={})",
entity.getName(), entity.getAgentType(), protocol.getId(),
toolSet.size(), agent.toolCallingEnabled);
return agent;
}
// ==================== Agent 构建方法 ====================
StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, int maxIter) {
return buildReActAgent(toolSet, runtimeModel, maxIter, null);
}
StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
int maxIter, Long agentId) {
return buildReActAgent(toolSet, runtimeModel, maxIter, agentId, null);
}
StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
int maxIter, Long agentId, SkillCatalogRenderer skillCatalogRenderer) {
return buildReActAgent(toolSet, runtimeModel, maxIter, agentId, skillCatalogRenderer, null, Set.of());
}
StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
int maxIter, Long agentId, SkillCatalogRenderer skillCatalogRenderer,
PrefixBudgetPlan prefixBudgetPlan, Set<String> autoDemotedTools) {
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
ChatClient chatClient = ChatClient.create(chatModel);
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort,
runtimeModel, agentId, skillCatalogRenderer, prefixBudgetPlan, autoDemotedTools);
StateGraphReActAgent agent = new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
chatModel, conversationWindowManager, toolSet);
if (reasoningRetentionProperties != null) {
agent.setPersistEveryIterationReasoning(reasoningRetentionProperties.persistsEveryIteration());
}
return agent;
}
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, int maxIter) {
return buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, null);
}
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
int maxIter, Long agentId) {
return buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, agentId, null);
}
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
int maxIter, Long agentId,
SkillCatalogRenderer skillCatalogRenderer) {
return buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, agentId,
skillCatalogRenderer, Set.of());
}
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
int maxIter, Long agentId,
SkillCatalogRenderer skillCatalogRenderer,
Set<String> autoDemotedTools) {
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
ChatClient chatClient = ChatClient.create(chatModel);
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort,
runtimeModel, agentId, skillCatalogRenderer, autoDemotedTools);
return new StateGraphPlanExecuteAgent(chatClient, conversationService, graph, planningService,
chatModel, conversationWindowManager, toolSet);
}
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort, null, null);
}
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig) {
return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort, primaryModelConfig, null);
}
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId) {
return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort,
primaryModelConfig, agentId, null);
}
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId, SkillCatalogRenderer skillCatalogRenderer) {
return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort,
primaryModelConfig, agentId, skillCatalogRenderer, Set.of());
}
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId, SkillCatalogRenderer skillCatalogRenderer,
Set<String> autoDemotedTools) {
try {
List<vip.mate.llm.failover.FallbackEntry> fallbackChain = buildFallbackChain(primaryModelConfig, agentId);
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
providerPool);
if (primaryModelConfig != null) {
// Feed "prompt too long" rejections back into the window resolver
// so the next turn budgets against the server-reported limit.
streamingHelper.setContextLimitObserver(errorMessage ->
contextWindowResolver.noteContextLimitError(
primaryModelConfig.getProvider(),
primaryModelConfig.getModelName(), errorMessage));
}
streamingHelper.setStreamIdleTimeoutSec(
resolveStreamIdleTimeoutSeconds(primaryModelConfig));
ToolExecutionExecutor executor = new ToolExecutionExecutor(
toolSet, toolGuardService, approvalService, streamTracker,
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
workspaceLookupCache, approvalGrantResolver);
// Issue #46: enable skill-aware "Tool not found" hint so when the
// LLM mis-calls a skill name as a tool, the response tells it
// the right invocation pattern instead of a dead-end error.
executor.setSkillRuntimeService(skillRuntimeService);
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
executor.setProgressContext(progressContext);
// Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) {
executor.setAuditEventService(auditEventService);
}
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet, goalService, goalProperties, agentService);
// Team hand-off: a lead-of-team plan agent parks multi-step plans on
// the team task board instead of the serial delegation pipeline.
planGenerationNode.setTeamPlanBridge(teamPlanBridge);
List<ToolCallback> advertisedCallbacks = toolDisclosureService
.split(toolSet, Set.of(), autoDemotedTools).activeCallbacks();
AgentToolSet advertisedToolSet = AgentToolSet.fromCallbacks(
toolSet.toolBeans(), advertisedCallbacks);
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, advertisedToolSet,
executor, planningService, streamTracker, reasoningEffort, streamingHelper,
conversationWindowManager, skillCatalogRenderer);
// Per-step delegation: route a step assigned to a specialist agent
// through DelegateAgentTool (null when delegation deps aren't wired).
stepExecutionNode.setDelegateAgentTool(delegateAgentTool);
PlanSummaryNode planSummaryNode = new PlanSummaryNode(chatModel, planningService, streamingHelper);
DirectAnswerNode directAnswerNode = new DirectAnswerNode();
KeyStrategyFactory keyStrategyFactory = KeyStrategy.builder()
// 共享键
.addStrategy(MateClawStateKeys.PENDING_EVENTS, KeyStrategy.APPEND)
.addStrategy(MateClawStateKeys.CURRENT_PHASE, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.SYSTEM_PROMPT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CONVERSATION_ID, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.TRACE_ID, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.AGENT_ID, KeyStrategy.REPLACE)
// 会话消息(复用 ReAct 的 MESSAGES keyAPPEND 策略)
.addStrategy(MateClawStateKeys.MESSAGES, KeyStrategy.APPEND)
// Plan 特有键
.addStrategy(PlanStateKeys.GOAL, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.PLAN_ID, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.PLAN_STEPS, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.PLAN_VALID, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.NEEDS_PLANNING, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.CURRENT_STEP_INDEX, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.CURRENT_STEP_TITLE, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.CURRENT_STEP_RESULT, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.COMPLETED_RESULTS, KeyStrategy.APPEND)
.addStrategy(PlanStateKeys.PLAN_REPLAN_COUNT, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.FINAL_SUMMARY, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.DIRECT_ANSWER, KeyStrategy.REPLACE)
// 工作上下文REPLACE 策略,每次重新生成)
.addStrategy(PlanStateKeys.WORKING_CONTEXT, KeyStrategy.REPLACE)
// Thinking 键
.addStrategy(PlanStateKeys.FINAL_SUMMARY_THINKING, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.CURRENT_STEP_THINKING, KeyStrategy.REPLACE)
.addStrategy(PlanStateKeys.PLAN_THINKING, KeyStrategy.REPLACE)
// 流式防重键
.addStrategy(MateClawStateKeys.CONTENT_STREAMED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.THINKING_STREAMED, KeyStrategy.REPLACE)
// 流式内容暂存AWAITING_APPROVAL 路径持久化使用)
.addStrategy(MateClawStateKeys.STREAMED_CONTENT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.STREAMED_THINKING, KeyStrategy.REPLACE)
// 请求者身份(审批身份校验使用)
.addStrategy(MateClawStateKeys.REQUESTER_ID, KeyStrategy.REPLACE)
// 审批重放键
.addStrategy(MateClawStateKeys.FORCED_TOOL_CALL, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.PRE_APPROVED_TOOL_CALL, KeyStrategy.REPLACE)
// RFC-063r §2.5: ChatOrigin must survive every node merge so
// sub-graph nodes (StepExecutionNode + DelegateAgentTool's
// child agents) can read the originating channel binding.
// Without explicit REPLACE the framework's merge drops it
// on multi-iteration paths — root cause of the channel-binding
// flakiness reported on first deployment.
.addStrategy(MateClawStateKeys.CHAT_ORIGIN, KeyStrategy.REPLACE)
// Caught by StateKeyRegistrationCoverageTest — these state keys
// were silently unregistered before the post-deploy audit.
// WORKSPACE_BASE_PATH: written by buildInitialState; sub-graph
// tools read it via WorkspacePathGuard.
// STOP_REQUESTED: external cancel flag checked by every node.
// RETURN_DIRECT_TRIGGERED / DIRECT_TOOL_OUTPUTS (RFC-052):
// Plan-Execute itself doesn't trigger returnDirect, but
// DelegateAgentTool sub-agents could; register defensively.
.addStrategy(MateClawStateKeys.WORKSPACE_BASE_PATH, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.STOP_REQUESTED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, KeyStrategy.REPLACE)
// Token Usage
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CACHE_READ_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CACHE_WRITE_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.REASONING_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.LLM_CALL_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
// SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的
// (sourcePaths, sourceSymbols, failedPaths) merge 进这个 ledger
// 后续 ReasoningNode / FinalAnswerNode 调 validateAnswer 校验
// 模型引用是否有真实证据。漏注册时框架在多 node merge 时会偶发
// 丢这个键evidence_insufficient 检查会"静默地不生效" ——
// StateKeyRegistrationCoverageTest 专门兜这条。
.addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.ACTION_EXECUTION_LEDGER, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.ACTION_COMPLETION_REQUIRED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.ACTION_COMPLETION_RETRY_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CONTINUE_REASONING, KeyStrategy.REPLACE)
// Multimodal sidecar routing decision for the current turn.
.addStrategy(MateClawStateKeys.ROUTING_DECISION, KeyStrategy.REPLACE)
// RFC 48 — persistent goal state keys must be registered in
// BOTH graph KeyStrategyFactory blocks. The architecture
// coverage test only checks "appears somewhere"; the
// GoalStateKeyDoubleRegistrationTest below verifies the
// double registration explicitly.
.addStrategy(MateClawStateKeys.ACTIVE_GOAL, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_EVALUATION_RESULT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_HARD_CONTINUATION_COUNT, KeyStrategy.REPLACE)
// Skill progressive disclosure — pinned skills loaded this
// run. Registered in BOTH graphs so the read-merge-write in
// ActionNode is not dropped on multi-node merges.
.addStrategy(MateClawStateKeys.LOADED_SKILLS, KeyStrategy.REPLACE)
// Tool progressive disclosure — extensions enabled this run.
// Registered in BOTH graphs for the same merge-safety reason.
.addStrategy(MateClawStateKeys.ENABLED_EXTENSION_TOOLS, KeyStrategy.REPLACE)
// Tool-call loop guard counters + one-shot post-mutation
// verification reminder flag. Read-merge-write by
// ObservationNode; registered in BOTH graphs so the
// counters survive multi-node merges.
.addStrategy(MateClawStateKeys.TOOL_LOOP_STATS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.MUTATION_REMINDER_INJECTED, KeyStrategy.REPLACE)
.build();
// Graph 拓扑:
// START → PLAN_GENERATION → (PlanGenerationDispatcher)
// ├→ DIRECT_ANSWER_NODE → END
// └→ STEP_EXECUTION → (StepProgressDispatcher)
// ├→ STEP_EXECUTION (loop)
// ├→ PLAN_GENERATION (re-plan on step failure, bounded by PLAN_REPLAN_COUNT)
// └→ PLAN_SUMMARY → (active goal?)
// ├→ GOAL_EVALUATION → (followup?)
// │ ├→ PLAN_GENERATION (re-plan)
// │ └→ END
// └→ END
GoalEvaluationNode goalEvalNode = new GoalEvaluationNode(
goalEvaluationService, goalFollowupService, goalService, goalProperties,
conversationWindowManager, conversationService,
vip.mate.goal.service.GraphFlavor.PLAN_EXECUTE);
StateGraph graph = new StateGraph("plan-execute-agent", keyStrategyFactory)
.addNode(PlanStateKeys.PLAN_GENERATION_NODE,
AsyncNodeAction.node_async(planGenerationNode))
.addNode(PlanStateKeys.STEP_EXECUTION_NODE,
AsyncNodeAction.node_async(stepExecutionNode))
.addNode(PlanStateKeys.PLAN_SUMMARY_NODE,
AsyncNodeAction.node_async(planSummaryNode))
.addNode(PlanStateKeys.DIRECT_ANSWER_NODE,
AsyncNodeAction.node_async(directAnswerNode))
.addNode(MateClawStateKeys.GOAL_EVALUATION_NODE,
AsyncNodeAction.node_async(goalEvalNode))
.addEdge(StateGraph.START, PlanStateKeys.PLAN_GENERATION_NODE)
.addConditionalEdges(PlanStateKeys.PLAN_GENERATION_NODE,
AsyncEdgeAction.edge_async(new PlanGenerationDispatcher()),
Map.of(
PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE,
PlanStateKeys.DIRECT_ANSWER_NODE, PlanStateKeys.DIRECT_ANSWER_NODE,
// Board-delegated plan settled: step results were
// rebuilt from team tasks — summarize directly.
PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE))
.addConditionalEdges(PlanStateKeys.STEP_EXECUTION_NODE,
AsyncEdgeAction.edge_async(new StepProgressDispatcher()),
Map.of(
PlanStateKeys.STEP_EXECUTION_NODE, PlanStateKeys.STEP_EXECUTION_NODE,
PlanStateKeys.PLAN_SUMMARY_NODE, PlanStateKeys.PLAN_SUMMARY_NODE,
// Step-failure recovery: re-plan the remaining work
// (StepProgressDispatcher returns this on phase=plan_replan).
PlanStateKeys.PLAN_GENERATION_NODE, PlanStateKeys.PLAN_GENERATION_NODE,
StateGraph.END, StateGraph.END))
.addConditionalEdges(PlanStateKeys.PLAN_SUMMARY_NODE,
AsyncEdgeAction.edge_async(state -> {
MateClawStateAccessor a = new MateClawStateAccessor(state);
// Same-turn activation: fall back to a DB lookup (gated on the
// feature flag) so a goal the agent set THIS turn is evaluated now,
// not only from the next message. See GoalEvaluationNode.resolveActiveGoal.
boolean hasGoal = goalProperties.isEnabled()
&& GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent();
boolean already = a.goalEvaluatedThisRun();
return (hasGoal && !already)
? MateClawStateKeys.GOAL_EVALUATION_NODE
: StateGraph.END;
}),
Map.of(
MateClawStateKeys.GOAL_EVALUATION_NODE, MateClawStateKeys.GOAL_EVALUATION_NODE,
StateGraph.END, StateGraph.END))
.addConditionalEdges(MateClawStateKeys.GOAL_EVALUATION_NODE,
AsyncEdgeAction.edge_async(new vip.mate.agent.graph.edge.GoalEvaluationDispatcher(
PlanStateKeys.PLAN_GENERATION_NODE, StateGraph.END)),
Map.of(
PlanStateKeys.PLAN_GENERATION_NODE, PlanStateKeys.PLAN_GENERATION_NODE,
StateGraph.END, StateGraph.END))
// DIRECT_ANSWER_NODE handles trivial requests that bypass the
// multi-step plan. For active goals, the direct answer is still
// a turn — without this edge, turns_used / score / completion
// would never tick on plan-execute conversations whose every
// reply happened to be simple enough to short-circuit through
// the direct path. Mirror PLAN_SUMMARY_NODE's gate so non-goal
// turns still go straight to END (no goal node invocation).
.addConditionalEdges(PlanStateKeys.DIRECT_ANSWER_NODE,
AsyncEdgeAction.edge_async(state -> {
MateClawStateAccessor a = new MateClawStateAccessor(state);
// Same-turn activation: fall back to a DB lookup (gated on the
// feature flag) so a goal the agent set THIS turn is evaluated now,
// not only from the next message. See GoalEvaluationNode.resolveActiveGoal.
boolean hasGoal = goalProperties.isEnabled()
&& GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent();
boolean already = a.goalEvaluatedThisRun();
return (hasGoal && !already)
? MateClawStateKeys.GOAL_EVALUATION_NODE
: StateGraph.END;
}),
Map.of(
MateClawStateKeys.GOAL_EVALUATION_NODE, MateClawStateKeys.GOAL_EVALUATION_NODE,
StateGraph.END, StateGraph.END));
return graph.compile(CompileConfig.builder()
.recursionLimit(frameworkRecursionLimit())
.build());
} catch (Exception e) {
throw new MateClawException("err.agent.plan_compile_failed", "Plan-Execute StateGraph 编译失败: " + e.getMessage());
}
}
/**
* Hard ceiling for the underlying graph framework's recursion guard.
* <p>
* The framework treats "recursion limit reached" as a normal completion —
* it emits a {@code done} signal with no exception and no log. That makes
* it indistinguishable from a real final answer downstream, and is the
* mechanism by which a turn can silently stop mid-execution and persist
* only whatever partial content the accumulator happened to hold.
* <p>
* To avoid that class of bug, the recursion limit must be sized so it can
* <em>never</em> trip before the soft cap (ObservationDispatcher →
* LimitExceededNode), which is the only path that produces a proper
* {@code finish_reason} and human-facing message. Sized for the maximum
* effective soft cap (DB hard ceiling + thinking-mode bonus) multiplied
* by 4 (each iteration is worst-case reasoning + summarizing + action +
* observation) plus a 100-step buffer for phase nodes, approval replays
* and tool-result chunking. Decoupled from the per-agent value so a small
* {@code max_iterations} can never accidentally re-introduce the silent
* killer.
* <p>
* The base segment budget is further multiplied to cover goal-driven "hard
* continuations" — each grants a fresh full iteration budget after a
* max-iterations turn (see {@code GoalEvaluationNode}). One run can perform
* up to {@link vip.mate.goal.config.GoalProperties#MAX_HARD_CONTINUATIONS_CEILING} of them, so
* the ceiling is sized for {@code (1 + CEILING)} segments to keep the
* recursion guard from tripping before the soft caps do.
*/
private static int frameworkRecursionLimit() {
int perSegment = (BaseAgent.MAX_ITERATIONS_HARD_CEILING + 5) * 4;
return perSegment * (1 + vip.mate.goal.config.GoalProperties.MAX_HARD_CONTINUATIONS_CEILING) + 100;
}
static long resolveStreamIdleTimeoutSeconds(ModelConfigEntity modelConfig) {
Integer override = modelConfig != null
? modelConfig.getRequestTimeoutSeconds()
: null;
return HttpTimeouts.resolveStreamIdleTimeout(override).toSeconds();
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, null, null);
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig) {
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, primaryModelConfig, null);
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId) {
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort,
primaryModelConfig, agentId, null);
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId, SkillCatalogRenderer skillCatalogRenderer) {
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort,
primaryModelConfig, agentId, skillCatalogRenderer, null, Set.of());
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId, SkillCatalogRenderer skillCatalogRenderer,
PrefixBudgetPlan prefixBudgetPlan, Set<String> autoDemotedTools) {
try {
List<vip.mate.llm.failover.FallbackEntry> fallbackChain = buildFallbackChain(primaryModelConfig, agentId);
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
providerPool);
if (primaryModelConfig != null) {
// Feed "prompt too long" rejections back into the window resolver
// so the next turn budgets against the server-reported limit.
streamingHelper.setContextLimitObserver(errorMessage ->
contextWindowResolver.noteContextLimitError(
primaryModelConfig.getProvider(),
primaryModelConfig.getModelName(), errorMessage));
}
streamingHelper.setStreamIdleTimeoutSec(
resolveStreamIdleTimeoutSeconds(primaryModelConfig));
ToolExecutionExecutor executor = new ToolExecutionExecutor(
toolSet, toolGuardService, approvalService, streamTracker,
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
workspaceLookupCache, approvalGrantResolver);
// Issue #46: enable skill-aware "Tool not found" hint so when the
// LLM mis-calls a skill name as a tool, the response tells it
// the right invocation pattern instead of a dead-end error.
executor.setSkillRuntimeService(skillRuntimeService);
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
executor.setProgressContext(progressContext);
// Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) {
executor.setAuditEventService(auditEventService);
}
// PR-1.2 (RFC-049 L1-B): propagate the bound model's capability so ReasoningNode
// can gate the ThinkingLevelHolder override explicitly, rather than inferring
// capability from reasoningEffort == null.
boolean supportsReasoningEffort = primaryModelConfig != null
&& ModelFamily.detect(primaryModelConfig.getModelName()).supportsReasoningEffort();
// Honor the model's configured output cap. Passing 0 here made the
// node fall back to its 16384 default, so the user-configured
// maxTokens never took effect and strict local servers (vLLM's
// max_model_len pre-check) rejected the request outright.
int configuredMaxOutputTokens = (primaryModelConfig != null
&& primaryModelConfig.getMaxTokens() != null
&& primaryModelConfig.getMaxTokens() > 0)
? primaryModelConfig.getMaxTokens() : 0;
ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort,
supportsReasoningEffort,
streamingHelper, conversationWindowManager, streamTracker,
configuredMaxOutputTokens, wikiContextService,
skillCatalogRenderer, toolDisclosureService, progressLedgerService);
reasoningNode.setPrefixBudgetPlan(prefixBudgetPlan);
reasoningNode.setAutoDemotedTools(autoDemotedTools);
// C4: wire the environment-notification registry so ReasoningNode
// can drain pending MCP/skill events and inject them as a SystemMessage.
reasoningNode.setRunningConversationRegistry(runningConversationRegistry);
// Live team-board snapshot for leads, injected per turn as a meta
// user message; no-op for agents outside any team.
reasoningNode.setTeamContextBuilder(teamContextBuilder);
ActionNode actionNode = new ActionNode(executor, streamTracker);
// B2/B5: wire optional collaborators so ActionNode can pin skill
// constraints and auto-record tool completions into ProgressLedger.
// Setter injection keeps the existing constructor signature stable
// for tests that build ActionNode directly.
actionNode.setSkillRuntimeService(skillRuntimeService);
actionNode.setProgressLedgerService(progressLedgerService);
ObservationProcessor observationProcessor = new ObservationProcessor(graphObservationProperties);
ObservationNode observationNode = new ObservationNode(observationProcessor, streamTracker);
SummarizingNode summarizingNode = new SummarizingNode(chatModel, streamingHelper, streamTracker);
LimitExceededNode limitExceededNode = new LimitExceededNode(
chatModel, observationProcessor, streamingHelper, i18nService, progressLedgerService);
FinalAnswerNode finalAnswerNode = new FinalAnswerNode(generatedFileCache, markdownNormalizeEnabled);
KeyStrategyFactory keyStrategyFactory = KeyStrategy.builder()
// 输入字段
.addStrategy(MateClawStateKeys.USER_MESSAGE, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CONVERSATION_ID, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.SYSTEM_PROMPT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.AGENT_ID, KeyStrategy.REPLACE)
// 消息列表(追加策略)
.addStrategy(MateClawStateKeys.MESSAGES, KeyStrategy.APPEND)
// 迭代控制
.addStrategy(MateClawStateKeys.CURRENT_ITERATION, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.ITERATION_REFUND_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.MAX_ITERATIONS, KeyStrategy.REPLACE)
// 工具调用
.addStrategy(MateClawStateKeys.TOOL_CALLS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.TOOL_RESULTS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.TOOL_CALL_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.LLM_CALL_COUNT, KeyStrategy.REPLACE)
// 控制流
.addStrategy(MateClawStateKeys.FINAL_ANSWER, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.NEEDS_TOOL_CALL, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.ERROR, KeyStrategy.REPLACE)
// 观察历史REPLACE 策略,由 ObservationNode 手动累加SummarizingNode 可清空)
.addStrategy(MateClawStateKeys.OBSERVATION_HISTORY, KeyStrategy.REPLACE)
// Summarizing
.addStrategy(MateClawStateKeys.SUMMARIZED_CONTEXT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.FINAL_ANSWER_DRAFT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.SHOULD_SUMMARIZE, KeyStrategy.REPLACE)
// 终止控制
.addStrategy(MateClawStateKeys.FINISH_REASON, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.LIMIT_EXCEEDED, KeyStrategy.REPLACE)
// 统计与追踪
.addStrategy(MateClawStateKeys.ERROR_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.TRACE_ID, KeyStrategy.REPLACE)
// 事件流
.addStrategy(MateClawStateKeys.PENDING_EVENTS, KeyStrategy.APPEND)
.addStrategy(MateClawStateKeys.CURRENT_PHASE, KeyStrategy.REPLACE)
// Thinking
.addStrategy(MateClawStateKeys.FINAL_THINKING, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CURRENT_THINKING, KeyStrategy.REPLACE)
// 流式防重
.addStrategy(MateClawStateKeys.CONTENT_STREAMED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.THINKING_STREAMED, KeyStrategy.REPLACE)
// 审批控制
.addStrategy(MateClawStateKeys.AWAITING_APPROVAL, KeyStrategy.REPLACE)
// 流式内容暂存AWAITING_APPROVAL 路径持久化使用)
.addStrategy(MateClawStateKeys.STREAMED_CONTENT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.STREAMED_THINKING, KeyStrategy.REPLACE)
// 请求者身份(审批身份校验使用)
.addStrategy(MateClawStateKeys.REQUESTER_ID, KeyStrategy.REPLACE)
// 审批重放
.addStrategy(MateClawStateKeys.FORCED_TOOL_CALL, KeyStrategy.REPLACE)
// RFC-063r §2.5: ChatOrigin must survive every node merge so
// ActionNode (and DelegateAgentTool's child agents) can read
// the originating channel binding across multi-iteration ReAct
// loops. Without explicit REPLACE the framework's merge drops
// it after the first node transition — root cause of the
// channel-binding flakiness reported on first deployment.
.addStrategy(MateClawStateKeys.CHAT_ORIGIN, KeyStrategy.REPLACE)
// Caught by StateKeyRegistrationCoverageTest — silently
// unregistered before the audit. WORKSPACE_BASE_PATH from
// initial state; STOP_REQUESTED is the external cancel flag;
// RETURN_DIRECT_TRIGGERED / DIRECT_TOOL_OUTPUTS are RFC-052
// returnDirect short-circuit signals consumed by ObservationDispatcher.
.addStrategy(MateClawStateKeys.WORKSPACE_BASE_PATH, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.STOP_REQUESTED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, KeyStrategy.REPLACE)
// Token Usage
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CACHE_READ_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CACHE_WRITE_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.REASONING_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
// SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的
// (sourcePaths, sourceSymbols, failedPaths) merge 进这个 ledger
// 后续 ReasoningNode / FinalAnswerNode 调 validateAnswer 校验
// 模型引用是否有真实证据。漏注册时框架在多 node merge 时会偶发
// 丢这个键evidence_insufficient 检查会"静默地不生效" ——
// StateKeyRegistrationCoverageTest 专门兜这条。
.addStrategy(MateClawStateKeys.SOURCE_EVIDENCE_LEDGER, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.ACTION_EXECUTION_LEDGER, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.ACTION_COMPLETION_REQUIRED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.ACTION_COMPLETION_RETRY_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CONTINUE_REASONING, KeyStrategy.REPLACE)
// Multimodal sidecar routing decision for the current turn.
.addStrategy(MateClawStateKeys.ROUTING_DECISION, KeyStrategy.REPLACE)
// RFC 48 — persistent goal state keys must be registered in
// BOTH graph KeyStrategyFactory blocks. See
// GoalStateKeyDoubleRegistrationTest for the strict
// double-registration check.
.addStrategy(MateClawStateKeys.ACTIVE_GOAL, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_EVALUATION_RESULT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_HARD_CONTINUATION_COUNT, KeyStrategy.REPLACE)
// Skill progressive disclosure — pinned skills loaded this
// run. Registered in BOTH graphs so the read-merge-write in
// ActionNode is not dropped on multi-node merges.
.addStrategy(MateClawStateKeys.LOADED_SKILLS, KeyStrategy.REPLACE)
// Tool progressive disclosure — extensions enabled this run.
// Registered in BOTH graphs for the same merge-safety reason.
.addStrategy(MateClawStateKeys.ENABLED_EXTENSION_TOOLS, KeyStrategy.REPLACE)
// Tool-call loop guard counters + one-shot post-mutation
// verification reminder flag. Read-merge-write by
// ObservationNode; registered in BOTH graphs so the
// counters survive multi-node merges.
.addStrategy(MateClawStateKeys.TOOL_LOOP_STATS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.MUTATION_REMINDER_INJECTED, KeyStrategy.REPLACE)
.build();
GoalEvaluationNode goalEvalNode = new GoalEvaluationNode(
goalEvaluationService, goalFollowupService, goalService, goalProperties,
conversationWindowManager, conversationService,
vip.mate.goal.service.GraphFlavor.REACT);
StateGraph graph = new StateGraph("react-agent-v2", keyStrategyFactory)
.addNode(MateClawStateKeys.REASONING_NODE,
AsyncNodeAction.node_async(reasoningNode))
.addNode(MateClawStateKeys.ACTION_NODE,
AsyncNodeAction.node_async(actionNode))
.addNode(MateClawStateKeys.OBSERVATION_NODE,
AsyncNodeAction.node_async(observationNode))
.addNode(MateClawStateKeys.SUMMARIZING_NODE,
AsyncNodeAction.node_async(summarizingNode))
.addNode(MateClawStateKeys.LIMIT_EXCEEDED_NODE,
AsyncNodeAction.node_async(limitExceededNode))
.addNode(MateClawStateKeys.FINAL_ANSWER_NODE,
AsyncNodeAction.node_async(finalAnswerNode))
.addNode(MateClawStateKeys.GOAL_EVALUATION_NODE,
AsyncNodeAction.node_async(goalEvalNode))
.addEdge(StateGraph.START, MateClawStateKeys.REASONING_NODE)
.addConditionalEdges(MateClawStateKeys.REASONING_NODE,
AsyncEdgeAction.edge_async(new ReasoningDispatcher()),
Map.of(MateClawStateKeys.REASONING_NODE, MateClawStateKeys.REASONING_NODE,
MateClawStateKeys.ACTION_NODE, MateClawStateKeys.ACTION_NODE,
MateClawStateKeys.SUMMARIZING_NODE, MateClawStateKeys.SUMMARIZING_NODE,
MateClawStateKeys.FINAL_ANSWER_NODE, MateClawStateKeys.FINAL_ANSWER_NODE,
MateClawStateKeys.LIMIT_EXCEEDED_NODE, MateClawStateKeys.LIMIT_EXCEEDED_NODE))
.addEdge(MateClawStateKeys.ACTION_NODE, MateClawStateKeys.OBSERVATION_NODE)
.addConditionalEdges(MateClawStateKeys.OBSERVATION_NODE,
AsyncEdgeAction.edge_async(new ObservationDispatcher()),
Map.of(MateClawStateKeys.REASONING_NODE, MateClawStateKeys.REASONING_NODE,
MateClawStateKeys.SUMMARIZING_NODE, MateClawStateKeys.SUMMARIZING_NODE,
MateClawStateKeys.LIMIT_EXCEEDED_NODE, MateClawStateKeys.LIMIT_EXCEEDED_NODE,
MateClawStateKeys.FINAL_ANSWER_NODE, MateClawStateKeys.FINAL_ANSWER_NODE))
.addEdge(MateClawStateKeys.SUMMARIZING_NODE, MateClawStateKeys.REASONING_NODE)
.addEdge(MateClawStateKeys.LIMIT_EXCEEDED_NODE, MateClawStateKeys.FINAL_ANSWER_NODE)
// FinalAnswer -> (active goal && not yet evaluated this run) ? GoalEvaluation : END
.addConditionalEdges(MateClawStateKeys.FINAL_ANSWER_NODE,
AsyncEdgeAction.edge_async(state -> {
MateClawStateAccessor a = new MateClawStateAccessor(state);
// Same-turn activation: fall back to a DB lookup (gated on the
// feature flag) so a goal the agent set THIS turn is evaluated now,
// not only from the next message. See GoalEvaluationNode.resolveActiveGoal.
boolean hasGoal = goalProperties.isEnabled()
&& GoalEvaluationNode.resolveActiveGoal(state, goalService).isPresent();
boolean already = a.goalEvaluatedThisRun();
return (hasGoal && !already)
? MateClawStateKeys.GOAL_EVALUATION_NODE
: StateGraph.END;
}),
Map.of(
MateClawStateKeys.GOAL_EVALUATION_NODE, MateClawStateKeys.GOAL_EVALUATION_NODE,
StateGraph.END, StateGraph.END))
// GoalEvaluation -> (followup injected) ? Reasoning : END
.addConditionalEdges(MateClawStateKeys.GOAL_EVALUATION_NODE,
AsyncEdgeAction.edge_async(new vip.mate.agent.graph.edge.GoalEvaluationDispatcher(
MateClawStateKeys.REASONING_NODE, StateGraph.END)),
Map.of(
MateClawStateKeys.REASONING_NODE, MateClawStateKeys.REASONING_NODE,
StateGraph.END, StateGraph.END));
return graph.compile(CompileConfig.builder()
.recursionLimit(frameworkRecursionLimit())
.withLifecycleListener(new ReActLifecycleListener())
.build());
} catch (Exception e) {
throw new MateClawException("err.agent.graph_compile_failed", "StateGraph v2 编译失败: " + e.getMessage());
}
}
// ==================== 协议能力判断 ====================
private boolean supportsStateGraph(ModelProtocol protocol) {
return protocol == ModelProtocol.DASHSCOPE_NATIVE
|| protocol == ModelProtocol.OPENAI_COMPATIBLE
|| protocol == ModelProtocol.ANTHROPIC_MESSAGES
// RFC-062: Claude Code OAuth tunnels through the same Messages API
// wrapped in AnthropicChatModel — same StateGraph capability surface.
|| protocol == ModelProtocol.ANTHROPIC_CLAUDE_CODE
|| protocol == ModelProtocol.OPENAI_CHATGPT
// Gemini native generateContent — GeminiChatModel exposes the same
// streaming + tool-calling surface the StateGraph nodes rely on.
|| protocol == ModelProtocol.GEMINI_NATIVE;
}
// ==================== 模型构建 ====================
/**
* 构建运行时 ChatModel不包装为 ChatClient
* 用于 StateGraph 节点直接调用。使用注入的共享 {@link #retryTemplate} 作为 Spring AI
* 内层重试策略。
*/
public ChatModel buildRuntimeChatModel(ModelConfigEntity runtimeModel) {
return buildRuntimeChatModel(runtimeModel, this.retryTemplate);
}
/**
* Resolve the user-facing locale used for sidecar caption prompts.
* Reads {@code language} from system settings; falls back to
* {@code zh-CN} so CN deployments stay consistent with the chat UI.
*/
private java.util.Locale resolveLocale() {
try {
String lang = systemSettingService.getLanguage();
if (lang == null || lang.isBlank()) return java.util.Locale.SIMPLIFIED_CHINESE;
return java.util.Locale.forLanguageTag(lang);
} catch (Exception e) {
return java.util.Locale.SIMPLIFIED_CHINESE;
}
}
/**
* 构建运行时 ChatModel并指定自定义的 Spring AI {@link RetryTemplate}。
* <p>
* 用于调用方(如 Wiki 消化管线)已经有自己的外层重试策略,
* 希望绕过 Spring AI 内层重试、独占重试控制权的场景:传入
* {@code RetryTemplate.builder().maxAttempts(1).build()} 即可把内层降级为"只跑一次"。
* <p>
* DashScope 和 OpenAI-ChatGPT 分支不走 Spring AI 的 RetryTemplate 接口,
* 本参数对它们无效(它们各自有内部重试或直通)。
*/
public ChatModel buildRuntimeChatModel(ModelConfigEntity runtimeModel, RetryTemplate retryOverride) {
// PR-0 (RFC-009 Phase 4 prelude): protocol switch extracted to
// ProviderChatModelFactory + per-protocol ChatModelBuilder strategies.
// Per-protocol builders (DashScope / OpenAI-compatible / Anthropic /
// ChatGPT-Responses) live in vip.mate.agent.chatmodel + vip.mate.llm.chatmodel.
// See RFC-009 Phase 4 plan for the rationale (circular-dep break for
// ProviderInitProbe + AgentGraphBuilder slimming).
return chatModelFactory.buildFor(runtimeModel, retryOverride);
}
/**
* RFC-009: build the full multi-provider failover chain for a primary
* model. Providers are read from {@code mate_model_provider} ordered by
* {@code fallback_priority ASC} (positive values only), each resolved to
* its default {@link ModelConfigEntity} and turned into a {@link ChatModel}
* via {@link #buildRuntimeChatModel(ModelConfigEntity, RetryTemplate)}.
*
* <p>Providers whose API key / base URL is missing (build throws) are
* <b>silently skipped</b> with a warning — fallback should never break
* the primary call path. The returned list preserves chain order; the
* streaming helper tries entries in order until one succeeds.</p>
*
* <p>The primary model is excluded from the chain when its provider +
* model name matches a chain entry. Previously only reference equality
* was checked, which meant a DashScope-primary deployment ended up with
* {@code null} fallback — exactly the case RFC-009 targets.</p>
*
* @param primaryModelConfig the {@code ModelConfigEntity} used to build
* the primary model; used to identity-filter the chain
* @return ordered, possibly-empty list of fallback {@link ChatModel}s
*/
List<vip.mate.llm.failover.FallbackEntry> buildFallbackChain(ModelConfigEntity primaryModelConfig) {
return buildFallbackChain(primaryModelConfig, null);
}
/**
* RFC-009 PR-3 overload: when {@code agentId} is non-null, the agent's
* {@code mate_agent_provider_preference} rows bias the chain order — listed
* providers come first in their declared {@code sort_order}, then the
* remaining providers fall in by global {@code fallback_priority} ascending,
* tie-broken by provider id alphabetically. {@code null} agentId keeps the
* pre-PR-3 ordering (pure global priority) — that's the path for legacy
* callers and tests.
*
* <p><b>Source = the available pool</b> (RFC-009 follow-up). Earlier this
* method only considered providers with {@code fallback_priority > 0}, which
* meant any provider the user hadn't explicitly opted into the chain was
* silently excluded — even if it was healthy and in the pool. The pool is
* the source of truth for "what's usable right now"; {@code fallback_priority}
* is just an ordering hint within the pool.</p>
*
* <p><b>Per-provider model selection</b> falls back gracefully: the
* provider's {@code is_default=true} chat model wins, otherwise we pick
* the first enabled chat model on that provider. Forcing users to mark a
* default per provider was administrative friction with no real benefit.</p>
*/
List<vip.mate.llm.failover.FallbackEntry> buildFallbackChain(ModelConfigEntity primaryModelConfig,
Long agentId) {
List<ModelProviderEntity> providers;
try {
// Pull every configured provider, not just the ones with
// fallback_priority > 0 — pool membership is what gates usability,
// not this admin-set hint.
providers = modelProviderService.listProviders().stream()
.filter(dto -> Boolean.TRUE.equals(dto.getConfigured()))
.map(dto -> {
try {
return modelProviderService.getProviderConfig(dto.getId());
} catch (Exception e) {
return null;
}
})
.filter(java.util.Objects::nonNull)
.collect(java.util.stream.Collectors.toCollection(ArrayList::new));
} catch (Exception e) {
log.warn("[LlmFailover] failed to load configured providers: {}; running without fallback",
e.getMessage());
return List.of();
}
if (providers.isEmpty()) {
return List.of();
}
// Order: explicit fallback_priority > 0 wins (asc), priority == 0 trails alphabetically.
providers.sort((a, b) -> {
int pa = a.getFallbackPriority() == null ? 0 : a.getFallbackPriority();
int pb = b.getFallbackPriority() == null ? 0 : b.getFallbackPriority();
if (pa > 0 && pb > 0) return Integer.compare(pa, pb);
if (pa > 0) return -1; // a has explicit priority, comes first
if (pb > 0) return 1; // b has explicit priority, comes first
return a.getProviderId().compareTo(b.getProviderId()); // both 0: alphabetical
});
String primaryProviderId = primaryModelConfig != null ? primaryModelConfig.getProvider() : null;
String primaryModelName = primaryModelConfig != null ? primaryModelConfig.getModelName() : null;
// RFC-090 §9.2 调整 C — lift providers that satisfy the bound-skill
// capability set (vision / video / audio) ahead of those that don't.
// Run before planning so the non-preferred tail inherits this order;
// the explicit preferred-model head keeps the user's declared order.
try {
providers = new ArrayList<>(providerRouter.reorderForCapabilities(agentId, providers));
} catch (Exception e) {
log.debug("[ProviderRouter] chain reorder failed: {}", e.getMessage());
}
// Preferred-model chain: explicit (provider, model) entries lead in the
// user's order — the same provider may repeat with different models —
// then every non-preferred provider follows with its default model.
List<ProviderModelRef> preferred = agentId == null
? java.util.Collections.emptyList()
: agentBindingService.getPreferredProviderModels(agentId);
List<String> globalProviderIds = providers.stream()
.map(ModelProviderEntity::getProviderId)
.toList();
List<ProviderModelRef> plan = planFallbackOrder(preferred, globalProviderIds);
if (!preferred.isEmpty()) {
log.debug("[LlmFailover] agent={} preferred-model chain={} -> plan={}", agentId, preferred, plan);
}
// Dedup by exact (provider, model) — seeded with the primary so we never
// rebuild the primary call, but OTHER models of the primary provider are
// still legitimate fallback entries.
List<vip.mate.llm.failover.FallbackEntry> chain = new ArrayList<>();
Set<String> seen = new java.util.HashSet<>();
if (primaryProviderId != null && primaryModelName != null) {
seen.add(primaryProviderId + "::" + primaryModelName);
}
for (ProviderModelRef ref : plan) {
String pid = ref.providerId();
// RFC-009 Phase 4: skip providers known-bad at build time. The runtime
// walker re-checks pool membership per request, so a provider that
// re-enters the pool later still gets used (graph rebuilt on
// ModelConfigChangedEvent).
if (providerPool != null && !providerPool.contains(pid)) {
log.debug("[LlmFailover] skipping provider {} — not in available pool", pid);
continue;
}
ModelConfigEntity fallbackConfig = resolveChainModel(ref);
if (fallbackConfig == null) {
log.debug("[LlmFailover] skipping {} — no usable chat model", pid);
continue;
}
String key = pid + "::" + fallbackConfig.getModelName();
if (!seen.add(key)) {
// Exact (provider, model) already queued or equal to the primary.
continue;
}
try {
ChatModel m = buildRuntimeChatModel(fallbackConfig, RetryTemplate.builder().maxAttempts(1).build());
chain.add(new vip.mate.llm.failover.FallbackEntry(pid, m));
log.info("[LlmFailover] chain[{}] = {}/{}", chain.size(), pid, fallbackConfig.getModelName());
} catch (Exception e) {
log.warn("[LlmFailover] skipping provider {} — chat model build failed: {}", pid, e.getMessage());
}
}
return chain;
}
/**
* Resolve a planned chain entry to a concrete chat model. A pinned model
* ({@code modelId != null}) is used when it still exists and is enabled;
* otherwise we fall back to the provider's default chat model so a deleted
* or disabled pin keeps the provider in the chain.
*/
private ModelConfigEntity resolveChainModel(ProviderModelRef ref) {
if (ref.modelId() != null) {
try {
ModelConfigEntity m = modelConfigService.getModel(ref.modelId());
// Honour the pin only when it is a usable chat model that actually
// belongs to this entry's provider. The FallbackEntry is keyed by
// ref.providerId() for cooldown/pool, so a model from a different
// provider would mis-key the chain; an embedding model would never
// serve as a chat fallback. Either case falls back to the
// provider's default chat model.
if (m != null && Boolean.TRUE.equals(m.getEnabled())
&& ref.providerId().equals(m.getProvider())
&& (m.getModelType() == null || "chat".equals(m.getModelType()))) {
return m;
}
log.info("[LlmFailover] pinned model {} for provider {} not usable "
+ "(disabled / wrong provider / non-chat), using provider default",
ref.modelId(), ref.providerId());
} catch (Exception e) {
log.info("[LlmFailover] pinned model {} for provider {} unresolved ({}), using provider default",
ref.modelId(), ref.providerId(), e.getMessage());
}
}
return pickFallbackModel(ref.providerId());
}
/**
* Pick a chat model to use as a fallback for the given provider:
* <ol>
* <li>Provider's explicit default ({@code is_default=true}) — most user-aligned.</li>
* <li>First enabled chat model on the provider — pragmatic fallback so the user
* isn't required to mark a default per provider just to participate in failover.</li>
* </ol>
* Returns {@code null} when the provider has no usable chat model.
*/
private ModelConfigEntity pickFallbackModel(String providerId) {
try {
ModelConfigEntity defaultModel = modelConfigService.getDefaultModelByProvider(providerId);
if (defaultModel != null) return defaultModel;
} catch (Exception ignored) {
// No default — fall through to first-enabled lookup.
}
try {
return modelConfigService.listModelsByProvider(providerId).stream()
.filter(m -> Boolean.TRUE.equals(m.getEnabled()))
.filter(m -> m.getModelType() == null || "chat".equals(m.getModelType()))
.findFirst()
.orElse(null);
} catch (Exception e) {
log.warn("[LlmFailover] cannot list models for provider {}: {}", providerId, e.getMessage());
return null;
}
}
/**
* Plan the fallback order as a list of (provider, model) refs.
*
* <p>Head: the agent's explicit preference entries in declared order,
* model-granular — the same provider may appear more than once with
* different models. Exact (provider, model) duplicates are dropped.
*
* <p>Tail: every provider not named in the preferences, in the supplied
* global order, each using its default model ({@code modelId == null}).
*
* <p>Preference entries with a blank provider id are ignored. Package-private
* for unit testing — see {@code AgentGraphBuilderPreferenceTest}.
*/
static List<ProviderModelRef> planFallbackOrder(List<ProviderModelRef> preferred,
List<String> globalProviderIds) {
List<ProviderModelRef> plan = new ArrayList<>();
Set<String> headEntryKeys = new java.util.HashSet<>();
Set<String> headProviderIds = new java.util.HashSet<>();
if (preferred != null) {
for (ProviderModelRef ref : preferred) {
if (ref == null || ref.providerId() == null || ref.providerId().isBlank()) continue;
String key = ref.providerId() + "::" + (ref.modelId() == null ? "" : ref.modelId());
if (!headEntryKeys.add(key)) continue; // exact (provider, model) dup
plan.add(ref);
headProviderIds.add(ref.providerId());
}
}
if (globalProviderIds != null) {
for (String pid : globalProviderIds) {
if (pid == null || pid.isBlank()) continue;
if (headProviderIds.contains(pid)) continue; // already led by an explicit entry
plan.add(new ProviderModelRef(pid, null));
}
}
return plan;
}
/**
* Resolve the effective working directory for an agent.
* <p>Precedence:
* <ol>
* <li>When the agent-level override is set, it wins.</li>
* <li>An absolute override is used verbatim, but only when it sits
* inside the workspace basePath (or when the workspace has no
* basePath of its own). An absolute path that points outside a
* configured workspace root is rejected — otherwise a less-trusted
* user with agent-edit access could set
* {@code workspaceBasePath="/"} and bypass workspace scoping.</li>
* <li>A relative override is resolved <em>under</em> the workspace basePath
* when the workspace has one, matching the UI hint that agent paths
* are relative to the workspace root.</li>
* <li>A relative override with no workspace basePath is used as-is
* (resolves against the JVM working directory at file-tool time).</li>
* <li>With no override, the workspace basePath is inherited verbatim;
* returns {@code null} when neither side has a value.</li>
* </ol>
*
* @throws IllegalArgumentException when an absolute override escapes the
* workspace root
*/
public static String resolveAgentBasePath(String agentOverride, String workspaceBase) {
boolean hasOverride = agentOverride != null && !agentOverride.isBlank();
boolean hasWorkspace = workspaceBase != null && !workspaceBase.isBlank();
if (!hasOverride) {
return hasWorkspace ? workspaceBase : null;
}
Path overridePath = Paths.get(agentOverride);
if (overridePath.isAbsolute()) {
if (hasWorkspace) {
Path wsRoot = Paths.get(workspaceBase).toAbsolutePath().normalize();
Path absOverride = overridePath.toAbsolutePath().normalize();
if (!absOverride.startsWith(wsRoot)) {
throw new IllegalArgumentException(
"Agent workspaceBasePath override must be inside the workspace root: "
+ absOverride + " is not under " + wsRoot);
}
}
return agentOverride;
}
if (hasWorkspace) {
// Relative override resolves under the workspace root; reject any value
// that escapes it via "../" so attachment/media/tool I/O stays contained.
Path wsRoot = Paths.get(workspaceBase).toAbsolutePath().normalize();
Path resolved = wsRoot.resolve(agentOverride).normalize();
if (!resolved.startsWith(wsRoot)) {
throw new IllegalArgumentException(
"Agent workspaceBasePath override must stay inside the workspace root: "
+ resolved + " escapes " + wsRoot);
}
return Paths.get(workspaceBase).resolve(agentOverride).toString();
}
return agentOverride;
}
/**
* Finds the first enabled chat model whose provider is fully configured.
* Used as a fallback when the default model's provider is not available.
*/
private ModelConfigEntity findFirstAvailableChatModel() {
return modelConfigService.listByType("chat").stream()
.filter(m -> Boolean.TRUE.equals(m.getEnabled()))
.filter(m -> {
try {
return modelProviderService.isProviderConfigured(m.getProvider());
} catch (Exception e) {
return false;
}
})
.findFirst()
.orElse(null);
}
// PR-0b: legacy single-fallback buildFallbackModel deleted (already @Deprecated, no callers).
// PR-0b: isDashScopeSearchEnabled moved to DashScopeChatModelBuilder.
// ==================== Prompt 构建 ====================
/**
* Cache-stable platform identity, appended to every agent's system
* prompt. Answers "who are you / what are you based on". The volatile
* "which model right now" fact is injected per-turn by
* {@link vip.mate.agent.context.RuntimeContextInjector} instead, to
* keep this prefix's prompt-cache hash stable.
*/
static final String ABOUT_YOU_BLOCK = """
## About You
You are powered by MateClaw — a multi-user AI Agent platform built on
Spring Boot 3.5 and Spring AI Alibaba Graph. You are reachable through
WebChat and 8+ IM channels (DingTalk, Feishu, WeCom, WeChat, Telegram,
Discord, QQ, Slack). If asked who you are or what you are based on,
answer with MateClaw and the technology stack above.
""";
private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled) {
return buildEnhancedPrompt(entity, builtinSearchEnabled, Integer.MAX_VALUE);
}
private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled, int memoryBudgetTokens) {
// The agent's own systemPrompt encodes its identity (role / goal /
// backstory). The memory block from workspace files (AGENTS.md, SOUL.md,
// PROFILE.md, MEMORY.md, ...) augments that identity with durable
// context. Both are independently optional, but when both exist they
// must be joined — earlier this branch picked memory and silently
// dropped the identity prompt, so editor-side identity changes never
// reached runtime if the agent had any workspace files.
String identityPrompt = entity.getSystemPrompt() != null ? entity.getSystemPrompt().trim() : "";
String memoryPrompt = memoryManager.buildSystemPromptBlock(entity.getId(), memoryBudgetTokens);
StringBuilder basePromptBuilder = new StringBuilder();
if (!identityPrompt.isEmpty()) {
basePromptBuilder.append(identityPrompt);
}
if (memoryPrompt != null && !memoryPrompt.isBlank()) {
if (basePromptBuilder.length() > 0) {
basePromptBuilder.append("\n\n");
}
basePromptBuilder.append(memoryPrompt);
}
String basePrompt = basePromptBuilder.toString();
// The skill catalog (## Skills) is NOT baked here. It is rendered at
// runtime by the reasoning / step-execution nodes via
// SkillCatalogRenderer so its ordering can react to skills loaded this
// run (load_skill pins). Keeping it out of the baked system prompt also
// keeps the prompt-cache prefix stable across turns.
// 工具调用指导
String toolGuidance = """
## Runtime Context
- Current Agent ID: %s
## Workspace Memory Guidelines
Your durable memory is stored in database-backed workspace markdown files for this agent:
- `PROFILE.md`: stable user profile, preferences, collaboration style
- `MEMORY.md`: distilled long-term memory, durable facts, lessons, recurring patterns
- `memory/YYYY-MM-DD.md`: daily notes, raw events, temporary observations, open loops
Use workspace memory tools instead of local filesystem tools for those files:
- `list_workspace_memory_files(agentId=..., filenamePrefix=...)`
- `read_workspace_memory_file(agentId=..., filename=...)`
- `write_workspace_memory_file(agentId=..., filename=..., content=...)`
- `edit_workspace_memory_file(agentId=..., filename=..., oldText=..., newText=...)`
Memory writing policy:
- Stable user preference, identity, collaboration habit -> `PROFILE.md`
- Stable project fact, workflow, tool setup, lesson learned, recurring decision -> `MEMORY.md`
- One-off event, meeting note, temporary context, today's decision trace -> `memory/YYYY-MM-DD.md`
- Read before write unless you are creating a brand new daily note
- Do not store secrets or highly sensitive data unless the user explicitly asks
- Updating workspace memory files is internal state maintenance for this agent and can be done proactively when useful
Memory emergence policy:
- If the same preference, constraint, workflow, or lesson appears repeatedly, consolidate it from daily notes into `MEMORY.md`
- Prefer updating an existing section over appending duplicate bullets
- Treat `MEMORY.md` as a compact mental model, not a raw transcript dump
- When answering tasks involving prior decisions, preferences, habits, or ongoing work, proactively consult relevant workspace memory first
## Structured Memory Tools
For discrete, typed facts use structured memory tools (separate from workspace files):
- `remember_structured(agentId, type, key, content)` — store a typed entry
- `recall_structured(agentId, type, keyword)` — search entries by type and/or keyword
- `forget_structured(agentId, type, key)` — remove an entry
Types:
- `user`: preferences, expertise, communication style, role
- `feedback`: behavioral corrections or confirmed approaches (include WHY)
- `project`: decisions, deadlines, constraints not derivable from code/git
- `reference`: pointers to external systems (Linear boards, Grafana dashboards, Slack channels)
Use workspace memory tools (MEMORY.md, daily notes) for long-form narrative notes.
Use structured memory tools for key-value facts the system can query efficiently.
## Memory vs Knowledge Base Precedence
When a question is about the user themselves — who they are, their current
project, its name/codename, tech stack, goals, metrics, budget, team, or what
they are working on — your recalled memory (the <memory-context> block plus
structured/workspace memory) is the authoritative source. Knowledge-base / wiki
pages are reference material that may describe unrelated, example, or upstream
projects; do NOT treat a KB page's subject as the user's own project. Only read
the knowledge base for explicit reference lookups, never to decide what the
user's project is. If memory and a KB page disagree about the user's project,
trust memory. If memory has no answer, say you do not have it rather than
adopting a KB article as the user's project.
## Session Search
- `session_search(agentId, mode, query, limit)` — search conversation history
- mode="recent": list recent conversations (titles, times, message counts)
- mode="search": keyword full-text search across past messages
- Use this to recall previous discussions, look up past decisions, or find context from earlier conversations
- Only completed sessions (not currently running) are included in results
## Tool Usage Guidelines
When you have available tools, use them to access local system information, files, or execute commands.
Do not assume you cannot access local resources - try calling the appropriate tool first.
If a tool requires approval due to security policies, the system will prompt the user for confirmation.
Only state you cannot access something if no relevant tool is available.
Do not claim a tool-generated file, URL, UUID, path, task id, or success result before the corresponding tool call has completed. If a tool is needed, call the tool first, then report only the actual returned result.
## MCP Tool Naming
Tools from MCP servers have names shaped like `mcp_<serverId>_<slug>_<hash6>`:
- `<serverId>` is a numeric ID identifying which MCP server the tool belongs to.
- Tools from DIFFERENT servers have DIFFERENT serverId prefixes, even if they have the same raw name (e.g. `search` on server A vs server B) — they are DIFFERENT tools and are NOT interchangeable.
- Each MCP tool's description starts with `[MCP server: <name>]` so you can identify the source server by its human-readable name.
- MCP tools are listed in the Extension Tools catalog by default. Call `tool_call(toolName="<exact-name>", arguments={...})` to execute one in the same action round; use `tool_search` first only when the exact name is unknown.
- Always call tools by the EXACT name shown in the tool list. Do NOT reconstruct a tool name by swapping the slug into a serverId you remember from a previous successful call — that produces a non-existent tool name and the call will fail.
- If a tool call returns "Tool not found" with candidate suggestions, pick the correct one from the candidates verbatim.
## Multi-Part Question Guidelines
When the user asks multiple questions or requests multiple tasks in a single message:
1. Structure your final answer with numbered sections, one per sub-task
2. Each section must contain the complete, detailed result for that sub-task
3. Never compress earlier sub-tasks into summary sentences while expanding the last one
4. If observations were summarized during processing, reconstruct each section from the summary
5. Treat each sub-task's result as equally important regardless of processing order
## File Reading Guidelines
**Text Files** (use read_file):
For .txt, .md, .json, .yaml, .csv, .log, .py, .java, .js, .html, .xml, .sql, .conf, .ini, .toml files.
**Office/PDF Documents** (DO NOT use read_file):
For .pdf, .docx, .doc, .xlsx, .xls, .pptx, .ppt files, NEVER use read_file.
Instead use:
- detect_file_type(filePath="...") - to check file type first
- extract_document_text(filePath="...") - general document extraction
- extract_pdf_text(filePath="...") - for PDF files
- extract_docx_text(filePath="...") - for Word documents
Example workflow for document:
1. detect_file_type(filePath="/path/to/document.pdf")
2. Based on result, use extract_pdf_text() or extract_document_text()
3. Process the extracted text content
If you try to read a PDF/Office file with read_file, you will get binary garbage or an error.
## ProgressLedger Discipline (mandatory)
The `## 当前任务进度` block injected near the top of every turn is the **authoritative record** of what you have done and what remains. Treat it as ground truth, not as a scratchpad you may ignore.
- **On starting any multi-step task** (≥3 tool calls expected), call `progress_update` in parallel batches of at most 16 calls to register every pending step BEFORE doing the work. Split larger ledgers across turns so the executor cap never drops entries.
- **After each completed sub-step**, immediately call `progress_update` to flip its status to `done`. "Immediately" means in the same tool_calls batch that returns the result, not after the next reasoning turn.
- **Never re-execute a step the ledger shows as `done`** unless you can articulate why the prior result is stale.
- **🔒 固定约束 entries** (pinned from skill manifests) are non-negotiable. They survive context compression for a reason — re-read them every turn and make sure your planned action still satisfies them.
- **🔧 自动记录 entries** are auto-filled by Java after each tool call. They are a safety net, not a substitute for your own `progress_update` — if you only rely on them, you will lose the pending/blocked view that drives planning.
- If you see a `⚠️ 进度账本已 N 秒未更新` reminder, **stop whatever you are doing and update the ledger first**. Continuing to call tools without updating the ledger is the #1 cause of duplicate work and missed steps.
- The ledger is per-conversation and persists across context trims; treating it as ephemeral will cause you to repeat work after every compaction.
## Environment Change Notifications
Occasionally you will see a `## 📢 环境变更通知` block injected near the top of a turn. It is generated by Java when an external event affects your runtime — an MCP server disconnecting, a skill being updated/removed, or a tool binding change.
- These notifications are **authoritative** — Java detected the change; do not second-guess them by re-probing the tool.
- If a notification says an MCP server is unavailable, **immediately stop calling tools prefixed with that server's id** and either switch to an alternative or report the gap to the user.
- If a notification says a skill was updated, **re-load it via `load_skill`** to refresh its constraints in your pinned ledger; the old constraints you remember may no longer apply.
- If a notification says a tool was removed, **do not attempt to call it**; pick a different approach or ask the user.
- These notifications appear at most once per event; if you miss one, it will not be repeated, so act on it in the turn you see it.
""".formatted(entity.getId());
// Web-search vs browser_use priority guidance — emitted unconditionally so the rule
// also reaches OpenAI-compatible / Anthropic / Gemini / DeepSeek / Ollama agents that
// do not have builtin search. Issue #40: without this rule the model treats
// browser_use as a search tool and gets stuck in a Playwright launch loop on Windows.
String searchGuidance = """
## Web Search Capability
### Tool Priority
- For plain web search or fetching public page content, call the `search` tool. It supports advanced parameters: `freshness` (day/week/month/year), `language` (zh-CN/en), `count` (1-10).
- Call `browser_use` ONLY when you need to interact with a page (click, fill forms, screenshot, run JS, follow a logged-in flow). Do NOT use `browser_use` as a search alternative.
- **NEVER** call both `browser_use` and `search` for the same query.
- When searching for news, use the standard format: `📰 [Category] Title — Source | Time + Summary`, up to 5 results per category.
""";
if (builtinSearchEnabled) {
searchGuidance += """
### Built-in Search (preferred when available)
Your responses automatically incorporate live web search results from the model provider. For most queries, answer directly — your reply already includes real-time search data. Do NOT say you cannot search.
Use the `search` tool ONLY when you need precise time filtering (e.g., "yesterday's news" → freshness=day), a specific language, or when built-in results feel insufficient.
""";
}
// Wiki 知识库上下文注入。Share the same prefix budget as the memory
// block so a large KB's page listing can't consume a fixed
// maxContextChars-sized slice of a small model's window every turn
// (issue #521). Integer.MAX_VALUE (the unbudgeted default path) keeps
// the legacy chars-only cap for large cloud models.
Integer wikiBudgetTokens = memoryBudgetTokens == Integer.MAX_VALUE ? null : memoryBudgetTokens;
String wikiContext = wikiContextService.buildWikiContext(entity.getId(), wikiBudgetTokens);
// Team context (role-specific board playbook, or a negative notice for
// agents outside any team). Baked here so it shares the prompt-cache
// prefix; TeamChangedEvent evicts the cached agent on composition changes.
String teamContext = teamContextBuilder.buildTeamContext(entity.getId());
return basePrompt + ABOUT_YOU_BLOCK + toolGuidance + searchGuidance + wikiContext + teamContext;
}
/**
* Build the per-agent {@link SkillCatalogRenderer}. Captures the agent's
* bound skills, effective tool allowlist, model window and workspace once;
* the returned renderer is invoked each turn with the skills loaded so far
* this run so {@code load_skill} pins float to the top of the catalog.
*
* <p>agent-4: when any loaded skill declares structured {@code constraints}
* in its manifest, the rendered catalog gets a trailing anchor note
* {@code "🔒 = 含固定约束的 skill详见 ProgressLedger"} so the LLM has
* a visible cue that some skills carry non-negotiable rules pinned into
* the ledger. The cue is appended (not interleaved) to keep the
* catalog's prompt-cache hash stable for the unchanged prefix.
*/
private SkillCatalogRenderer buildSkillCatalogRenderer(AgentEntity entity, Set<String> boundTools,
Integer maxInputTokens) {
Set<Long> boundSkillIds = agentBindingService.getBoundSkillIds(entity.getId());
Long agentId = entity.getId();
Long workspaceId = entity.getWorkspaceId();
return loaded -> {
String catalog = skillRuntimeService.buildSkillPromptEnhancement(
boundSkillIds, boundTools, maxInputTokens, agentId, workspaceId, loaded);
if (catalog == null || catalog.isBlank() || loaded == null || loaded.isEmpty()) {
return catalog;
}
// Scan loaded skills for any with structured constraints. We only
// surface the anchor when at least one matches, to avoid noisy
// output on constraint-free skills.
boolean anyHasConstraints = false;
for (String skillName : loaded) {
try {
vip.mate.skill.runtime.model.ResolvedSkill skill = skillRuntimeService.findActiveSkill(skillName, workspaceId);
if (skill != null && skill.getManifest() != null) {
List<String> constraints = skill.getManifest().getConstraints();
if (constraints != null && !constraints.isEmpty()) {
anyHasConstraints = true;
break;
}
}
} catch (Exception ignored) {
// best-effort lookup; do not fail the catalog render
}
}
if (!anyHasConstraints) {
return catalog;
}
return catalog + "\n\n🔒 = 含固定约束的 skill已写入 ProgressLedger详见 🔒 固定约束 段落,全程不可忽略)";
};
}
/**
* Resolve the {@code reasoning_effort} to pass to the reasoning /
* step-execution nodes for the given model.
*/
private String resolveReasoningEffortForModel(ModelConfigEntity runtimeModel) {
ModelProviderEntity provider = modelProviderService.getProviderConfig(runtimeModel.getProvider());
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
ModelFamily family = ModelFamily.detect(runtimeModel.getModelName());
return ReasoningEffortResolver.resolveReasoningEffort(runtimeModel.getModelName(), kwargs, family);
}
}