perf(agent): add progressive tool bridge

This commit is contained in:
mateaix 2026-08-09 19:39:45 +08:00
parent a204e8eec8
commit 8dc6c64683
17 changed files with 638 additions and 71 deletions

View File

@ -12,6 +12,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import vip.mate.agent.graph.StateGraphReActAgent;
@ -453,24 +454,19 @@ public class AgentGraphBuilder {
SkillCatalogRenderer skillCatalogRenderer = buildSkillCatalogRenderer(
entity, boundTools, effectiveMaxInputTokens);
// Extension-tool catalog only for ReAct. The dynamic tool split runs
// in ReasoningNode; Plan-Execute keeps advertising every tool (it has no
// action node to record enable_tool), so baking the catalog there would
// describe an enable_tool flow that can never take effect.
// Auto-demotion is likewise ReAct-only: hiding a tool from Plan-Execute
// would remove it with no enable_tool path to recover it.
boolean isPlanExecute = "plan_execute".equals(entity.getAgentType());
// Progressive tool catalog is shared by both agent types. ReAct applies
// the split in ReasoningNode; Plan-Execute receives a separate advertised
// set for StepExecutionNode while its executor retains the full scoped
// set, so tool_call can recover a deferred tool in the same action round.
Set<String> autoDemotedTools = Set.of();
if (!isPlanExecute) {
if (prefixBudgetPlan.enabled()) {
autoDemotedTools = toolDisclosureService.computeAutoDemotions(
toolSet, prefixBudgetPlan.toolSchemaBudgetTokens());
}
String extensionCatalog = toolDisclosureService.renderExtensionCatalog(
toolSet, effectiveMaxInputTokens, autoDemotedTools);
if (extensionCatalog != null && !extensionCatalog.isBlank()) {
enhancedPrompt = enhancedPrompt + extensionCatalog;
}
if (prefixBudgetPlan.enabled()) {
autoDemotedTools = toolDisclosureService.computeAutoDemotions(
toolSet, prefixBudgetPlan.toolSchemaBudgetTokens());
}
String extensionCatalog = toolDisclosureService.renderExtensionCatalog(
toolSet, effectiveMaxInputTokens, autoDemotedTools);
if (extensionCatalog != null && !extensionCatalog.isBlank()) {
enhancedPrompt = enhancedPrompt + extensionCatalog;
}
// 当前仅支持 DashScope OpenAI-compatible其他协议直接拒绝
@ -482,7 +478,8 @@ public class AgentGraphBuilder {
BaseAgent agent;
boolean toolCallingEnabled;
if ("plan_execute".equals(entity.getAgentType())) {
agent = buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer);
agent = buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, entity.getId(),
skillCatalogRenderer, autoDemotedTools);
toolCallingEnabled = true;
log.info("Built StateGraph Plan-Execute agent: {} (maxIterations={}, tools={}, protocol={})",
entity.getName(), maxIter, toolSet.size(), protocol.getId());
@ -606,11 +603,19 @@ public class AgentGraphBuilder {
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
int maxIter, Long agentId,
SkillCatalogRenderer skillCatalogRenderer) {
return buildPlanExecuteAgent(toolSet, runtimeModel, maxIter, agentId,
skillCatalogRenderer, Set.of());
}
StateGraphPlanExecuteAgent buildPlanExecuteAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
int maxIter, Long agentId,
SkillCatalogRenderer skillCatalogRenderer,
Set<String> autoDemotedTools) {
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
ChatClient chatClient = ChatClient.create(chatModel);
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort,
runtimeModel, agentId, skillCatalogRenderer);
runtimeModel, agentId, skillCatalogRenderer, autoDemotedTools);
return new StateGraphPlanExecuteAgent(chatClient, conversationService, graph, planningService,
chatModel, conversationWindowManager, toolSet);
}
@ -634,6 +639,14 @@ public class AgentGraphBuilder {
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId, SkillCatalogRenderer skillCatalogRenderer) {
return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort,
primaryModelConfig, agentId, skillCatalogRenderer, Set.of());
}
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId, SkillCatalogRenderer skillCatalogRenderer,
Set<String> autoDemotedTools) {
try {
List<vip.mate.llm.failover.FallbackEntry> fallbackChain = buildFallbackChain(primaryModelConfig, agentId);
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(
@ -667,7 +680,13 @@ public class AgentGraphBuilder {
// Team hand-off: a lead-of-team plan agent parks multi-step plans on
// the team task board instead of the serial delegation pipeline.
planGenerationNode.setTeamPlanBridge(teamPlanBridge);
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager, skillCatalogRenderer);
List<ToolCallback> advertisedCallbacks = toolDisclosureService
.split(toolSet, Set.of(), autoDemotedTools).activeCallbacks();
AgentToolSet advertisedToolSet = AgentToolSet.fromCallbacks(
toolSet.toolBeans(), advertisedCallbacks);
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, advertisedToolSet,
executor, planningService, streamTracker, reasoningEffort, streamingHelper,
conversationWindowManager, skillCatalogRenderer);
// Per-step delegation: route a step assigned to a specialist agent
// through DelegateAgentTool (null when delegation deps aren't wired).
stepExecutionNode.setDelegateAgentTool(delegateAgentTool);
@ -1702,7 +1721,7 @@ public class AgentGraphBuilder {
- `<serverId>` is a numeric ID identifying which MCP server the tool belongs to.
- Tools from DIFFERENT servers have DIFFERENT serverId prefixes, even if they have the same raw name (e.g. `search` on server A vs server B) they are DIFFERENT tools and are NOT interchangeable.
- Each MCP tool's description starts with `[MCP server: <name>]` so you can identify the source server by its human-readable name.
- MCP tools are listed in the Extension Tools catalog by default. Use `enable_tool(toolName="<exact-name>")` to activate the one you need before calling it.
- MCP tools are listed in the Extension Tools catalog by default. Call `tool_call(toolName="<exact-name>", arguments={...})` to execute one in the same action round; use `tool_search` first only when the exact name is unknown.
- Always call tools by the EXACT name shown in the tool list. Do NOT reconstruct a tool name by swapping the slug into a serverId you remember from a previous successful call that produces a non-existent tool name and the call will fail.
- If a tool call returns "Tool not found" with candidate suggestions, pick the correct one from the candidates verbatim.

View File

@ -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",

View File

@ -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"

View File

@ -7,6 +7,7 @@ import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import vip.mate.tool.builtin.ToolExecutionContext;
import vip.mate.tool.builtin.ProgressiveToolBridgeTool;
import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
import vip.mate.tool.mcp.runtime.McpProgressContext;
import vip.mate.tool.mcp.runtime.McpToolNameResolver;
@ -513,6 +514,30 @@ public class ToolExecutionExecutor {
for (int i = 0; i < effectiveCalls.size(); i++) {
AssistantMessage.ToolCall toolCall = effectiveCalls.get(i);
// Keep the provider-facing response name paired with the function
// name emitted by the model. The execution name may be rewritten
// below (tool_call -> real target), but Gemini pairs a
// functionResponse by name rather than OpenAI's call_id alone.
String responseName = toolCall.name();
// Hermes-style deferred tool proxy: unwrap tool_call before any
// policy decision so guard, approval, audit, concurrency and UI
// all operate on the real tool. The executor's callback map is
// already scoped to this agent, making it the final authority for
// whether the requested target may be invoked.
if (ProgressiveToolBridgeTool.CALL.equals(resolveToolName(toolCall.name()))) {
BridgeUnwrap unwrap = unwrapBridgeCall(toolCall);
if (unwrap.error() != null) {
events.add(GraphEventPublisher.toolStart(
toolCall.id(), ProgressiveToolBridgeTool.CALL, toolCall.arguments()));
events.add(GraphEventPublisher.toolComplete(
toolCall.id(), ProgressiveToolBridgeTool.CALL, unwrap.error(), false));
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), responseName, unwrap.error()));
continue;
}
toolCall = unwrap.toolCall();
log.info("[ToolExecutor] Progressive bridge unwrapped tool_call -> {}", toolCall.name());
}
// Resolve LLM-emitted name to canonical BEFORE guard / lookup so a
// mangled name (Read_File, web_search_tool, BrowserUseTool) can't
// bypass guard rules keyed on the canonical name.
@ -545,7 +570,7 @@ public class ToolExecutionExecutor {
}
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
allResponses.add(new org.springframework.ai.chat.messages.ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, msg));
toolCall.id(), responseName, msg));
continue;
}
}
@ -560,7 +585,7 @@ public class ToolExecutionExecutor {
String truncationError = normalizeToolExecutionError(jsonEx);
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false));
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, truncationError));
toolCall.id(), responseName, truncationError));
continue;
}
}
@ -572,13 +597,13 @@ public class ToolExecutionExecutor {
if (decision.blocked) {
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, decision.response));
toolCall.id(), responseName, decision.response));
continue;
}
if (decision.needsApproval) {
// Barrier: 当前工具创建审批后续工具不执行
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, decision.response));
toolCall.id(), responseName, decision.response));
// 标记后续工具为等待审批
for (int j = i + 1; j < effectiveCalls.size(); j++) {
AssistantMessage.ToolCall remaining = effectiveCalls.get(j);
@ -597,7 +622,7 @@ public class ToolExecutionExecutor {
if (toolName.startsWith("$")) {
log.info("[ToolExecutor] Skipping provider builtin tool: {}", toolName);
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, "Provider builtin tool executed server-side"));
toolCall.id(), responseName, "Provider builtin tool executed server-side"));
continue;
}
ToolCallback callback = toolCallbackMap.get(toolName);
@ -610,20 +635,20 @@ public class ToolExecutionExecutor {
events.add(GraphEventPublisher.toolComplete(
toolCall.id(), toolName, redirect.response(), true));
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, redirect.response()));
toolCall.id(), responseName, redirect.response()));
continue;
}
String msg = skillAwareNotFoundMessage(toolName, safeOrigin);
log.warn("[ToolExecutor] {}", msg);
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, msg));
toolCall.id(), responseName, msg));
continue;
}
// 4. 分类: concurrencySafe
boolean safe = isConcurrencySafe(toolName);
preparedCalls.add(new PreparedToolCall(toolCall, callback, arguments, safe, allResponses.size(),
preparedCalls.add(new PreparedToolCall(toolCall, responseName, callback, arguments, safe, allResponses.size(),
conversationId, requesterId, workspaceBasePath, safeOrigin, rawEvidenceRef));
// 占位Phase 2 填充
allResponses.add(null);
@ -719,7 +744,7 @@ public class ToolExecutionExecutor {
ChatOrigin replayOrigin = ChatOrigin.EMPTY
.withConversationId(conversationId)
.withWorkspace(null, workspaceBasePath);
String result = callback.call(callArguments, replayOrigin.toToolContext());
String result = callback.call(callArguments, toolContextWithScopedCatalog(replayOrigin));
int rawLen = result != null ? result.length() : 0;
// RFC-052: pre-approved tool may itself be returnDirect in that
@ -879,7 +904,7 @@ public class ToolExecutionExecutor {
PreparedToolCall pc = batch.stream()
.filter(p -> p.resultIndex == entry.getKey())
.findFirst().orElse(null);
String toolName = pc != null ? pc.toolCall.name() : "unknown";
String toolName = pc != null ? pc.responseName : "unknown";
String toolId = pc != null ? pc.toolCall.id() : "";
log.error("[ToolExecutor] Parallel tool {} failed: {}", toolName, e.getMessage());
allResponses.set(entry.getKey(), new ToolResponseMessage.ToolResponse(
@ -917,7 +942,7 @@ public class ToolExecutionExecutor {
runtimeOrigin = runtimeOrigin
.withConversationId(pc.conversationId)
.withWorkspace(runtimeOrigin.workspaceId(), pc.workspaceBasePath);
ToolContext toolContext = runtimeOrigin.toToolContext();
ToolContext toolContext = toolContextWithScopedCatalog(runtimeOrigin);
// MCP progress: generate progressToken and inject into ToolContext
// so ProgressAwareMcpToolCallback can include it in tools/call _meta.
@ -968,7 +993,7 @@ public class ToolExecutionExecutor {
// any subsequent LLM round (the graph won't take a next round
// see ObservationDispatcher RETURN_DIRECT_TRIGGERED branch).
return new ToolResponseMessage.ToolResponse(
pc.toolCall.id(), toolName, DIRECT_TOOL_PLACEHOLDER);
pc.toolCall.id(), pc.responseName, DIRECT_TOOL_PLACEHOLDER);
}
// Capture SourceEvidenceLedger from the RAW result, before truncate/
@ -1008,7 +1033,8 @@ public class ToolExecutionExecutor {
// Append the card-rendering directive to the LLM-facing response only,
// leaving the broadcast tool-result panel unchanged.
return new ToolResponseMessage.ToolResponse(
pc.toolCall.id(), toolName, withProductCardDirective(toolName, result != null ? result : ""));
pc.toolCall.id(), pc.responseName,
withProductCardDirective(toolName, result != null ? result : ""));
} catch (Exception e) {
log.error("[ToolExecutor] Tool {} execution failed: {}", toolName, e.getMessage(), e);
// RFC-052: for returnDirect tools, even the error message is
@ -1026,7 +1052,7 @@ public class ToolExecutionExecutor {
streamTracker.updateRunningTool(pc.conversationId, null);
}
return new ToolResponseMessage.ToolResponse(
pc.toolCall.id(), toolName, reportedError);
pc.toolCall.id(), pc.responseName, reportedError);
}
}
@ -1462,7 +1488,7 @@ public class ToolExecutionExecutor {
+ "\",\"filePath\":\"SKILL.md\"}";
String skillMd;
try {
ToolContext ctx = (origin != null ? origin : ChatOrigin.EMPTY).toToolContext();
ToolContext ctx = toolContextWithScopedCatalog(origin != null ? origin : ChatOrigin.EMPTY);
skillMd = readSkillFile.call(redirectArgs, ctx);
} catch (Exception e) {
log.warn("[ToolExecutor] Auto-redirect readSkillFile failed for '{}': {}", toolName, e.getMessage());
@ -1487,10 +1513,104 @@ public class ToolExecutionExecutor {
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
/**
* Parse and validate a progressive {@code tool_call} envelope. Validation
* happens before guard execution and never invokes a callback. In
* particular, required-argument probing mirrors Hermes: an incomplete
* call returns the target schema immediately instead of spending another
* round on a guaranteed callback failure.
*/
private BridgeUnwrap unwrapBridgeCall(AssistantMessage.ToolCall bridgeCall) {
try {
var envelope = OBJECT_MAPPER.readTree(bridgeCall.arguments());
String requestedName = textField(envelope, "toolName", "name");
if (requestedName == null || requestedName.isBlank()) {
return BridgeUnwrap.error("Error: tool_call requires an exact toolName.");
}
String targetName = resolveToolName(requestedName);
if (ProgressiveToolBridgeTool.BRIDGE_NAMES.contains(targetName)) {
return BridgeUnwrap.error("Error: tool_call cannot invoke a progressive bridge recursively.");
}
ToolCallback target = toolCallbackMap.get(targetName);
if (target == null) {
return BridgeUnwrap.error("Error: Tool '" + requestedName
+ "' is not available to this agent. Use tool_search for scoped results.");
}
var argsNode = envelope != null && envelope.has("arguments")
? envelope.get("arguments")
: envelope != null ? envelope.get("args") : null;
String targetArguments;
if (argsNode == null || argsNode.isNull()) {
targetArguments = "{}";
} else if (argsNode.isTextual()) {
targetArguments = argsNode.asText();
// A textual envelope is accepted for weaker models, but it
// must itself contain valid JSON before proceeding.
OBJECT_MAPPER.readTree(targetArguments);
} else {
targetArguments = OBJECT_MAPPER.writeValueAsString(argsNode);
}
String missing = missingRequiredArguments(target, targetArguments);
if (missing != null) {
return BridgeUnwrap.error("Error: Missing required arguments for '" + targetName
+ "': " + missing + ". Full input schema: "
+ target.getToolDefinition().inputSchema());
}
return BridgeUnwrap.success(new AssistantMessage.ToolCall(
bridgeCall.id(), bridgeCall.type(), targetName, targetArguments));
} catch (Exception e) {
return BridgeUnwrap.error("Error: invalid tool_call envelope: " + normalizeToolExecutionError(e));
}
}
private static String textField(com.fasterxml.jackson.databind.JsonNode node, String... names) {
if (node == null) return null;
for (String name : names) {
var value = node.get(name);
if (value != null && value.isTextual()) return value.asText();
}
return null;
}
private static String missingRequiredArguments(ToolCallback callback, String arguments) {
try {
var schema = OBJECT_MAPPER.readTree(callback.getToolDefinition().inputSchema());
var required = schema.get("required");
if (required == null || !required.isArray() || required.isEmpty()) return null;
var actual = OBJECT_MAPPER.readTree(arguments);
List<String> missing = new ArrayList<>();
for (var name : required) {
if (actual == null || !actual.has(name.asText()) || actual.get(name.asText()).isNull()) {
missing.add(name.asText());
}
}
return missing.isEmpty() ? null : String.join(", ", missing);
} catch (Exception ignored) {
// Bad third-party schemas should not make an otherwise valid tool
// unreachable; the callback remains the source of truth.
return null;
}
}
/**
* Carries the executor's immutable, agent-scoped callback snapshot into
* catalog bridge calls. This makes tool_search/tool_describe observe the
* exact same authority set that tool_call validates against.
*/
private ToolContext toolContextWithScopedCatalog(ChatOrigin origin) {
ToolContext base = (origin != null ? origin : ChatOrigin.EMPTY).toToolContext();
Map<String, Object> context = new HashMap<>(base.getContext());
context.put(ProgressiveToolBridgeTool.SCOPED_TOOL_CALLBACKS_CONTEXT_KEY, toolCallbackMap);
return new ToolContext(context);
}
// ==================== 内部数据类 ====================
private record PreparedToolCall(
AssistantMessage.ToolCall toolCall,
String responseName,
ToolCallback callback,
String arguments,
boolean concurrencySafe,
@ -1520,6 +1640,16 @@ public class ToolExecutionExecutor {
java.util.concurrent.atomic.AtomicReference<SourceEvidenceLedger> rawEvidenceCollector
) {}
private record BridgeUnwrap(AssistantMessage.ToolCall toolCall, String error) {
static BridgeUnwrap success(AssistantMessage.ToolCall call) {
return new BridgeUnwrap(call, null);
}
static BridgeUnwrap error(String message) {
return new BridgeUnwrap(null, message);
}
}
private record ApprovalBarrier(String pendingId, String toolName) {}
private static final class GuardDecision {

View File

@ -51,6 +51,10 @@ public class ActionNode implements NodeAction {
/** Function name of the extension-tool activator, mirrored from EnableExtensionTool. */
private static final String ENABLE_TOOL = "enable_tool";
/** Progressive catalog inspection is setup; tool_call itself is real work. */
private static final String TOOL_SEARCH = "tool_search";
private static final String TOOL_DESCRIBE = "tool_describe";
/** Function name of the progress-update tool — skip auto-recording it. */
private static final String PROGRESS_UPDATE_TOOL = "progress_update";
@ -70,7 +74,7 @@ public class ActionNode implements NodeAction {
* </ul>
*/
private static final Set<String> AUTO_RECORD_SKIP = Set.of(
LOAD_SKILL_TOOL, ENABLE_TOOL, PROGRESS_UPDATE_TOOL,
LOAD_SKILL_TOOL, ENABLE_TOOL, TOOL_SEARCH, TOOL_DESCRIBE, PROGRESS_UPDATE_TOOL,
"listAvailableSkills", "readSkillFile", "runSkillScript",
// read-only / status-query tools
"read_file", "web_search",

View File

@ -41,7 +41,7 @@ public class ObservationNode implements NodeAction {
* set in {@code DefaultToolDisclosureService.ALWAYS_CORE}.
*/
private static final java.util.Set<String> DISCLOSURE_TOOLS =
java.util.Set.of("load_skill", "enable_tool");
java.util.Set.of("load_skill", "enable_tool", "tool_search", "tool_describe");
/** Per-run cap on iteration refunds — keeps a load-skill-only model from looping forever. */
private static final int MAX_ITERATION_REFUNDS_PER_RUN = 3;

View File

@ -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();

View File

@ -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) {}
}

View File

@ -38,10 +38,11 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
/**
* Meta-tools that must always stay core: hiding them would make progressive
* disclosure unrecoverable (the model could never call {@code enable_tool}
* to surface anything, nor {@code load_skill} to read a skill).
* disclosure unrecoverable (the model could neither search/call a deferred
* tool nor load a skill).
*/
private static final Set<String> ALWAYS_CORE = Set.of("enable_tool", "load_skill");
private static final Set<String> ALWAYS_CORE = Set.of(
"enable_tool", "load_skill", "tool_search", "tool_describe", "tool_call");
/**
* Code-level extension defaults for builtin tools that may not yet have a
@ -137,8 +138,9 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
/**
* {@inheritDoc}
*
* <p>Protection set: {@link #ALWAYS_CORE} meta-tools and builtin tools
* with an explicit {@code disclosure_tier = core} row. MCP tools default
* <p>Protection set: only the {@link #ALWAYS_CORE} recovery/bridge tools.
* An explicit {@code disclosure_tier = core} remains a preference, but it
* cannot override the hard per-request schema ceiling. MCP tools default
* to EXTENSION (Move 5) so they only enter the CORE list when an operator
* explicitly sets {@code disclosure_tier = core} on the server in that
* case they are still demotable, since MCP schemas are typically the
@ -171,7 +173,7 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
}
if (!demoted.isEmpty()) {
log.info("[ToolDisclosure] 工具 schema 估算 {} tokens 超出预算 {}——已将 {} 个最少使用的工具"
+ "降级到扩展目录(enable_tool 可找回): {}",
+ "降级到渐进目录(tool_call 可当轮调用): {}",
coreTokens, budgetTokens, demoted.size(), demoted);
}
return demoted;
@ -181,8 +183,7 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
if (toolName == null || ALWAYS_CORE.contains(toolName)) {
return false;
}
// An explicit core row is an operator decision never override it.
return snap.builtinTierByName.get(toolName) != DisclosureTier.CORE;
return true;
}
/** Never-used tools demote first, then least recently used; name-tiebreak keeps builds deterministic. */
@ -216,9 +217,9 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
StringBuilder sb = new StringBuilder();
sb.append("\n\n## Extension Tools\n");
sb.append("These tools are not directly callable yet. To use one, first call ");
sb.append("`enable_tool(toolName=\"<name>\")`, then issue the real tool call in your next response. ");
sb.append("Activation lasts for the rest of this conversation. Only enable a tool when the task needs it.\n\n");
sb.append("Full schemas are hidden until needed. If the exact name is known, call ");
sb.append("`tool_call(toolName=\"<name>\", arguments={...})` to execute it in this same round. ");
sb.append("Use `tool_search` to discover by capability and `tool_describe` only when arguments are unclear.\n\n");
sb.append("| Tool | Source | Description |\n");
sb.append("|------|--------|-------------|\n");
int shown = 0;

View File

@ -9,7 +9,9 @@ import java.util.Set;
/**
* Splits an agent's tool set into the subset advertised to the LLM up front
* ({@code core} + already-enabled extensions) and the {@code extension} catalog
* that stays behind {@code enable_tool} until the model activates it.
* that stays behind a stable progressive bridge. The model can invoke a
* deferred tool in the same action round through {@code tool_call}; legacy
* sessions may still activate one through {@code enable_tool}.
*
* <p>Tier is resolved per source: builtin / channel atomic tools from
* {@code mate_tool.disclosure_tier}, MCP tools from their owning
@ -46,8 +48,8 @@ public interface ToolDisclosureService {
/**
* Decide which core-tier tools to auto-demote so the advertised tool
* schemas fit {@code budgetTokens} (estimated). Ranking: never-used tools
* first, then least recently used; meta-tools and explicitly configured
* core tools are never demoted. Empty when the set already fits, when
* first, then least recently used; recovery/bridge meta-tools are never
* demoted. Empty when the set already fits, when
* {@code budgetTokens} is null, or in legacy disclosure mode.
*/
default Set<String> computeAutoDemotions(AgentToolSet baseSet, Integer budgetTokens) {
@ -63,7 +65,7 @@ public interface ToolDisclosureService {
/**
* Budget-aware variant: auto-demoted tools are listed in the catalog too,
* so the model can discover and {@code enable_tool} them back.
* so the model can discover and invoke them through {@code tool_call}.
*/
default String renderExtensionCatalog(AgentToolSet baseSet, Integer maxInputTokens,
Set<String> autoDemoted) {

View File

@ -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

View File

@ -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());
}

View File

@ -3,13 +3,16 @@ package vip.mate.agent.graph.executor;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import vip.mate.agent.AgentToolSet;
import vip.mate.tool.guard.ToolGuard;
import vip.mate.tool.guard.ToolGuardResult;
import vip.mate.tool.builtin.ProgressiveToolBridgeTool;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ -124,6 +127,8 @@ class ToolExecutionExecutorNameNormalizationTest {
"conv", "agent", false, "user", null);
assertEquals(1, result.responses().size());
assertEquals("WebSearch", result.responses().get(0).name(),
"provider-facing response name must match the model-emitted function name");
assertEquals("ok:web_search", result.responses().get(0).responseData(),
"Mangled name should resolve and dispatch to the registered tool");
}
@ -139,4 +144,65 @@ class ToolExecutionExecutorNameNormalizationTest {
assertEquals("ok:read_file", result.responses().get(0).responseData());
}
@Test
@DisplayName("tool_call unwraps and executes the real tool in the same action round")
void progressiveBridge_executesTargetSameRound() {
ToolCallback target = callbackNamed("web_search");
AtomicReference<String> guardedName = new AtomicReference<>();
ToolGuard guard = (name, args) -> {
guardedName.set(name);
return ToolGuardResult.allow();
};
ToolExecutionExecutor executor = new ToolExecutionExecutor(
AgentToolSet.fromCallbacks(List.of(), List.of(target)), guard, null, null);
var result = executor.execute(List.of(new AssistantMessage.ToolCall(
"bridge_1", "function", "tool_call",
"{\"toolName\":\"web_search\",\"arguments\":{\"query\":\"MateClaw\"}}")),
"conv", "agent", false, "user", null);
assertEquals("web_search", guardedName.get(),
"guard must see the real target, never the proxy name");
assertEquals("tool_call", result.responses().get(0).name(),
"provider-facing response must stay paired with the bridge function name");
assertEquals("ok:web_search", result.responses().get(0).responseData());
var contextCaptor = org.mockito.ArgumentCaptor.forClass(ToolContext.class);
verify(target).call(eq("{\"query\":\"MateClaw\"}"), contextCaptor.capture());
Object scoped = contextCaptor.getValue().getContext()
.get(ProgressiveToolBridgeTool.SCOPED_TOOL_CALLBACKS_CONTEXT_KEY);
assertInstanceOf(java.util.Map.class, scoped);
assertSame(target, ((java.util.Map<?, ?>) scoped).get("web_search"));
}
@Test
@DisplayName("tool_call cannot invoke a target outside the agent-scoped callback map")
void progressiveBridge_rejectsOutOfScopeTarget() {
ToolExecutionExecutor executor = newExecutor(callbackNamed("web_search"));
var result = executor.execute(List.of(new AssistantMessage.ToolCall(
"bridge_2", "function", "tool_call",
"{\"toolName\":\"admin_delete_all\",\"arguments\":{}}")),
"conv", "agent", false, "user", null);
assertTrue(result.responses().get(0).responseData().contains("not available to this agent"));
}
@Test
@DisplayName("tool_call probes required arguments and returns the schema without execution")
void progressiveBridge_probesRequiredArguments() {
ToolCallback target = callbackNamed("web_search");
when(target.getToolDefinition().inputSchema()).thenReturn(
"{\"type\":\"object\",\"required\":[\"query\"],\"properties\":{\"query\":{\"type\":\"string\"}}}");
ToolExecutionExecutor executor = newExecutor(target);
var result = executor.execute(List.of(new AssistantMessage.ToolCall(
"bridge_3", "function", "tool_call",
"{\"toolName\":\"web_search\",\"arguments\":{}}")),
"conv", "agent", false, "user", null);
assertTrue(result.responses().get(0).responseData().contains("Missing required arguments"));
assertTrue(result.responses().get(0).responseData().contains("query"));
verify(target, never()).call(anyString(), any());
}
}

View File

@ -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;
}
}

View File

@ -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");
}
}

View File

@ -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',

View File

@ -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: '编辑工具',