mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
perf(agent): add progressive tool bridge
This commit is contained in:
parent
a204e8eec8
commit
8dc6c64683
@ -12,6 +12,7 @@ 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;
|
||||
@ -453,24 +454,19 @@ public class AgentGraphBuilder {
|
||||
SkillCatalogRenderer skillCatalogRenderer = buildSkillCatalogRenderer(
|
||||
entity, boundTools, effectiveMaxInputTokens);
|
||||
|
||||
// Extension-tool catalog — only for ReAct. The dynamic tool split runs
|
||||
// in ReasoningNode; Plan-Execute keeps advertising every tool (it has no
|
||||
// action node to record enable_tool), so baking the catalog there would
|
||||
// describe an enable_tool flow that can never take effect.
|
||||
// Auto-demotion is likewise ReAct-only: hiding a tool from Plan-Execute
|
||||
// would remove it with no enable_tool path to recover it.
|
||||
boolean isPlanExecute = "plan_execute".equals(entity.getAgentType());
|
||||
// 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 (!isPlanExecute) {
|
||||
if (prefixBudgetPlan.enabled()) {
|
||||
autoDemotedTools = toolDisclosureService.computeAutoDemotions(
|
||||
toolSet, prefixBudgetPlan.toolSchemaBudgetTokens());
|
||||
}
|
||||
String extensionCatalog = toolDisclosureService.renderExtensionCatalog(
|
||||
toolSet, effectiveMaxInputTokens, autoDemotedTools);
|
||||
if (extensionCatalog != null && !extensionCatalog.isBlank()) {
|
||||
enhancedPrompt = enhancedPrompt + extensionCatalog;
|
||||
}
|
||||
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,其他协议直接拒绝
|
||||
@ -482,7 +478,8 @@ public class AgentGraphBuilder {
|
||||
BaseAgent agent;
|
||||
boolean toolCallingEnabled;
|
||||
if ("plan_execute".equals(entity.getAgentType())) {
|
||||
agent = buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer);
|
||||
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());
|
||||
@ -606,11 +603,19 @@ public class AgentGraphBuilder {
|
||||
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);
|
||||
runtimeModel, agentId, skillCatalogRenderer, autoDemotedTools);
|
||||
return new StateGraphPlanExecuteAgent(chatClient, conversationService, graph, planningService,
|
||||
chatModel, conversationWindowManager, toolSet);
|
||||
}
|
||||
@ -634,6 +639,14 @@ public class AgentGraphBuilder {
|
||||
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(
|
||||
@ -667,7 +680,13 @@ public class AgentGraphBuilder {
|
||||
// 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);
|
||||
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager, skillCatalogRenderer);
|
||||
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);
|
||||
@ -1702,7 +1721,7 @@ public class AgentGraphBuilder {
|
||||
- `<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. Use `enable_tool(toolName="<exact-name>")` to activate the one you need before calling it.
|
||||
- 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.
|
||||
|
||||
|
||||
@ -691,6 +691,12 @@ public class AgentBindingService implements AgentBindingResolver {
|
||||
// extension-tier tool for the rest of the conversation. Must be
|
||||
// agent-wide so the model can always surface hidden tools.
|
||||
"enable_tool",
|
||||
// Stable Hermes-style bridges. They must survive every explicit
|
||||
// allowlist because they are the only way to discover, inspect
|
||||
// and invoke schemas that were deferred by the hard budget.
|
||||
"tool_search",
|
||||
"tool_describe",
|
||||
"tool_call",
|
||||
// Skill discovery / dispatch — skills are docs, not callables;
|
||||
// these helpers let the LLM read SKILL.md / run scripts.
|
||||
"load_skill",
|
||||
|
||||
@ -87,7 +87,8 @@ public class PrefixBudgetPlanner {
|
||||
(int) (injectionBudget * shares.getSkill() / sum),
|
||||
(int) (injectionBudget * shares.getExtensionCatalog() / sum),
|
||||
(int) (injectionBudget * shares.getLedger() / sum),
|
||||
(int) (effectiveMax * properties.getToolSchemaRatio()));
|
||||
Math.min((int) (effectiveMax * properties.getToolSchemaRatio()),
|
||||
Math.max(1, properties.getToolSchemaMaxTokens())));
|
||||
|
||||
if (profile != PrefixBudgetPlan.Profile.NORMAL) {
|
||||
log.info("[PrefixBudget] 窗口 {} tokens 进入 {} 档:注入预算 {} tokens"
|
||||
|
||||
@ -7,6 +7,7 @@ import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||
import vip.mate.tool.builtin.ProgressiveToolBridgeTool;
|
||||
import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
|
||||
import vip.mate.tool.mcp.runtime.McpProgressContext;
|
||||
import vip.mate.tool.mcp.runtime.McpToolNameResolver;
|
||||
@ -513,6 +514,30 @@ public class ToolExecutionExecutor {
|
||||
|
||||
for (int i = 0; i < effectiveCalls.size(); i++) {
|
||||
AssistantMessage.ToolCall toolCall = effectiveCalls.get(i);
|
||||
// Keep the provider-facing response name paired with the function
|
||||
// name emitted by the model. The execution name may be rewritten
|
||||
// below (tool_call -> real target), but Gemini pairs a
|
||||
// functionResponse by name rather than OpenAI's call_id alone.
|
||||
String responseName = toolCall.name();
|
||||
// Hermes-style deferred tool proxy: unwrap tool_call before any
|
||||
// policy decision so guard, approval, audit, concurrency and UI
|
||||
// all operate on the real tool. The executor's callback map is
|
||||
// already scoped to this agent, making it the final authority for
|
||||
// whether the requested target may be invoked.
|
||||
if (ProgressiveToolBridgeTool.CALL.equals(resolveToolName(toolCall.name()))) {
|
||||
BridgeUnwrap unwrap = unwrapBridgeCall(toolCall);
|
||||
if (unwrap.error() != null) {
|
||||
events.add(GraphEventPublisher.toolStart(
|
||||
toolCall.id(), ProgressiveToolBridgeTool.CALL, toolCall.arguments()));
|
||||
events.add(GraphEventPublisher.toolComplete(
|
||||
toolCall.id(), ProgressiveToolBridgeTool.CALL, unwrap.error(), false));
|
||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||
toolCall.id(), responseName, unwrap.error()));
|
||||
continue;
|
||||
}
|
||||
toolCall = unwrap.toolCall();
|
||||
log.info("[ToolExecutor] Progressive bridge unwrapped tool_call -> {}", toolCall.name());
|
||||
}
|
||||
// Resolve LLM-emitted name to canonical BEFORE guard / lookup so a
|
||||
// mangled name (Read_File, web_search_tool, BrowserUseTool) can't
|
||||
// bypass guard rules keyed on the canonical name.
|
||||
@ -545,7 +570,7 @@ public class ToolExecutionExecutor {
|
||||
}
|
||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
|
||||
allResponses.add(new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse(
|
||||
toolCall.id(), toolName, msg));
|
||||
toolCall.id(), responseName, msg));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@ -560,7 +585,7 @@ public class ToolExecutionExecutor {
|
||||
String truncationError = normalizeToolExecutionError(jsonEx);
|
||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false));
|
||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||
toolCall.id(), toolName, truncationError));
|
||||
toolCall.id(), responseName, truncationError));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@ -572,13 +597,13 @@ public class ToolExecutionExecutor {
|
||||
|
||||
if (decision.blocked) {
|
||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||
toolCall.id(), toolName, decision.response));
|
||||
toolCall.id(), responseName, decision.response));
|
||||
continue;
|
||||
}
|
||||
if (decision.needsApproval) {
|
||||
// Barrier: 当前工具创建审批,后续工具不执行
|
||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||
toolCall.id(), toolName, decision.response));
|
||||
toolCall.id(), responseName, decision.response));
|
||||
// 标记后续工具为等待审批
|
||||
for (int j = i + 1; j < effectiveCalls.size(); j++) {
|
||||
AssistantMessage.ToolCall remaining = effectiveCalls.get(j);
|
||||
@ -597,7 +622,7 @@ public class ToolExecutionExecutor {
|
||||
if (toolName.startsWith("$")) {
|
||||
log.info("[ToolExecutor] Skipping provider builtin tool: {}", toolName);
|
||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||
toolCall.id(), toolName, "Provider builtin tool executed server-side"));
|
||||
toolCall.id(), responseName, "Provider builtin tool executed server-side"));
|
||||
continue;
|
||||
}
|
||||
ToolCallback callback = toolCallbackMap.get(toolName);
|
||||
@ -610,20 +635,20 @@ public class ToolExecutionExecutor {
|
||||
events.add(GraphEventPublisher.toolComplete(
|
||||
toolCall.id(), toolName, redirect.response(), true));
|
||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||
toolCall.id(), toolName, redirect.response()));
|
||||
toolCall.id(), responseName, redirect.response()));
|
||||
continue;
|
||||
}
|
||||
String msg = skillAwareNotFoundMessage(toolName, safeOrigin);
|
||||
log.warn("[ToolExecutor] {}", msg);
|
||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
|
||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||
toolCall.id(), toolName, msg));
|
||||
toolCall.id(), responseName, msg));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. 分类: concurrencySafe
|
||||
boolean safe = isConcurrencySafe(toolName);
|
||||
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size(),
|
||||
preparedCalls.add(new PreparedToolCall(toolCall, responseName, callback, arguments, safe, allResponses.size(),
|
||||
conversationId, requesterId, workspaceBasePath, safeOrigin, rawEvidenceRef));
|
||||
// 占位,Phase 2 填充
|
||||
allResponses.add(null);
|
||||
@ -719,7 +744,7 @@ public class ToolExecutionExecutor {
|
||||
ChatOrigin replayOrigin = ChatOrigin.EMPTY
|
||||
.withConversationId(conversationId)
|
||||
.withWorkspace(null, workspaceBasePath);
|
||||
String result = callback.call(callArguments, replayOrigin.toToolContext());
|
||||
String result = callback.call(callArguments, toolContextWithScopedCatalog(replayOrigin));
|
||||
int rawLen = result != null ? result.length() : 0;
|
||||
|
||||
// RFC-052: pre-approved tool may itself be returnDirect — in that
|
||||
@ -879,7 +904,7 @@ public class ToolExecutionExecutor {
|
||||
PreparedToolCall pc = batch.stream()
|
||||
.filter(p -> p.resultIndex == entry.getKey())
|
||||
.findFirst().orElse(null);
|
||||
String toolName = pc != null ? pc.toolCall.name() : "unknown";
|
||||
String toolName = pc != null ? pc.responseName : "unknown";
|
||||
String toolId = pc != null ? pc.toolCall.id() : "";
|
||||
log.error("[ToolExecutor] Parallel tool {} failed: {}", toolName, e.getMessage());
|
||||
allResponses.set(entry.getKey(), new ToolResponseMessage.ToolResponse(
|
||||
@ -917,7 +942,7 @@ public class ToolExecutionExecutor {
|
||||
runtimeOrigin = runtimeOrigin
|
||||
.withConversationId(pc.conversationId)
|
||||
.withWorkspace(runtimeOrigin.workspaceId(), pc.workspaceBasePath);
|
||||
ToolContext toolContext = runtimeOrigin.toToolContext();
|
||||
ToolContext toolContext = toolContextWithScopedCatalog(runtimeOrigin);
|
||||
|
||||
// MCP progress: generate progressToken and inject into ToolContext
|
||||
// so ProgressAwareMcpToolCallback can include it in tools/call _meta.
|
||||
@ -968,7 +993,7 @@ public class ToolExecutionExecutor {
|
||||
// any subsequent LLM round (the graph won't take a next round —
|
||||
// see ObservationDispatcher RETURN_DIRECT_TRIGGERED branch).
|
||||
return new ToolResponseMessage.ToolResponse(
|
||||
pc.toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
|
||||
pc.toolCall.id(), pc.responseName, DIRECT_TOOL_PLACEHOLDER);
|
||||
}
|
||||
|
||||
// Capture SourceEvidenceLedger from the RAW result, before truncate/
|
||||
@ -1008,7 +1033,8 @@ public class ToolExecutionExecutor {
|
||||
// Append the card-rendering directive to the LLM-facing response only,
|
||||
// leaving the broadcast tool-result panel unchanged.
|
||||
return new ToolResponseMessage.ToolResponse(
|
||||
pc.toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : ""));
|
||||
pc.toolCall.id(), pc.responseName,
|
||||
withProductCardDirective(toolName, result != null ? result : ""));
|
||||
} catch (Exception e) {
|
||||
log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e);
|
||||
// RFC-052: for returnDirect tools, even the error message is
|
||||
@ -1026,7 +1052,7 @@ public class ToolExecutionExecutor {
|
||||
streamTracker.updateRunningTool(pc.conversationId, null);
|
||||
}
|
||||
return new ToolResponseMessage.ToolResponse(
|
||||
pc.toolCall.id(), toolName, reportedError);
|
||||
pc.toolCall.id(), pc.responseName, reportedError);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1462,7 +1488,7 @@ public class ToolExecutionExecutor {
|
||||
+ "\",\"filePath\":\"SKILL.md\"}";
|
||||
String skillMd;
|
||||
try {
|
||||
ToolContext ctx = (origin != null ? origin : ChatOrigin.EMPTY).toToolContext();
|
||||
ToolContext ctx = toolContextWithScopedCatalog(origin != null ? origin : ChatOrigin.EMPTY);
|
||||
skillMd = readSkillFile.call(redirectArgs, ctx);
|
||||
} catch (Exception e) {
|
||||
log.warn("[ToolExecutor] Auto-redirect readSkillFile failed for '{}': {}", toolName, e.getMessage());
|
||||
@ -1487,10 +1513,104 @@ public class ToolExecutionExecutor {
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a progressive {@code tool_call} envelope. Validation
|
||||
* happens before guard execution and never invokes a callback. In
|
||||
* particular, required-argument probing mirrors Hermes: an incomplete
|
||||
* call returns the target schema immediately instead of spending another
|
||||
* round on a guaranteed callback failure.
|
||||
*/
|
||||
private BridgeUnwrap unwrapBridgeCall(AssistantMessage.ToolCall bridgeCall) {
|
||||
try {
|
||||
var envelope = OBJECT_MAPPER.readTree(bridgeCall.arguments());
|
||||
String requestedName = textField(envelope, "toolName", "name");
|
||||
if (requestedName == null || requestedName.isBlank()) {
|
||||
return BridgeUnwrap.error("Error: tool_call requires an exact toolName.");
|
||||
}
|
||||
String targetName = resolveToolName(requestedName);
|
||||
if (ProgressiveToolBridgeTool.BRIDGE_NAMES.contains(targetName)) {
|
||||
return BridgeUnwrap.error("Error: tool_call cannot invoke a progressive bridge recursively.");
|
||||
}
|
||||
ToolCallback target = toolCallbackMap.get(targetName);
|
||||
if (target == null) {
|
||||
return BridgeUnwrap.error("Error: Tool '" + requestedName
|
||||
+ "' is not available to this agent. Use tool_search for scoped results.");
|
||||
}
|
||||
|
||||
var argsNode = envelope != null && envelope.has("arguments")
|
||||
? envelope.get("arguments")
|
||||
: envelope != null ? envelope.get("args") : null;
|
||||
String targetArguments;
|
||||
if (argsNode == null || argsNode.isNull()) {
|
||||
targetArguments = "{}";
|
||||
} else if (argsNode.isTextual()) {
|
||||
targetArguments = argsNode.asText();
|
||||
// A textual envelope is accepted for weaker models, but it
|
||||
// must itself contain valid JSON before proceeding.
|
||||
OBJECT_MAPPER.readTree(targetArguments);
|
||||
} else {
|
||||
targetArguments = OBJECT_MAPPER.writeValueAsString(argsNode);
|
||||
}
|
||||
|
||||
String missing = missingRequiredArguments(target, targetArguments);
|
||||
if (missing != null) {
|
||||
return BridgeUnwrap.error("Error: Missing required arguments for '" + targetName
|
||||
+ "': " + missing + ". Full input schema: "
|
||||
+ target.getToolDefinition().inputSchema());
|
||||
}
|
||||
return BridgeUnwrap.success(new AssistantMessage.ToolCall(
|
||||
bridgeCall.id(), bridgeCall.type(), targetName, targetArguments));
|
||||
} catch (Exception e) {
|
||||
return BridgeUnwrap.error("Error: invalid tool_call envelope: " + normalizeToolExecutionError(e));
|
||||
}
|
||||
}
|
||||
|
||||
private static String textField(com.fasterxml.jackson.databind.JsonNode node, String... names) {
|
||||
if (node == null) return null;
|
||||
for (String name : names) {
|
||||
var value = node.get(name);
|
||||
if (value != null && value.isTextual()) return value.asText();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String missingRequiredArguments(ToolCallback callback, String arguments) {
|
||||
try {
|
||||
var schema = OBJECT_MAPPER.readTree(callback.getToolDefinition().inputSchema());
|
||||
var required = schema.get("required");
|
||||
if (required == null || !required.isArray() || required.isEmpty()) return null;
|
||||
var actual = OBJECT_MAPPER.readTree(arguments);
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (var name : required) {
|
||||
if (actual == null || !actual.has(name.asText()) || actual.get(name.asText()).isNull()) {
|
||||
missing.add(name.asText());
|
||||
}
|
||||
}
|
||||
return missing.isEmpty() ? null : String.join(", ", missing);
|
||||
} catch (Exception ignored) {
|
||||
// Bad third-party schemas should not make an otherwise valid tool
|
||||
// unreachable; the callback remains the source of truth.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries the executor's immutable, agent-scoped callback snapshot into
|
||||
* catalog bridge calls. This makes tool_search/tool_describe observe the
|
||||
* exact same authority set that tool_call validates against.
|
||||
*/
|
||||
private ToolContext toolContextWithScopedCatalog(ChatOrigin origin) {
|
||||
ToolContext base = (origin != null ? origin : ChatOrigin.EMPTY).toToolContext();
|
||||
Map<String, Object> context = new HashMap<>(base.getContext());
|
||||
context.put(ProgressiveToolBridgeTool.SCOPED_TOOL_CALLBACKS_CONTEXT_KEY, toolCallbackMap);
|
||||
return new ToolContext(context);
|
||||
}
|
||||
|
||||
// ==================== 内部数据类 ====================
|
||||
|
||||
private record PreparedToolCall(
|
||||
AssistantMessage.ToolCall toolCall,
|
||||
String responseName,
|
||||
ToolCallback callback,
|
||||
String arguments,
|
||||
boolean concurrencySafe,
|
||||
@ -1520,6 +1640,16 @@ public class ToolExecutionExecutor {
|
||||
java.util.concurrent.atomic.AtomicReference<SourceEvidenceLedger> rawEvidenceCollector
|
||||
) {}
|
||||
|
||||
private record BridgeUnwrap(AssistantMessage.ToolCall toolCall, String error) {
|
||||
static BridgeUnwrap success(AssistantMessage.ToolCall call) {
|
||||
return new BridgeUnwrap(call, null);
|
||||
}
|
||||
|
||||
static BridgeUnwrap error(String message) {
|
||||
return new BridgeUnwrap(null, message);
|
||||
}
|
||||
}
|
||||
|
||||
private record ApprovalBarrier(String pendingId, String toolName) {}
|
||||
|
||||
private static final class GuardDecision {
|
||||
|
||||
@ -51,6 +51,10 @@ public class ActionNode implements NodeAction {
|
||||
/** Function name of the extension-tool activator, mirrored from EnableExtensionTool. */
|
||||
private static final String ENABLE_TOOL = "enable_tool";
|
||||
|
||||
/** Progressive catalog inspection is setup; tool_call itself is real work. */
|
||||
private static final String TOOL_SEARCH = "tool_search";
|
||||
private static final String TOOL_DESCRIBE = "tool_describe";
|
||||
|
||||
/** Function name of the progress-update tool — skip auto-recording it. */
|
||||
private static final String PROGRESS_UPDATE_TOOL = "progress_update";
|
||||
|
||||
@ -70,7 +74,7 @@ public class ActionNode implements NodeAction {
|
||||
* </ul>
|
||||
*/
|
||||
private static final Set<String> AUTO_RECORD_SKIP = Set.of(
|
||||
LOAD_SKILL_TOOL, ENABLE_TOOL, PROGRESS_UPDATE_TOOL,
|
||||
LOAD_SKILL_TOOL, ENABLE_TOOL, TOOL_SEARCH, TOOL_DESCRIBE, PROGRESS_UPDATE_TOOL,
|
||||
"listAvailableSkills", "readSkillFile", "runSkillScript",
|
||||
// read-only / status-query tools
|
||||
"read_file", "web_search",
|
||||
|
||||
@ -41,7 +41,7 @@ public class ObservationNode implements NodeAction {
|
||||
* set in {@code DefaultToolDisclosureService.ALWAYS_CORE}.
|
||||
*/
|
||||
private static final java.util.Set<String> DISCLOSURE_TOOLS =
|
||||
java.util.Set.of("load_skill", "enable_tool");
|
||||
java.util.Set.of("load_skill", "enable_tool", "tool_search", "tool_describe");
|
||||
|
||||
/** Per-run cap on iteration refunds — keeps a load-skill-only model from looping forever. */
|
||||
private static final int MAX_ITERATION_REFUNDS_PER_RUN = 3;
|
||||
|
||||
@ -51,10 +51,18 @@ public class PrefixBudgetProperties {
|
||||
* Fraction of the effective window the advertised tool schemas may
|
||||
* occupy. When the core tool set estimates above this, the least
|
||||
* recently used demotable tools are auto-moved to the extension catalog
|
||||
* (recoverable via {@code enable_tool}) until the set fits.
|
||||
* (recoverable via {@code tool_call}) until the set fits.
|
||||
*/
|
||||
private double toolSchemaRatio = 0.25;
|
||||
|
||||
/**
|
||||
* Absolute ceiling for schemas advertised on every model request. The
|
||||
* ratio alone is ineffective for very large declared context windows
|
||||
* (for example 25% of 1M tokens), which allowed tens of thousands of
|
||||
* fixed schema tokens to survive every round.
|
||||
*/
|
||||
private int toolSchemaMaxTokens = 12000;
|
||||
|
||||
/** Relative shares of the injection budget. Normalized at plan time. */
|
||||
private Shares shares = new Shares();
|
||||
|
||||
|
||||
@ -0,0 +1,250 @@
|
||||
package vip.mate.tool.builtin;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.AgentToolSet;
|
||||
import vip.mate.agent.binding.service.AgentBindingService;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
import vip.mate.tool.ToolRegistry;
|
||||
import vip.mate.tool.guard.service.ToolGuardConfigService;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Stable, small-schema bridge for progressively disclosed tools.
|
||||
*
|
||||
* <p>The catalog is rebuilt from the current agent's effective tool set on
|
||||
* every call, so search/describe cannot reveal tools outside its binding.
|
||||
* {@code tool_call} is intentionally not executed here: the graph executor
|
||||
* unwraps it before guard/approval/audit and invokes the real callback in the
|
||||
* same action round. That keeps the bridge from becoming a security bypass.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ProgressiveToolBridgeTool {
|
||||
|
||||
public static final String SEARCH = "tool_search";
|
||||
public static final String DESCRIBE = "tool_describe";
|
||||
public static final String CALL = "tool_call";
|
||||
public static final Set<String> BRIDGE_NAMES = Set.of(SEARCH, DESCRIBE, CALL);
|
||||
/** Executor-owned, immutable callback snapshot carried through ToolContext. */
|
||||
public static final String SCOPED_TOOL_CALLBACKS_CONTEXT_KEY =
|
||||
"mateclaw.progressiveToolCallbacks";
|
||||
|
||||
private static final int DEFAULT_LIMIT = 8;
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final ToolRegistry toolRegistry;
|
||||
private final AgentBindingService agentBindingService;
|
||||
private final ToolGuardConfigService toolGuardConfigService;
|
||||
|
||||
@Tool(name = SEARCH, description = """
|
||||
Search the current agent's tool catalog by capability. Returns compact
|
||||
names and one-line descriptions, not full schemas. If you already know
|
||||
the exact tool name from the Extension Tools catalog, skip this search
|
||||
and call tool_call directly.
|
||||
""")
|
||||
public String search(
|
||||
@ToolParam(description = "Capability or keywords to search for", required = false)
|
||||
String query,
|
||||
@ToolParam(description = "Maximum results (default 8, maximum 20)", required = false)
|
||||
Integer limit,
|
||||
@Nullable ToolContext ctx) {
|
||||
int safeLimit = Math.max(1, Math.min(limit == null ? DEFAULT_LIMIT : limit, 20));
|
||||
List<String> terms = terms(query);
|
||||
boolean browseCatalog = query == null || query.isBlank();
|
||||
List<ToolCallback> candidates = effectiveToolSet(ctx).callbacks().stream()
|
||||
.filter(cb -> !BRIDGE_NAMES.contains(cb.getToolDefinition().name()))
|
||||
.toList();
|
||||
List<ScoredTool> matches = (browseCatalog ? rank(candidates, List.of())
|
||||
: terms.isEmpty() ? List.<ScoredTool>of() : rank(candidates, terms)).stream()
|
||||
.filter(st -> browseCatalog || st.score() > 0.0d)
|
||||
.sorted(Comparator.comparingDouble(ScoredTool::score).reversed()
|
||||
.thenComparing(st -> st.callback().getToolDefinition().name()))
|
||||
.limit(safeLimit)
|
||||
.toList();
|
||||
|
||||
List<Map<String, Object>> rows = new ArrayList<>(matches.size());
|
||||
for (ScoredTool match : matches) {
|
||||
var def = match.callback().getToolDefinition();
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("name", def.name());
|
||||
row.put("description", compact(def.description(), 180));
|
||||
rows.add(row);
|
||||
}
|
||||
return json(Map.of("query", query == null ? "" : query, "tools", rows,
|
||||
"hint", "Use tool_describe only when arguments are unclear; use tool_call to execute in this round."));
|
||||
}
|
||||
|
||||
@Tool(name = DESCRIBE, description = """
|
||||
Return the full JSON input schema for one exact tool name. Use only
|
||||
when its arguments are unclear; description is not a prerequisite for
|
||||
tool_call.
|
||||
""")
|
||||
public String describe(
|
||||
@ToolParam(description = "Exact tool function name") String toolName,
|
||||
@Nullable ToolContext ctx) {
|
||||
ToolCallback callback = effectiveToolSet(ctx).callbackByName().get(toolName);
|
||||
if (callback == null || BRIDGE_NAMES.contains(toolName)) {
|
||||
return json(Map.of("error", "Tool is not available to this agent", "toolName", safe(toolName)));
|
||||
}
|
||||
var def = callback.getToolDefinition();
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("name", def.name());
|
||||
result.put("description", def.description());
|
||||
try {
|
||||
result.put("inputSchema", OBJECT_MAPPER.readTree(def.inputSchema()));
|
||||
} catch (Exception ignored) {
|
||||
result.put("inputSchema", def.inputSchema());
|
||||
}
|
||||
return json(result);
|
||||
}
|
||||
|
||||
@Tool(name = CALL, description = """
|
||||
Execute a tool that is listed in the current agent's tool catalog,
|
||||
including progressively disclosed tools whose full schema is hidden.
|
||||
The real tool is invoked in this same action round. Pass arguments as
|
||||
a JSON object. Use tool_describe first only if you do not know them.
|
||||
""")
|
||||
public String call(
|
||||
@ToolParam(description = "Exact target tool function name") String toolName,
|
||||
@ToolParam(description = "Arguments for the target tool as a JSON object") Map<String, Object> arguments,
|
||||
@Nullable ToolContext ctx) {
|
||||
// Defense in depth. Normal graph execution intercepts this call and
|
||||
// routes it through the real tool's guard/approval path.
|
||||
return "Error: tool_call must be handled by the graph tool executor.";
|
||||
}
|
||||
|
||||
private AgentToolSet effectiveToolSet(ToolContext ctx) {
|
||||
AgentToolSet scoped = scopedToolSet(ctx);
|
||||
if (scoped != null) {
|
||||
return scoped;
|
||||
}
|
||||
// Compatibility fallback for direct/unit invocations outside a graph.
|
||||
// Normal graph execution always supplies the executor-owned snapshot.
|
||||
AgentToolSet set = toolRegistry.getEnabledToolSet();
|
||||
Long agentId = ChatOrigin.from(ctx).agentId();
|
||||
Set<String> denied = new LinkedHashSet<>(toolGuardConfigService.getDeniedTools());
|
||||
if (agentId != null) {
|
||||
denied.addAll(agentBindingService.getSkillDiscoveryDeniedTools(agentId));
|
||||
}
|
||||
set = set.withDeniedToolsFiltered(denied);
|
||||
if (agentId != null) {
|
||||
set = set.withAllowedToolsOnly(agentBindingService.getEffectiveToolNames(agentId));
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
private static AgentToolSet scopedToolSet(ToolContext ctx) {
|
||||
if (ctx == null) return null;
|
||||
Object value = ctx.getContext().get(SCOPED_TOOL_CALLBACKS_CONTEXT_KEY);
|
||||
if (!(value instanceof Map<?, ?> raw)) return null;
|
||||
List<ToolCallback> callbacks = raw.values().stream()
|
||||
.filter(ToolCallback.class::isInstance)
|
||||
.map(ToolCallback.class::cast)
|
||||
.toList();
|
||||
return AgentToolSet.fromCallbacks(List.of(), callbacks);
|
||||
}
|
||||
|
||||
/** Small in-memory BM25 index; catalogs are normally below a few hundred tools. */
|
||||
private static List<ScoredTool> rank(List<ToolCallback> callbacks, List<String> queryTerms) {
|
||||
if (queryTerms.isEmpty()) {
|
||||
return callbacks.stream().map(cb -> new ScoredTool(cb, 0.0d)).toList();
|
||||
}
|
||||
List<CatalogEntry> entries = callbacks.stream().map(ProgressiveToolBridgeTool::catalogEntry).toList();
|
||||
double avgLength = entries.stream().mapToInt(e -> e.tokens().size()).average().orElse(1.0d);
|
||||
Map<String, Long> documentFrequency = queryTerms.stream().collect(Collectors.toMap(
|
||||
Function.identity(),
|
||||
term -> entries.stream().filter(e -> e.frequencies().containsKey(term)).count(),
|
||||
(a, b) -> a,
|
||||
LinkedHashMap::new));
|
||||
int documentCount = Math.max(1, entries.size());
|
||||
List<ScoredTool> result = new ArrayList<>(entries.size());
|
||||
for (CatalogEntry entry : entries) {
|
||||
double score = 0.0d;
|
||||
for (String term : queryTerms) {
|
||||
int frequency = entry.frequencies().getOrDefault(term, 0);
|
||||
long df = documentFrequency.getOrDefault(term, 0L);
|
||||
if (frequency > 0) {
|
||||
double idf = Math.log(1.0d + (documentCount - df + 0.5d) / (df + 0.5d));
|
||||
double denominator = frequency + 1.5d
|
||||
* (1.0d - 0.75d + 0.75d * entry.tokens().size() / avgLength);
|
||||
score += idf * frequency * 2.5d / denominator;
|
||||
}
|
||||
if (entry.normalizedName().equals(term)) score += 8.0d;
|
||||
else if (entry.normalizedName().contains(term)) score += 2.0d;
|
||||
}
|
||||
result.add(new ScoredTool(entry.callback(), score));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static CatalogEntry catalogEntry(ToolCallback callback) {
|
||||
var definition = callback.getToolDefinition();
|
||||
List<String> tokens = new ArrayList<>();
|
||||
tokens.addAll(terms(definition.name().replace('_', ' ')));
|
||||
tokens.addAll(terms(definition.description()));
|
||||
try {
|
||||
var schema = OBJECT_MAPPER.readTree(definition.inputSchema());
|
||||
var properties = schema.path("properties");
|
||||
if (properties.isObject()) {
|
||||
properties.fieldNames().forEachRemaining(name ->
|
||||
tokens.addAll(terms(name.replace('_', ' '))));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Third-party schemas may be malformed; name/description remain searchable.
|
||||
}
|
||||
Map<String, Integer> frequencies = new HashMap<>();
|
||||
tokens.forEach(token -> frequencies.merge(token, 1, Integer::sum));
|
||||
return new CatalogEntry(callback, definition.name().toLowerCase(Locale.ROOT),
|
||||
List.copyOf(tokens), Map.copyOf(frequencies));
|
||||
}
|
||||
|
||||
private static List<String> terms(String query) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return java.util.Arrays.stream(query.toLowerCase(Locale.ROOT)
|
||||
.split("[^\\p{L}\\p{N}]+"))
|
||||
.filter(term -> !term.isBlank())
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static String compact(String value, int max) {
|
||||
String normalized = safe(value).replace('\n', ' ').replaceAll("\\s+", " ").trim();
|
||||
return normalized.length() <= max ? normalized : normalized.substring(0, max - 3) + "...";
|
||||
}
|
||||
|
||||
private static String safe(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private static String json(Object value) {
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsString(value);
|
||||
} catch (JsonProcessingException e) {
|
||||
return "{\"error\":\"Failed to render tool catalog\"}";
|
||||
}
|
||||
}
|
||||
|
||||
private record CatalogEntry(ToolCallback callback, String normalizedName,
|
||||
List<String> tokens, Map<String, Integer> frequencies) {}
|
||||
|
||||
private record ScoredTool(ToolCallback callback, double score) {}
|
||||
}
|
||||
@ -38,10 +38,11 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
|
||||
|
||||
/**
|
||||
* Meta-tools that must always stay core: hiding them would make progressive
|
||||
* disclosure unrecoverable (the model could never call {@code enable_tool}
|
||||
* to surface anything, nor {@code load_skill} to read a skill).
|
||||
* disclosure unrecoverable (the model could neither search/call a deferred
|
||||
* tool nor load a skill).
|
||||
*/
|
||||
private static final Set<String> ALWAYS_CORE = Set.of("enable_tool", "load_skill");
|
||||
private static final Set<String> ALWAYS_CORE = Set.of(
|
||||
"enable_tool", "load_skill", "tool_search", "tool_describe", "tool_call");
|
||||
|
||||
/**
|
||||
* Code-level extension defaults for builtin tools that may not yet have a
|
||||
@ -137,8 +138,9 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>Protection set: {@link #ALWAYS_CORE} meta-tools and builtin tools
|
||||
* with an explicit {@code disclosure_tier = core} row. MCP tools default
|
||||
* <p>Protection set: only the {@link #ALWAYS_CORE} recovery/bridge tools.
|
||||
* An explicit {@code disclosure_tier = core} remains a preference, but it
|
||||
* cannot override the hard per-request schema ceiling. MCP tools default
|
||||
* to EXTENSION (Move 5) so they only enter the CORE list when an operator
|
||||
* explicitly sets {@code disclosure_tier = core} on the server — in that
|
||||
* case they are still demotable, since MCP schemas are typically the
|
||||
@ -171,7 +173,7 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
|
||||
}
|
||||
if (!demoted.isEmpty()) {
|
||||
log.info("[ToolDisclosure] 工具 schema 估算 {} tokens 超出预算 {}——已将 {} 个最少使用的工具"
|
||||
+ "降级到扩展目录(enable_tool 可找回): {}",
|
||||
+ "降级到渐进目录(tool_call 可当轮调用): {}",
|
||||
coreTokens, budgetTokens, demoted.size(), demoted);
|
||||
}
|
||||
return demoted;
|
||||
@ -181,8 +183,7 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
|
||||
if (toolName == null || ALWAYS_CORE.contains(toolName)) {
|
||||
return false;
|
||||
}
|
||||
// An explicit core row is an operator decision — never override it.
|
||||
return snap.builtinTierByName.get(toolName) != DisclosureTier.CORE;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Never-used tools demote first, then least recently used; name-tiebreak keeps builds deterministic. */
|
||||
@ -216,9 +217,9 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("\n\n## Extension Tools\n");
|
||||
sb.append("These tools are not directly callable yet. To use one, first call ");
|
||||
sb.append("`enable_tool(toolName=\"<name>\")`, then issue the real tool call in your next response. ");
|
||||
sb.append("Activation lasts for the rest of this conversation. Only enable a tool when the task needs it.\n\n");
|
||||
sb.append("Full schemas are hidden until needed. If the exact name is known, call ");
|
||||
sb.append("`tool_call(toolName=\"<name>\", arguments={...})` to execute it in this same round. ");
|
||||
sb.append("Use `tool_search` to discover by capability and `tool_describe` only when arguments are unclear.\n\n");
|
||||
sb.append("| Tool | Source | Description |\n");
|
||||
sb.append("|------|--------|-------------|\n");
|
||||
int shown = 0;
|
||||
|
||||
@ -9,7 +9,9 @@ import java.util.Set;
|
||||
/**
|
||||
* Splits an agent's tool set into the subset advertised to the LLM up front
|
||||
* ({@code core} + already-enabled extensions) and the {@code extension} catalog
|
||||
* that stays behind {@code enable_tool} until the model activates it.
|
||||
* that stays behind a stable progressive bridge. The model can invoke a
|
||||
* deferred tool in the same action round through {@code tool_call}; legacy
|
||||
* sessions may still activate one through {@code enable_tool}.
|
||||
*
|
||||
* <p>Tier is resolved per source: builtin / channel atomic tools from
|
||||
* {@code mate_tool.disclosure_tier}, MCP tools from their owning
|
||||
@ -46,8 +48,8 @@ public interface ToolDisclosureService {
|
||||
/**
|
||||
* Decide which core-tier tools to auto-demote so the advertised tool
|
||||
* schemas fit {@code budgetTokens} (estimated). Ranking: never-used tools
|
||||
* first, then least recently used; meta-tools and explicitly configured
|
||||
* core tools are never demoted. Empty when the set already fits, when
|
||||
* first, then least recently used; recovery/bridge meta-tools are never
|
||||
* demoted. Empty when the set already fits, when
|
||||
* {@code budgetTokens} is null, or in legacy disclosure mode.
|
||||
*/
|
||||
default Set<String> computeAutoDemotions(AgentToolSet baseSet, Integer budgetTokens) {
|
||||
@ -63,7 +65,7 @@ public interface ToolDisclosureService {
|
||||
|
||||
/**
|
||||
* Budget-aware variant: auto-demoted tools are listed in the catalog too,
|
||||
* so the model can discover and {@code enable_tool} them back.
|
||||
* so the model can discover and invoke them through {@code tool_call}.
|
||||
*/
|
||||
default String renderExtensionCatalog(AgentToolSet baseSet, Integer maxInputTokens,
|
||||
Set<String> autoDemoted) {
|
||||
|
||||
@ -174,12 +174,16 @@ mateclaw:
|
||||
enabled: true
|
||||
tools:
|
||||
disclosure:
|
||||
# progressive: extension-tier tools are hidden behind the extension-tools
|
||||
# catalog until the model calls enable_tool. By default that's the heavy
|
||||
# generative / browser tools; MCP servers default to core (visible) and
|
||||
# an admin can move a noisy one to extension per server.
|
||||
# progressive: deferred schemas stay behind tool_search/tool_describe and
|
||||
# execute through tool_call in the same action round. enable_tool remains
|
||||
# available only for backwards compatibility with older conversations.
|
||||
# legacy: advertise every bound tool up front (pre-disclosure behavior).
|
||||
mode: ${MATECLAW_TOOLS_DISCLOSURE_MODE:progressive}
|
||||
context:
|
||||
prefix-budget:
|
||||
# Ratio remains useful for small contexts; this hard ceiling is what keeps
|
||||
# a provider-declared 1M window from advertising ~30k schema tokens forever.
|
||||
tool-schema-max-tokens: ${MATECLAW_TOOL_SCHEMA_MAX_TOKENS:12000}
|
||||
workspace:
|
||||
sandbox:
|
||||
# Global fallback filesystem boundary for file/shell tools. When a
|
||||
|
||||
@ -71,6 +71,8 @@ class PrefixBudgetPlannerTest {
|
||||
void toolSchemaBudget() {
|
||||
PrefixBudgetPlan plan = planner.plan(16384, 0, 0);
|
||||
assertEquals((int) (16384 * 0.25), plan.toolSchemaBudgetTokens());
|
||||
assertEquals(12000, planner.plan(1_000_000, 0, 0).toolSchemaBudgetTokens(),
|
||||
"large declared windows must not disable progressive disclosure");
|
||||
properties.setEnabled(false);
|
||||
assertEquals(Integer.MAX_VALUE, planner.plan(16384, 0, 0).toolSchemaBudgetTokens());
|
||||
}
|
||||
|
||||
@ -3,13 +3,16 @@ package vip.mate.agent.graph.executor;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||
import vip.mate.agent.AgentToolSet;
|
||||
import vip.mate.tool.guard.ToolGuard;
|
||||
import vip.mate.tool.guard.ToolGuardResult;
|
||||
import vip.mate.tool.builtin.ProgressiveToolBridgeTool;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
@ -124,6 +127,8 @@ class ToolExecutionExecutorNameNormalizationTest {
|
||||
"conv", "agent", false, "user", null);
|
||||
|
||||
assertEquals(1, result.responses().size());
|
||||
assertEquals("WebSearch", result.responses().get(0).name(),
|
||||
"provider-facing response name must match the model-emitted function name");
|
||||
assertEquals("ok:web_search", result.responses().get(0).responseData(),
|
||||
"Mangled name should resolve and dispatch to the registered tool");
|
||||
}
|
||||
@ -139,4 +144,65 @@ class ToolExecutionExecutorNameNormalizationTest {
|
||||
|
||||
assertEquals("ok:read_file", result.responses().get(0).responseData());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tool_call unwraps and executes the real tool in the same action round")
|
||||
void progressiveBridge_executesTargetSameRound() {
|
||||
ToolCallback target = callbackNamed("web_search");
|
||||
AtomicReference<String> guardedName = new AtomicReference<>();
|
||||
ToolGuard guard = (name, args) -> {
|
||||
guardedName.set(name);
|
||||
return ToolGuardResult.allow();
|
||||
};
|
||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(
|
||||
AgentToolSet.fromCallbacks(List.of(), List.of(target)), guard, null, null);
|
||||
|
||||
var result = executor.execute(List.of(new AssistantMessage.ToolCall(
|
||||
"bridge_1", "function", "tool_call",
|
||||
"{\"toolName\":\"web_search\",\"arguments\":{\"query\":\"MateClaw\"}}")),
|
||||
"conv", "agent", false, "user", null);
|
||||
|
||||
assertEquals("web_search", guardedName.get(),
|
||||
"guard must see the real target, never the proxy name");
|
||||
assertEquals("tool_call", result.responses().get(0).name(),
|
||||
"provider-facing response must stay paired with the bridge function name");
|
||||
assertEquals("ok:web_search", result.responses().get(0).responseData());
|
||||
var contextCaptor = org.mockito.ArgumentCaptor.forClass(ToolContext.class);
|
||||
verify(target).call(eq("{\"query\":\"MateClaw\"}"), contextCaptor.capture());
|
||||
Object scoped = contextCaptor.getValue().getContext()
|
||||
.get(ProgressiveToolBridgeTool.SCOPED_TOOL_CALLBACKS_CONTEXT_KEY);
|
||||
assertInstanceOf(java.util.Map.class, scoped);
|
||||
assertSame(target, ((java.util.Map<?, ?>) scoped).get("web_search"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tool_call cannot invoke a target outside the agent-scoped callback map")
|
||||
void progressiveBridge_rejectsOutOfScopeTarget() {
|
||||
ToolExecutionExecutor executor = newExecutor(callbackNamed("web_search"));
|
||||
|
||||
var result = executor.execute(List.of(new AssistantMessage.ToolCall(
|
||||
"bridge_2", "function", "tool_call",
|
||||
"{\"toolName\":\"admin_delete_all\",\"arguments\":{}}")),
|
||||
"conv", "agent", false, "user", null);
|
||||
|
||||
assertTrue(result.responses().get(0).responseData().contains("not available to this agent"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tool_call probes required arguments and returns the schema without execution")
|
||||
void progressiveBridge_probesRequiredArguments() {
|
||||
ToolCallback target = callbackNamed("web_search");
|
||||
when(target.getToolDefinition().inputSchema()).thenReturn(
|
||||
"{\"type\":\"object\",\"required\":[\"query\"],\"properties\":{\"query\":{\"type\":\"string\"}}}");
|
||||
ToolExecutionExecutor executor = newExecutor(target);
|
||||
|
||||
var result = executor.execute(List.of(new AssistantMessage.ToolCall(
|
||||
"bridge_3", "function", "tool_call",
|
||||
"{\"toolName\":\"web_search\",\"arguments\":{}}")),
|
||||
"conv", "agent", false, "user", null);
|
||||
|
||||
assertTrue(result.responses().get(0).responseData().contains("Missing required arguments"));
|
||||
assertTrue(result.responses().get(0).responseData().contains("query"));
|
||||
verify(target, never()).call(anyString(), any());
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
package vip.mate.tool.builtin;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||
import vip.mate.agent.AgentToolSet;
|
||||
import vip.mate.agent.binding.service.AgentBindingService;
|
||||
import vip.mate.tool.ToolRegistry;
|
||||
import vip.mate.tool.guard.service.ToolGuardConfigService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ProgressiveToolBridgeToolTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void searchUsesExecutorSnapshotInsteadOfLiveRegistry() throws Exception {
|
||||
ToolRegistry registry = mock(ToolRegistry.class);
|
||||
ToolCallback liveOnly = callback("live_admin_tool", "Unrelated live tool", "session_id");
|
||||
AgentToolSet liveSet = AgentToolSet.fromCallbacks(List.of(), List.of(liveOnly));
|
||||
when(registry.getEnabledToolSet()).thenReturn(liveSet);
|
||||
ProgressiveToolBridgeTool bridge = new ProgressiveToolBridgeTool(
|
||||
registry, mock(AgentBindingService.class), mock(ToolGuardConfigService.class));
|
||||
|
||||
ToolCallback scoped = callback("lookup_customer", "Find an account", "customer_id");
|
||||
ToolContext context = new ToolContext(Map.of(
|
||||
ProgressiveToolBridgeTool.SCOPED_TOOL_CALLBACKS_CONTEXT_KEY,
|
||||
Map.of("lookup_customer", scoped)));
|
||||
|
||||
JsonNode result = MAPPER.readTree(bridge.search("customer_id", 8, context));
|
||||
|
||||
assertEquals("lookup_customer", result.path("tools").path(0).path("name").asText());
|
||||
assertFalse(result.toString().contains("live_admin_tool"));
|
||||
verify(registry, never()).getEnabledToolSet();
|
||||
}
|
||||
|
||||
@Test
|
||||
void punctuationOnlyQueryDoesNotMatchEveryTool() throws Exception {
|
||||
ProgressiveToolBridgeTool bridge = new ProgressiveToolBridgeTool(
|
||||
mock(ToolRegistry.class), mock(AgentBindingService.class),
|
||||
mock(ToolGuardConfigService.class));
|
||||
ToolCallback scoped = callback("lookup_customer", "Find an account", "customer_id");
|
||||
ToolContext context = new ToolContext(Map.of(
|
||||
ProgressiveToolBridgeTool.SCOPED_TOOL_CALLBACKS_CONTEXT_KEY,
|
||||
Map.of("lookup_customer", scoped)));
|
||||
|
||||
JsonNode result = MAPPER.readTree(bridge.search("!!!", 8, context));
|
||||
|
||||
assertTrue(result.path("tools").isEmpty());
|
||||
}
|
||||
|
||||
private static ToolCallback callback(String name, String description, String property) {
|
||||
ToolCallback callback = mock(ToolCallback.class);
|
||||
ToolDefinition definition = mock(ToolDefinition.class);
|
||||
when(definition.name()).thenReturn(name);
|
||||
when(definition.description()).thenReturn(description);
|
||||
when(definition.inputSchema()).thenReturn("{\"type\":\"object\",\"properties\":{\""
|
||||
+ property + "\":{\"type\":\"string\"}}}");
|
||||
when(callback.getToolDefinition()).thenReturn(definition);
|
||||
return callback;
|
||||
}
|
||||
}
|
||||
@ -93,11 +93,14 @@ class ToolDisclosureServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("meta-tools enable_tool / load_skill are always core")
|
||||
@DisplayName("skill and progressive bridge meta-tools are always core")
|
||||
void metaToolsAlwaysCore() {
|
||||
var svc = service(List.of(toolRow("enable_tool", "builtin", "extension")), List.of(), List.of());
|
||||
assertEquals(DisclosureTier.CORE, svc.resolveTierByName("enable_tool"));
|
||||
assertEquals(DisclosureTier.CORE, svc.resolveTierByName("load_skill"));
|
||||
assertEquals(DisclosureTier.CORE, svc.resolveTierByName("tool_search"));
|
||||
assertEquals(DisclosureTier.CORE, svc.resolveTierByName("tool_describe"));
|
||||
assertEquals(DisclosureTier.CORE, svc.resolveTierByName("tool_call"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -199,7 +202,7 @@ class ToolDisclosureServiceTest {
|
||||
String catalog = svc.renderExtensionCatalog(set, 8192);
|
||||
assertTrue(catalog.contains("## Extension Tools"));
|
||||
assertTrue(catalog.contains("image_generate"));
|
||||
assertTrue(catalog.contains("enable_tool"));
|
||||
assertTrue(catalog.contains("tool_call"));
|
||||
assertFalse(catalog.contains("my_core_tool"), "core tools must not appear in the extension catalog");
|
||||
}
|
||||
|
||||
@ -278,11 +281,11 @@ class ToolDisclosureServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("explicit core DB row and meta-tools are never demoted")
|
||||
void explicitCoreProtected() {
|
||||
@DisplayName("hard schema ceiling may demote explicit core rows")
|
||||
void explicitCoreStillFitsHardCeiling() {
|
||||
var svc = service(List.of(toolRow("tool_a", "builtin", "core")), List.of(), List.of());
|
||||
var demoted = svc.computeAutoDemotions(manyCoreSet(), 1);
|
||||
assertEquals(Set.of("tool_b", "tool_c"), demoted);
|
||||
assertEquals(Set.of("tool_a", "tool_b", "tool_c"), demoted);
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -292,7 +295,7 @@ class ToolDisclosureServiceTest {
|
||||
AgentToolSet set = manyCoreSet();
|
||||
|
||||
var split = svc.split(set, Set.of(), Set.of("tool_b"));
|
||||
assertEquals(List.of("tool_a", "tool_c"), names(split.activeCallbacks()));
|
||||
assertEquals(Set.of("tool_a", "tool_c"), Set.copyOf(names(split.activeCallbacks())));
|
||||
assertEquals(List.of("tool_b"), names(split.extensionCatalog()));
|
||||
|
||||
var enabledBack = svc.split(set, Set.of("tool_b"), Set.of("tool_b"));
|
||||
@ -305,6 +308,7 @@ class ToolDisclosureServiceTest {
|
||||
var svc = service(List.of(), List.of(), List.of());
|
||||
String catalog = svc.renderExtensionCatalog(manyCoreSet(), 8192, Set.of("tool_b"));
|
||||
assertTrue(catalog.contains("tool_b"));
|
||||
assertTrue(catalog.contains("tool_call"));
|
||||
assertFalse(catalog.contains("| `tool_a`"), "non-demoted core tools stay out of the catalog");
|
||||
}
|
||||
}
|
||||
|
||||
@ -2135,7 +2135,7 @@ export default {
|
||||
core: 'Core',
|
||||
extension: 'Extension',
|
||||
coreHint: "Core: this server's tools are advertised to the model directly. Click to make extension.",
|
||||
extensionHint: "Extension: this server's tools live in the tool box, activated after enable_tool. Click to make core.",
|
||||
extensionHint: "Extension: this server's full tool schemas stay hidden and are executed through tool_call. Click to make core.",
|
||||
},
|
||||
kv: {
|
||||
envKey: 'KEY',
|
||||
@ -2287,11 +2287,11 @@ export default {
|
||||
toCore: '→ Core',
|
||||
toExtension: '→ Extension',
|
||||
toCoreHint: 'Move to Core: advertised to the model directly',
|
||||
toExtensionHint: 'Move to Extension: lives in the tool box, activated after enable_tool',
|
||||
toExtensionHint: 'Move to Extension: hide full schemas and execute through tool_call',
|
||||
locked: 'Source-owned',
|
||||
lockedHint: "MCP / ACP / Skill tools are tiered by their owning server / endpoint / skill — change it there",
|
||||
core: { desc: 'Advertised to the model directly' },
|
||||
extension: { desc: 'Lives in the tool box; activated after the model calls enable_tool' },
|
||||
extension: { desc: 'Full schemas stay hidden; the model executes tools through tool_call' },
|
||||
},
|
||||
modal: {
|
||||
editTitle: 'Edit Tool',
|
||||
|
||||
@ -2009,7 +2009,7 @@ export default {
|
||||
core: '核心',
|
||||
extension: '扩展',
|
||||
coreHint: '当前为核心:该 server 的工具直接进入模型可调用列表。点击改为扩展。',
|
||||
extensionHint: '当前为扩展:该 server 的工具进入工具盒,模型调用 enable_tool 后激活。点击改为核心。',
|
||||
extensionHint: '当前为扩展:该 server 的完整工具 schema 保持隐藏,模型通过 tool_call 当轮执行。点击改为核心。',
|
||||
},
|
||||
kv: {
|
||||
envKey: 'KEY',
|
||||
@ -2161,11 +2161,11 @@ export default {
|
||||
toCore: '→ 核心',
|
||||
toExtension: '→ 扩展',
|
||||
toCoreHint: '移至核心工具:直接进入模型可调用列表',
|
||||
toExtensionHint: '移至扩展工具:进入工具盒目录,模型调用 enable_tool 后激活',
|
||||
toExtensionHint: '移至扩展工具:隐藏完整 schema,通过 tool_call 当轮执行',
|
||||
locked: '由来源决定',
|
||||
lockedHint: 'MCP / ACP / Skill 工具的分级由所属 server / endpoint / skill 决定,请到对应页面修改',
|
||||
core: { desc: '直接进入模型可调用列表' },
|
||||
extension: { desc: '进入工具盒目录,模型调用 enable_tool 后激活' },
|
||||
extension: { desc: '完整 schema 保持隐藏,模型通过 tool_call 当轮执行' },
|
||||
},
|
||||
modal: {
|
||||
editTitle: '编辑工具',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user