mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 03:33: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 lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.ai.chat.client.ChatClient;
|
import org.springframework.ai.chat.client.ChatClient;
|
||||||
import org.springframework.ai.chat.model.ChatModel;
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
import org.springframework.retry.support.RetryTemplate;
|
import org.springframework.retry.support.RetryTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import vip.mate.agent.graph.StateGraphReActAgent;
|
import vip.mate.agent.graph.StateGraphReActAgent;
|
||||||
@ -453,24 +454,19 @@ public class AgentGraphBuilder {
|
|||||||
SkillCatalogRenderer skillCatalogRenderer = buildSkillCatalogRenderer(
|
SkillCatalogRenderer skillCatalogRenderer = buildSkillCatalogRenderer(
|
||||||
entity, boundTools, effectiveMaxInputTokens);
|
entity, boundTools, effectiveMaxInputTokens);
|
||||||
|
|
||||||
// Extension-tool catalog — only for ReAct. The dynamic tool split runs
|
// Progressive tool catalog is shared by both agent types. ReAct applies
|
||||||
// in ReasoningNode; Plan-Execute keeps advertising every tool (it has no
|
// the split in ReasoningNode; Plan-Execute receives a separate advertised
|
||||||
// action node to record enable_tool), so baking the catalog there would
|
// set for StepExecutionNode while its executor retains the full scoped
|
||||||
// describe an enable_tool flow that can never take effect.
|
// set, so tool_call can recover a deferred tool in the same action round.
|
||||||
// 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());
|
|
||||||
Set<String> autoDemotedTools = Set.of();
|
Set<String> autoDemotedTools = Set.of();
|
||||||
if (!isPlanExecute) {
|
if (prefixBudgetPlan.enabled()) {
|
||||||
if (prefixBudgetPlan.enabled()) {
|
autoDemotedTools = toolDisclosureService.computeAutoDemotions(
|
||||||
autoDemotedTools = toolDisclosureService.computeAutoDemotions(
|
toolSet, prefixBudgetPlan.toolSchemaBudgetTokens());
|
||||||
toolSet, prefixBudgetPlan.toolSchemaBudgetTokens());
|
}
|
||||||
}
|
String extensionCatalog = toolDisclosureService.renderExtensionCatalog(
|
||||||
String extensionCatalog = toolDisclosureService.renderExtensionCatalog(
|
toolSet, effectiveMaxInputTokens, autoDemotedTools);
|
||||||
toolSet, effectiveMaxInputTokens, autoDemotedTools);
|
if (extensionCatalog != null && !extensionCatalog.isBlank()) {
|
||||||
if (extensionCatalog != null && !extensionCatalog.isBlank()) {
|
enhancedPrompt = enhancedPrompt + extensionCatalog;
|
||||||
enhancedPrompt = enhancedPrompt + extensionCatalog;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当前仅支持 DashScope 和 OpenAI-compatible,其他协议直接拒绝
|
// 当前仅支持 DashScope 和 OpenAI-compatible,其他协议直接拒绝
|
||||||
@ -482,7 +478,8 @@ public class AgentGraphBuilder {
|
|||||||
BaseAgent agent;
|
BaseAgent agent;
|
||||||
boolean toolCallingEnabled;
|
boolean toolCallingEnabled;
|
||||||
if ("plan_execute".equals(entity.getAgentType())) {
|
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;
|
toolCallingEnabled = true;
|
||||||
log.info("Built StateGraph Plan-Execute agent: {} (maxIterations={}, tools={}, protocol={})",
|
log.info("Built StateGraph Plan-Execute agent: {} (maxIterations={}, tools={}, protocol={})",
|
||||||
entity.getName(), maxIter, toolSet.size(), protocol.getId());
|
entity.getName(), maxIter, toolSet.size(), protocol.getId());
|
||||||
@ -606,11 +603,19 @@ public class AgentGraphBuilder {
|
|||||||
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
|
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
|
||||||
int maxIter, Long agentId,
|
int maxIter, Long agentId,
|
||||||
SkillCatalogRenderer skillCatalogRenderer) {
|
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);
|
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
|
||||||
ChatClient chatClient = ChatClient.create(chatModel);
|
ChatClient chatClient = ChatClient.create(chatModel);
|
||||||
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
|
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
|
||||||
CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort,
|
CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort,
|
||||||
runtimeModel, agentId, skillCatalogRenderer);
|
runtimeModel, agentId, skillCatalogRenderer, autoDemotedTools);
|
||||||
return new StateGraphPlanExecuteAgent(chatClient, conversationService, graph, planningService,
|
return new StateGraphPlanExecuteAgent(chatClient, conversationService, graph, planningService,
|
||||||
chatModel, conversationWindowManager, toolSet);
|
chatModel, conversationWindowManager, toolSet);
|
||||||
}
|
}
|
||||||
@ -634,6 +639,14 @@ public class AgentGraphBuilder {
|
|||||||
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
|
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
|
||||||
String reasoningEffort, ModelConfigEntity primaryModelConfig,
|
String reasoningEffort, ModelConfigEntity primaryModelConfig,
|
||||||
Long agentId, SkillCatalogRenderer skillCatalogRenderer) {
|
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 {
|
try {
|
||||||
List<vip.mate.llm.failover.FallbackEntry> fallbackChain = buildFallbackChain(primaryModelConfig, agentId);
|
List<vip.mate.llm.failover.FallbackEntry> fallbackChain = buildFallbackChain(primaryModelConfig, agentId);
|
||||||
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(
|
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
|
// Team hand-off: a lead-of-team plan agent parks multi-step plans on
|
||||||
// the team task board instead of the serial delegation pipeline.
|
// the team task board instead of the serial delegation pipeline.
|
||||||
planGenerationNode.setTeamPlanBridge(teamPlanBridge);
|
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
|
// Per-step delegation: route a step assigned to a specialist agent
|
||||||
// through DelegateAgentTool (null when delegation deps aren't wired).
|
// through DelegateAgentTool (null when delegation deps aren't wired).
|
||||||
stepExecutionNode.setDelegateAgentTool(delegateAgentTool);
|
stepExecutionNode.setDelegateAgentTool(delegateAgentTool);
|
||||||
@ -1702,7 +1721,7 @@ public class AgentGraphBuilder {
|
|||||||
- `<serverId>` is a numeric ID identifying which MCP server the tool belongs to.
|
- `<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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
// extension-tier tool for the rest of the conversation. Must be
|
||||||
// agent-wide so the model can always surface hidden tools.
|
// agent-wide so the model can always surface hidden tools.
|
||||||
"enable_tool",
|
"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;
|
// Skill discovery / dispatch — skills are docs, not callables;
|
||||||
// these helpers let the LLM read SKILL.md / run scripts.
|
// these helpers let the LLM read SKILL.md / run scripts.
|
||||||
"load_skill",
|
"load_skill",
|
||||||
|
|||||||
@ -87,7 +87,8 @@ public class PrefixBudgetPlanner {
|
|||||||
(int) (injectionBudget * shares.getSkill() / sum),
|
(int) (injectionBudget * shares.getSkill() / sum),
|
||||||
(int) (injectionBudget * shares.getExtensionCatalog() / sum),
|
(int) (injectionBudget * shares.getExtensionCatalog() / sum),
|
||||||
(int) (injectionBudget * shares.getLedger() / 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) {
|
if (profile != PrefixBudgetPlan.Profile.NORMAL) {
|
||||||
log.info("[PrefixBudget] 窗口 {} tokens 进入 {} 档:注入预算 {} tokens"
|
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.chat.model.ToolContext;
|
||||||
import org.springframework.ai.tool.ToolCallback;
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||||
|
import vip.mate.tool.builtin.ProgressiveToolBridgeTool;
|
||||||
import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
|
import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
|
||||||
import vip.mate.tool.mcp.runtime.McpProgressContext;
|
import vip.mate.tool.mcp.runtime.McpProgressContext;
|
||||||
import vip.mate.tool.mcp.runtime.McpToolNameResolver;
|
import vip.mate.tool.mcp.runtime.McpToolNameResolver;
|
||||||
@ -513,6 +514,30 @@ public class ToolExecutionExecutor {
|
|||||||
|
|
||||||
for (int i = 0; i < effectiveCalls.size(); i++) {
|
for (int i = 0; i < effectiveCalls.size(); i++) {
|
||||||
AssistantMessage.ToolCall toolCall = effectiveCalls.get(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
|
// Resolve LLM-emitted name to canonical BEFORE guard / lookup so a
|
||||||
// mangled name (Read_File, web_search_tool, BrowserUseTool) can't
|
// mangled name (Read_File, web_search_tool, BrowserUseTool) can't
|
||||||
// bypass guard rules keyed on the canonical name.
|
// bypass guard rules keyed on the canonical name.
|
||||||
@ -545,7 +570,7 @@ public class ToolExecutionExecutor {
|
|||||||
}
|
}
|
||||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
|
||||||
allResponses.add(new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse(
|
allResponses.add(new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, msg));
|
toolCall.id(), responseName, msg));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -560,7 +585,7 @@ public class ToolExecutionExecutor {
|
|||||||
String truncationError = normalizeToolExecutionError(jsonEx);
|
String truncationError = normalizeToolExecutionError(jsonEx);
|
||||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false));
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false));
|
||||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, truncationError));
|
toolCall.id(), responseName, truncationError));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -572,13 +597,13 @@ public class ToolExecutionExecutor {
|
|||||||
|
|
||||||
if (decision.blocked) {
|
if (decision.blocked) {
|
||||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, decision.response));
|
toolCall.id(), responseName, decision.response));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (decision.needsApproval) {
|
if (decision.needsApproval) {
|
||||||
// Barrier: 当前工具创建审批,后续工具不执行
|
// Barrier: 当前工具创建审批,后续工具不执行
|
||||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, decision.response));
|
toolCall.id(), responseName, decision.response));
|
||||||
// 标记后续工具为等待审批
|
// 标记后续工具为等待审批
|
||||||
for (int j = i + 1; j < effectiveCalls.size(); j++) {
|
for (int j = i + 1; j < effectiveCalls.size(); j++) {
|
||||||
AssistantMessage.ToolCall remaining = effectiveCalls.get(j);
|
AssistantMessage.ToolCall remaining = effectiveCalls.get(j);
|
||||||
@ -597,7 +622,7 @@ public class ToolExecutionExecutor {
|
|||||||
if (toolName.startsWith("$")) {
|
if (toolName.startsWith("$")) {
|
||||||
log.info("[ToolExecutor] Skipping provider builtin tool: {}", toolName);
|
log.info("[ToolExecutor] Skipping provider builtin tool: {}", toolName);
|
||||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, "Provider builtin tool executed server-side"));
|
toolCall.id(), responseName, "Provider builtin tool executed server-side"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ToolCallback callback = toolCallbackMap.get(toolName);
|
ToolCallback callback = toolCallbackMap.get(toolName);
|
||||||
@ -610,20 +635,20 @@ public class ToolExecutionExecutor {
|
|||||||
events.add(GraphEventPublisher.toolComplete(
|
events.add(GraphEventPublisher.toolComplete(
|
||||||
toolCall.id(), toolName, redirect.response(), true));
|
toolCall.id(), toolName, redirect.response(), true));
|
||||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, redirect.response()));
|
toolCall.id(), responseName, redirect.response()));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String msg = skillAwareNotFoundMessage(toolName, safeOrigin);
|
String msg = skillAwareNotFoundMessage(toolName, safeOrigin);
|
||||||
log.warn("[ToolExecutor] {}", msg);
|
log.warn("[ToolExecutor] {}", msg);
|
||||||
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
|
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
|
||||||
allResponses.add(new ToolResponseMessage.ToolResponse(
|
allResponses.add(new ToolResponseMessage.ToolResponse(
|
||||||
toolCall.id(), toolName, msg));
|
toolCall.id(), responseName, msg));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 分类: concurrencySafe
|
// 4. 分类: concurrencySafe
|
||||||
boolean safe = isConcurrencySafe(toolName);
|
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));
|
conversationId, requesterId, workspaceBasePath, safeOrigin, rawEvidenceRef));
|
||||||
// 占位,Phase 2 填充
|
// 占位,Phase 2 填充
|
||||||
allResponses.add(null);
|
allResponses.add(null);
|
||||||
@ -719,7 +744,7 @@ public class ToolExecutionExecutor {
|
|||||||
ChatOrigin replayOrigin = ChatOrigin.EMPTY
|
ChatOrigin replayOrigin = ChatOrigin.EMPTY
|
||||||
.withConversationId(conversationId)
|
.withConversationId(conversationId)
|
||||||
.withWorkspace(null, workspaceBasePath);
|
.withWorkspace(null, workspaceBasePath);
|
||||||
String result = callback.call(callArguments, replayOrigin.toToolContext());
|
String result = callback.call(callArguments, toolContextWithScopedCatalog(replayOrigin));
|
||||||
int rawLen = result != null ? result.length() : 0;
|
int rawLen = result != null ? result.length() : 0;
|
||||||
|
|
||||||
// RFC-052: pre-approved tool may itself be returnDirect — in that
|
// RFC-052: pre-approved tool may itself be returnDirect — in that
|
||||||
@ -879,7 +904,7 @@ public class ToolExecutionExecutor {
|
|||||||
PreparedToolCall pc = batch.stream()
|
PreparedToolCall pc = batch.stream()
|
||||||
.filter(p -> p.resultIndex == entry.getKey())
|
.filter(p -> p.resultIndex == entry.getKey())
|
||||||
.findFirst().orElse(null);
|
.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() : "";
|
String toolId = pc != null ? pc.toolCall.id() : "";
|
||||||
log.error("[ToolExecutor] Parallel tool {} failed: {}", toolName, e.getMessage());
|
log.error("[ToolExecutor] Parallel tool {} failed: {}", toolName, e.getMessage());
|
||||||
allResponses.set(entry.getKey(), new ToolResponseMessage.ToolResponse(
|
allResponses.set(entry.getKey(), new ToolResponseMessage.ToolResponse(
|
||||||
@ -917,7 +942,7 @@ public class ToolExecutionExecutor {
|
|||||||
runtimeOrigin = runtimeOrigin
|
runtimeOrigin = runtimeOrigin
|
||||||
.withConversationId(pc.conversationId)
|
.withConversationId(pc.conversationId)
|
||||||
.withWorkspace(runtimeOrigin.workspaceId(), pc.workspaceBasePath);
|
.withWorkspace(runtimeOrigin.workspaceId(), pc.workspaceBasePath);
|
||||||
ToolContext toolContext = runtimeOrigin.toToolContext();
|
ToolContext toolContext = toolContextWithScopedCatalog(runtimeOrigin);
|
||||||
|
|
||||||
// MCP progress: generate progressToken and inject into ToolContext
|
// MCP progress: generate progressToken and inject into ToolContext
|
||||||
// so ProgressAwareMcpToolCallback can include it in tools/call _meta.
|
// 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 —
|
// any subsequent LLM round (the graph won't take a next round —
|
||||||
// see ObservationDispatcher RETURN_DIRECT_TRIGGERED branch).
|
// see ObservationDispatcher RETURN_DIRECT_TRIGGERED branch).
|
||||||
return new ToolResponseMessage.ToolResponse(
|
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/
|
// 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,
|
// Append the card-rendering directive to the LLM-facing response only,
|
||||||
// leaving the broadcast tool-result panel unchanged.
|
// leaving the broadcast tool-result panel unchanged.
|
||||||
return new ToolResponseMessage.ToolResponse(
|
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) {
|
} catch (Exception e) {
|
||||||
log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e);
|
log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e);
|
||||||
// RFC-052: for returnDirect tools, even the error message is
|
// RFC-052: for returnDirect tools, even the error message is
|
||||||
@ -1026,7 +1052,7 @@ public class ToolExecutionExecutor {
|
|||||||
streamTracker.updateRunningTool(pc.conversationId, null);
|
streamTracker.updateRunningTool(pc.conversationId, null);
|
||||||
}
|
}
|
||||||
return new ToolResponseMessage.ToolResponse(
|
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\"}";
|
+ "\",\"filePath\":\"SKILL.md\"}";
|
||||||
String skillMd;
|
String skillMd;
|
||||||
try {
|
try {
|
||||||
ToolContext ctx = (origin != null ? origin : ChatOrigin.EMPTY).toToolContext();
|
ToolContext ctx = toolContextWithScopedCatalog(origin != null ? origin : ChatOrigin.EMPTY);
|
||||||
skillMd = readSkillFile.call(redirectArgs, ctx);
|
skillMd = readSkillFile.call(redirectArgs, ctx);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[ToolExecutor] Auto-redirect readSkillFile failed for '{}': {}", toolName, e.getMessage());
|
log.warn("[ToolExecutor] Auto-redirect readSkillFile failed for '{}': {}", toolName, e.getMessage());
|
||||||
@ -1487,10 +1513,104 @@ public class ToolExecutionExecutor {
|
|||||||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
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(
|
private record PreparedToolCall(
|
||||||
AssistantMessage.ToolCall toolCall,
|
AssistantMessage.ToolCall toolCall,
|
||||||
|
String responseName,
|
||||||
ToolCallback callback,
|
ToolCallback callback,
|
||||||
String arguments,
|
String arguments,
|
||||||
boolean concurrencySafe,
|
boolean concurrencySafe,
|
||||||
@ -1520,6 +1640,16 @@ public class ToolExecutionExecutor {
|
|||||||
java.util.concurrent.atomic.AtomicReference<SourceEvidenceLedger> rawEvidenceCollector
|
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 record ApprovalBarrier(String pendingId, String toolName) {}
|
||||||
|
|
||||||
private static final class GuardDecision {
|
private static final class GuardDecision {
|
||||||
|
|||||||
@ -51,6 +51,10 @@ public class ActionNode implements NodeAction {
|
|||||||
/** Function name of the extension-tool activator, mirrored from EnableExtensionTool. */
|
/** Function name of the extension-tool activator, mirrored from EnableExtensionTool. */
|
||||||
private static final String ENABLE_TOOL = "enable_tool";
|
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. */
|
/** Function name of the progress-update tool — skip auto-recording it. */
|
||||||
private static final String PROGRESS_UPDATE_TOOL = "progress_update";
|
private static final String PROGRESS_UPDATE_TOOL = "progress_update";
|
||||||
|
|
||||||
@ -70,7 +74,7 @@ public class ActionNode implements NodeAction {
|
|||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
private static final Set<String> AUTO_RECORD_SKIP = Set.of(
|
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",
|
"listAvailableSkills", "readSkillFile", "runSkillScript",
|
||||||
// read-only / status-query tools
|
// read-only / status-query tools
|
||||||
"read_file", "web_search",
|
"read_file", "web_search",
|
||||||
|
|||||||
@ -41,7 +41,7 @@ public class ObservationNode implements NodeAction {
|
|||||||
* set in {@code DefaultToolDisclosureService.ALWAYS_CORE}.
|
* set in {@code DefaultToolDisclosureService.ALWAYS_CORE}.
|
||||||
*/
|
*/
|
||||||
private static final java.util.Set<String> DISCLOSURE_TOOLS =
|
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. */
|
/** 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;
|
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
|
* Fraction of the effective window the advertised tool schemas may
|
||||||
* occupy. When the core tool set estimates above this, the least
|
* occupy. When the core tool set estimates above this, the least
|
||||||
* recently used demotable tools are auto-moved to the extension catalog
|
* 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;
|
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. */
|
/** Relative shares of the injection budget. Normalized at plan time. */
|
||||||
private Shares shares = new Shares();
|
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
|
* Meta-tools that must always stay core: hiding them would make progressive
|
||||||
* disclosure unrecoverable (the model could never call {@code enable_tool}
|
* disclosure unrecoverable (the model could neither search/call a deferred
|
||||||
* to surface anything, nor {@code load_skill} to read a skill).
|
* 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
|
* Code-level extension defaults for builtin tools that may not yet have a
|
||||||
@ -137,8 +138,9 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
|
|||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*
|
*
|
||||||
* <p>Protection set: {@link #ALWAYS_CORE} meta-tools and builtin tools
|
* <p>Protection set: only the {@link #ALWAYS_CORE} recovery/bridge tools.
|
||||||
* with an explicit {@code disclosure_tier = core} row. MCP tools default
|
* 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
|
* 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
|
* explicitly sets {@code disclosure_tier = core} on the server — in that
|
||||||
* case they are still demotable, since MCP schemas are typically the
|
* case they are still demotable, since MCP schemas are typically the
|
||||||
@ -171,7 +173,7 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
|
|||||||
}
|
}
|
||||||
if (!demoted.isEmpty()) {
|
if (!demoted.isEmpty()) {
|
||||||
log.info("[ToolDisclosure] 工具 schema 估算 {} tokens 超出预算 {}——已将 {} 个最少使用的工具"
|
log.info("[ToolDisclosure] 工具 schema 估算 {} tokens 超出预算 {}——已将 {} 个最少使用的工具"
|
||||||
+ "降级到扩展目录(enable_tool 可找回): {}",
|
+ "降级到渐进目录(tool_call 可当轮调用): {}",
|
||||||
coreTokens, budgetTokens, demoted.size(), demoted);
|
coreTokens, budgetTokens, demoted.size(), demoted);
|
||||||
}
|
}
|
||||||
return demoted;
|
return demoted;
|
||||||
@ -181,8 +183,7 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
|
|||||||
if (toolName == null || ALWAYS_CORE.contains(toolName)) {
|
if (toolName == null || ALWAYS_CORE.contains(toolName)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// An explicit core row is an operator decision — never override it.
|
return true;
|
||||||
return snap.builtinTierByName.get(toolName) != DisclosureTier.CORE;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Never-used tools demote first, then least recently used; name-tiebreak keeps builds deterministic. */
|
/** 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();
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append("\n\n## Extension Tools\n");
|
sb.append("\n\n## Extension Tools\n");
|
||||||
sb.append("These tools are not directly callable yet. To use one, first call ");
|
sb.append("Full schemas are hidden until needed. If the exact name is known, call ");
|
||||||
sb.append("`enable_tool(toolName=\"<name>\")`, then issue the real tool call in your next response. ");
|
sb.append("`tool_call(toolName=\"<name>\", arguments={...})` to execute it in this same round. ");
|
||||||
sb.append("Activation lasts for the rest of this conversation. Only enable a tool when the task needs it.\n\n");
|
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("| Tool | Source | Description |\n");
|
||||||
sb.append("|------|--------|-------------|\n");
|
sb.append("|------|--------|-------------|\n");
|
||||||
int shown = 0;
|
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
|
* 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
|
* ({@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
|
* <p>Tier is resolved per source: builtin / channel atomic tools from
|
||||||
* {@code mate_tool.disclosure_tier}, MCP tools from their owning
|
* {@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
|
* Decide which core-tier tools to auto-demote so the advertised tool
|
||||||
* schemas fit {@code budgetTokens} (estimated). Ranking: never-used tools
|
* schemas fit {@code budgetTokens} (estimated). Ranking: never-used tools
|
||||||
* first, then least recently used; meta-tools and explicitly configured
|
* first, then least recently used; recovery/bridge meta-tools are never
|
||||||
* core tools are never demoted. Empty when the set already fits, when
|
* demoted. Empty when the set already fits, when
|
||||||
* {@code budgetTokens} is null, or in legacy disclosure mode.
|
* {@code budgetTokens} is null, or in legacy disclosure mode.
|
||||||
*/
|
*/
|
||||||
default Set<String> computeAutoDemotions(AgentToolSet baseSet, Integer budgetTokens) {
|
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,
|
* 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,
|
default String renderExtensionCatalog(AgentToolSet baseSet, Integer maxInputTokens,
|
||||||
Set<String> autoDemoted) {
|
Set<String> autoDemoted) {
|
||||||
|
|||||||
@ -174,12 +174,16 @@ mateclaw:
|
|||||||
enabled: true
|
enabled: true
|
||||||
tools:
|
tools:
|
||||||
disclosure:
|
disclosure:
|
||||||
# progressive: extension-tier tools are hidden behind the extension-tools
|
# progressive: deferred schemas stay behind tool_search/tool_describe and
|
||||||
# catalog until the model calls enable_tool. By default that's the heavy
|
# execute through tool_call in the same action round. enable_tool remains
|
||||||
# generative / browser tools; MCP servers default to core (visible) and
|
# available only for backwards compatibility with older conversations.
|
||||||
# an admin can move a noisy one to extension per server.
|
|
||||||
# legacy: advertise every bound tool up front (pre-disclosure behavior).
|
# legacy: advertise every bound tool up front (pre-disclosure behavior).
|
||||||
mode: ${MATECLAW_TOOLS_DISCLOSURE_MODE:progressive}
|
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:
|
workspace:
|
||||||
sandbox:
|
sandbox:
|
||||||
# Global fallback filesystem boundary for file/shell tools. When a
|
# Global fallback filesystem boundary for file/shell tools. When a
|
||||||
|
|||||||
@ -71,6 +71,8 @@ class PrefixBudgetPlannerTest {
|
|||||||
void toolSchemaBudget() {
|
void toolSchemaBudget() {
|
||||||
PrefixBudgetPlan plan = planner.plan(16384, 0, 0);
|
PrefixBudgetPlan plan = planner.plan(16384, 0, 0);
|
||||||
assertEquals((int) (16384 * 0.25), plan.toolSchemaBudgetTokens());
|
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);
|
properties.setEnabled(false);
|
||||||
assertEquals(Integer.MAX_VALUE, planner.plan(16384, 0, 0).toolSchemaBudgetTokens());
|
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.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
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.ToolCallback;
|
||||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||||
import vip.mate.agent.AgentToolSet;
|
import vip.mate.agent.AgentToolSet;
|
||||||
import vip.mate.tool.guard.ToolGuard;
|
import vip.mate.tool.guard.ToolGuard;
|
||||||
import vip.mate.tool.guard.ToolGuardResult;
|
import vip.mate.tool.guard.ToolGuardResult;
|
||||||
|
import vip.mate.tool.builtin.ProgressiveToolBridgeTool;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
@ -124,6 +127,8 @@ class ToolExecutionExecutorNameNormalizationTest {
|
|||||||
"conv", "agent", false, "user", null);
|
"conv", "agent", false, "user", null);
|
||||||
|
|
||||||
assertEquals(1, result.responses().size());
|
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(),
|
assertEquals("ok:web_search", result.responses().get(0).responseData(),
|
||||||
"Mangled name should resolve and dispatch to the registered tool");
|
"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());
|
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
|
@Test
|
||||||
@DisplayName("meta-tools enable_tool / load_skill are always core")
|
@DisplayName("skill and progressive bridge meta-tools are always core")
|
||||||
void metaToolsAlwaysCore() {
|
void metaToolsAlwaysCore() {
|
||||||
var svc = service(List.of(toolRow("enable_tool", "builtin", "extension")), List.of(), List.of());
|
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("enable_tool"));
|
||||||
assertEquals(DisclosureTier.CORE, svc.resolveTierByName("load_skill"));
|
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
|
@Test
|
||||||
@ -199,7 +202,7 @@ class ToolDisclosureServiceTest {
|
|||||||
String catalog = svc.renderExtensionCatalog(set, 8192);
|
String catalog = svc.renderExtensionCatalog(set, 8192);
|
||||||
assertTrue(catalog.contains("## Extension Tools"));
|
assertTrue(catalog.contains("## Extension Tools"));
|
||||||
assertTrue(catalog.contains("image_generate"));
|
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");
|
assertFalse(catalog.contains("my_core_tool"), "core tools must not appear in the extension catalog");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -278,11 +281,11 @@ class ToolDisclosureServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("explicit core DB row and meta-tools are never demoted")
|
@DisplayName("hard schema ceiling may demote explicit core rows")
|
||||||
void explicitCoreProtected() {
|
void explicitCoreStillFitsHardCeiling() {
|
||||||
var svc = service(List.of(toolRow("tool_a", "builtin", "core")), List.of(), List.of());
|
var svc = service(List.of(toolRow("tool_a", "builtin", "core")), List.of(), List.of());
|
||||||
var demoted = svc.computeAutoDemotions(manyCoreSet(), 1);
|
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
|
@Test
|
||||||
@ -292,7 +295,7 @@ class ToolDisclosureServiceTest {
|
|||||||
AgentToolSet set = manyCoreSet();
|
AgentToolSet set = manyCoreSet();
|
||||||
|
|
||||||
var split = svc.split(set, Set.of(), Set.of("tool_b"));
|
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()));
|
assertEquals(List.of("tool_b"), names(split.extensionCatalog()));
|
||||||
|
|
||||||
var enabledBack = svc.split(set, Set.of("tool_b"), Set.of("tool_b"));
|
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());
|
var svc = service(List.of(), List.of(), List.of());
|
||||||
String catalog = svc.renderExtensionCatalog(manyCoreSet(), 8192, Set.of("tool_b"));
|
String catalog = svc.renderExtensionCatalog(manyCoreSet(), 8192, Set.of("tool_b"));
|
||||||
assertTrue(catalog.contains("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");
|
assertFalse(catalog.contains("| `tool_a`"), "non-demoted core tools stay out of the catalog");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2135,7 +2135,7 @@ export default {
|
|||||||
core: 'Core',
|
core: 'Core',
|
||||||
extension: 'Extension',
|
extension: 'Extension',
|
||||||
coreHint: "Core: this server's tools are advertised to the model directly. Click to make 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: {
|
kv: {
|
||||||
envKey: 'KEY',
|
envKey: 'KEY',
|
||||||
@ -2287,11 +2287,11 @@ export default {
|
|||||||
toCore: '→ Core',
|
toCore: '→ Core',
|
||||||
toExtension: '→ Extension',
|
toExtension: '→ Extension',
|
||||||
toCoreHint: 'Move to Core: advertised to the model directly',
|
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',
|
locked: 'Source-owned',
|
||||||
lockedHint: "MCP / ACP / Skill tools are tiered by their owning server / endpoint / skill — change it there",
|
lockedHint: "MCP / ACP / Skill tools are tiered by their owning server / endpoint / skill — change it there",
|
||||||
core: { desc: 'Advertised to the model directly' },
|
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: {
|
modal: {
|
||||||
editTitle: 'Edit Tool',
|
editTitle: 'Edit Tool',
|
||||||
|
|||||||
@ -2009,7 +2009,7 @@ export default {
|
|||||||
core: '核心',
|
core: '核心',
|
||||||
extension: '扩展',
|
extension: '扩展',
|
||||||
coreHint: '当前为核心:该 server 的工具直接进入模型可调用列表。点击改为扩展。',
|
coreHint: '当前为核心:该 server 的工具直接进入模型可调用列表。点击改为扩展。',
|
||||||
extensionHint: '当前为扩展:该 server 的工具进入工具盒,模型调用 enable_tool 后激活。点击改为核心。',
|
extensionHint: '当前为扩展:该 server 的完整工具 schema 保持隐藏,模型通过 tool_call 当轮执行。点击改为核心。',
|
||||||
},
|
},
|
||||||
kv: {
|
kv: {
|
||||||
envKey: 'KEY',
|
envKey: 'KEY',
|
||||||
@ -2161,11 +2161,11 @@ export default {
|
|||||||
toCore: '→ 核心',
|
toCore: '→ 核心',
|
||||||
toExtension: '→ 扩展',
|
toExtension: '→ 扩展',
|
||||||
toCoreHint: '移至核心工具:直接进入模型可调用列表',
|
toCoreHint: '移至核心工具:直接进入模型可调用列表',
|
||||||
toExtensionHint: '移至扩展工具:进入工具盒目录,模型调用 enable_tool 后激活',
|
toExtensionHint: '移至扩展工具:隐藏完整 schema,通过 tool_call 当轮执行',
|
||||||
locked: '由来源决定',
|
locked: '由来源决定',
|
||||||
lockedHint: 'MCP / ACP / Skill 工具的分级由所属 server / endpoint / skill 决定,请到对应页面修改',
|
lockedHint: 'MCP / ACP / Skill 工具的分级由所属 server / endpoint / skill 决定,请到对应页面修改',
|
||||||
core: { desc: '直接进入模型可调用列表' },
|
core: { desc: '直接进入模型可调用列表' },
|
||||||
extension: { desc: '进入工具盒目录,模型调用 enable_tool 后激活' },
|
extension: { desc: '完整 schema 保持隐藏,模型通过 tool_call 当轮执行' },
|
||||||
},
|
},
|
||||||
modal: {
|
modal: {
|
||||||
editTitle: '编辑工具',
|
editTitle: '编辑工具',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user