From 8dc6c646830ceffe4f2f04c6bbf7cbd917cab63c Mon Sep 17 00:00:00 2001 From: mateaix <7333791@qq.com> Date: Sun, 9 Aug 2026 19:39:45 +0800 Subject: [PATCH] perf(agent): add progressive tool bridge --- .../vip/mate/agent/AgentGraphBuilder.java | 61 +++-- .../binding/service/AgentBindingService.java | 6 + .../agent/context/PrefixBudgetPlanner.java | 3 +- .../graph/executor/ToolExecutionExecutor.java | 160 +++++++++-- .../vip/mate/agent/graph/node/ActionNode.java | 6 +- .../agent/graph/node/ObservationNode.java | 2 +- .../mate/config/PrefixBudgetProperties.java | 10 +- .../builtin/ProgressiveToolBridgeTool.java | 250 ++++++++++++++++++ .../DefaultToolDisclosureService.java | 23 +- .../disclosure/ToolDisclosureService.java | 10 +- .../src/main/resources/application.yml | 12 +- .../context/PrefixBudgetPlannerTest.java | 2 + ...xecutionExecutorNameNormalizationTest.java | 66 +++++ .../ProgressiveToolBridgeToolTest.java | 70 +++++ .../disclosure/ToolDisclosureServiceTest.java | 16 +- mateclaw-ui/src/i18n/locales/en-US.ts | 6 +- mateclaw-ui/src/i18n/locales/zh-CN.ts | 6 +- 17 files changed, 638 insertions(+), 71 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/builtin/ProgressiveToolBridgeTool.java create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/builtin/ProgressiveToolBridgeToolTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 1f24ab66..4f62dffa 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -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 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 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 autoDemotedTools) { try { List 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 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 { - `` 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: ]` 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="")` to activate the one you need before calling it. + - MCP tools are listed in the Extension Tools catalog by default. Call `tool_call(toolName="", 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. diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java index 06a52376..0abe5bb2 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -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", diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlanner.java b/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlanner.java index fd9ab65a..e42c7a6a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlanner.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlanner.java @@ -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" diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java index 7ea31c01..12c767ac 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java @@ -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 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 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 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 { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java index 0041e648..8cf3e03a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ActionNode.java @@ -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 { * */ private static final Set 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", diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ObservationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ObservationNode.java index a1e89d4d..6f4d5981 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ObservationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ObservationNode.java @@ -41,7 +41,7 @@ public class ObservationNode implements NodeAction { * set in {@code DefaultToolDisclosureService.ALWAYS_CORE}. */ private static final java.util.Set 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; diff --git a/mateclaw-server/src/main/java/vip/mate/config/PrefixBudgetProperties.java b/mateclaw-server/src/main/java/vip/mate/config/PrefixBudgetProperties.java index 0bcd5dc2..f4f219af 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/PrefixBudgetProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/config/PrefixBudgetProperties.java @@ -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(); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ProgressiveToolBridgeTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ProgressiveToolBridgeTool.java new file mode 100644 index 00000000..dcb176f7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ProgressiveToolBridgeTool.java @@ -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. + * + *

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 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 terms = terms(query); + boolean browseCatalog = query == null || query.isBlank(); + List candidates = effectiveToolSet(ctx).callbacks().stream() + .filter(cb -> !BRIDGE_NAMES.contains(cb.getToolDefinition().name())) + .toList(); + List matches = (browseCatalog ? rank(candidates, List.of()) + : terms.isEmpty() ? List.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> rows = new ArrayList<>(matches.size()); + for (ScoredTool match : matches) { + var def = match.callback().getToolDefinition(); + Map 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 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 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 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 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 rank(List callbacks, List queryTerms) { + if (queryTerms.isEmpty()) { + return callbacks.stream().map(cb -> new ScoredTool(cb, 0.0d)).toList(); + } + List entries = callbacks.stream().map(ProgressiveToolBridgeTool::catalogEntry).toList(); + double avgLength = entries.stream().mapToInt(e -> e.tokens().size()).average().orElse(1.0d); + Map 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 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 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 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 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 tokens, Map frequencies) {} + + private record ScoredTool(ToolCallback callback, double score) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java index baa879fb..bec6f365 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/DefaultToolDisclosureService.java @@ -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 ALWAYS_CORE = Set.of("enable_tool", "load_skill"); + private static final Set 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} * - *

Protection set: {@link #ALWAYS_CORE} meta-tools and builtin tools - * with an explicit {@code disclosure_tier = core} row. MCP tools default + *

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=\"\")`, 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=\"\", 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; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/disclosure/ToolDisclosureService.java b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/ToolDisclosureService.java index c1b16b84..4157e891 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/disclosure/ToolDisclosureService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/disclosure/ToolDisclosureService.java @@ -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}. * *

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 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 autoDemoted) { diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index f3cc66ed..b2ff731c 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -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 diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/PrefixBudgetPlannerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/PrefixBudgetPlannerTest.java index c23cffc0..09794c90 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/context/PrefixBudgetPlannerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/PrefixBudgetPlannerTest.java @@ -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()); } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorNameNormalizationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorNameNormalizationTest.java index 0bdc16e5..86030a38 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorNameNormalizationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorNameNormalizationTest.java @@ -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 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()); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/ProgressiveToolBridgeToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ProgressiveToolBridgeToolTest.java new file mode 100644 index 00000000..d533484a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ProgressiveToolBridgeToolTest.java @@ -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; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/disclosure/ToolDisclosureServiceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/disclosure/ToolDisclosureServiceTest.java index 94fb91cb..f8d89278 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/disclosure/ToolDisclosureServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/disclosure/ToolDisclosureServiceTest.java @@ -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"); } } diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 101db3ea..6de599a8 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 4ef8874f..cd35d090 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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: '编辑工具',