feat(tool): 工具 schema 预算阈值门——超出窗口预算时按使用频度自动降级到扩展目录,enable_tool 可找回

This commit is contained in:
matevip 2026-07-03 18:52:45 +08:00
parent 56737e197f
commit bf0d64e46a
11 changed files with 314 additions and 18 deletions

View File

@ -52,6 +52,7 @@ import vip.mate.skill.runtime.SkillCatalogRenderer;
import vip.mate.skill.service.SkillService; import vip.mate.skill.service.SkillService;
import vip.mate.system.service.SystemSettingService; import vip.mate.system.service.SystemSettingService;
import vip.mate.tool.ToolRegistry; import vip.mate.tool.ToolRegistry;
import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
import vip.mate.memory.spi.MemoryManager; import vip.mate.memory.spi.MemoryManager;
import vip.mate.workspace.document.WorkspaceFileService; import vip.mate.workspace.document.WorkspaceFileService;
import vip.mate.tool.guard.service.ToolGuardService; import vip.mate.tool.guard.service.ToolGuardService;
@ -103,6 +104,7 @@ public class AgentGraphBuilder {
private final ModelProviderService modelProviderService; private final ModelProviderService modelProviderService;
private final ModelContextWindowResolver contextWindowResolver; private final ModelContextWindowResolver contextWindowResolver;
private final PrefixBudgetPlanner prefixBudgetPlanner; private final PrefixBudgetPlanner prefixBudgetPlanner;
private final ToolUsageRecencyTracker toolUsageRecencyTracker;
private final vip.mate.llm.service.ModelCapabilityService modelCapabilityService; private final vip.mate.llm.service.ModelCapabilityService modelCapabilityService;
private final ProviderRouter providerRouter; private final ProviderRouter providerRouter;
private final PlanningService planningService; private final PlanningService planningService;
@ -428,10 +430,17 @@ public class AgentGraphBuilder {
// in ReasoningNode; Plan-Execute keeps advertising every tool (it has no // in ReasoningNode; Plan-Execute keeps advertising every tool (it has no
// action node to record enable_tool), so baking the catalog there would // action node to record enable_tool), so baking the catalog there would
// describe an enable_tool flow that can never take effect. // 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()); boolean isPlanExecute = "plan_execute".equals(entity.getAgentType());
Set<String> autoDemotedTools = Set.of();
if (!isPlanExecute) { if (!isPlanExecute) {
if (prefixBudgetPlan.enabled()) {
autoDemotedTools = toolDisclosureService.computeAutoDemotions(
toolSet, prefixBudgetPlan.toolSchemaBudgetTokens());
}
String extensionCatalog = toolDisclosureService.renderExtensionCatalog( String extensionCatalog = toolDisclosureService.renderExtensionCatalog(
toolSet, effectiveMaxInputTokens); toolSet, effectiveMaxInputTokens, autoDemotedTools);
if (extensionCatalog != null && !extensionCatalog.isBlank()) { if (extensionCatalog != null && !extensionCatalog.isBlank()) {
enhancedPrompt = enhancedPrompt + extensionCatalog; enhancedPrompt = enhancedPrompt + extensionCatalog;
} }
@ -452,7 +461,7 @@ public class AgentGraphBuilder {
entity.getName(), maxIter, toolSet.size(), protocol.getId()); entity.getName(), maxIter, toolSet.size(), protocol.getId());
} else { } else {
agent = buildReActAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer, agent = buildReActAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer,
prefixBudgetPlan); prefixBudgetPlan, autoDemotedTools);
// StateGraph 路径下工具调用由 ActionNode 控制始终启用 // StateGraph 路径下工具调用由 ActionNode 控制始终启用
toolCallingEnabled = true; toolCallingEnabled = true;
log.info("Built StateGraph ReAct agent: {} (maxIterations={}, tools={}, protocol={})", log.info("Built StateGraph ReAct agent: {} (maxIterations={}, tools={}, protocol={})",
@ -539,17 +548,17 @@ public class AgentGraphBuilder {
StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
int maxIter, Long agentId, SkillCatalogRenderer skillCatalogRenderer) { int maxIter, Long agentId, SkillCatalogRenderer skillCatalogRenderer) {
return buildReActAgent(toolSet, runtimeModel, maxIter, agentId, skillCatalogRenderer, null); return buildReActAgent(toolSet, runtimeModel, maxIter, agentId, skillCatalogRenderer, null, Set.of());
} }
StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
int maxIter, Long agentId, SkillCatalogRenderer skillCatalogRenderer, int maxIter, Long agentId, SkillCatalogRenderer skillCatalogRenderer,
PrefixBudgetPlan prefixBudgetPlan) { PrefixBudgetPlan prefixBudgetPlan, 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 compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort, CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort,
runtimeModel, agentId, skillCatalogRenderer, prefixBudgetPlan); runtimeModel, agentId, skillCatalogRenderer, prefixBudgetPlan, autoDemotedTools);
return new StateGraphReActAgent(chatClient, conversationService, compiledGraph, return new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
chatModel, conversationWindowManager, toolSet); chatModel, conversationWindowManager, toolSet);
} }
@ -616,6 +625,7 @@ public class AgentGraphBuilder {
// LLM mis-calls a skill name as a tool, the response tells it // LLM mis-calls a skill name as a tool, the response tells it
// the right invocation pattern instead of a dead-end error. // the right invocation pattern instead of a dead-end error.
executor.setSkillRuntimeService(skillRuntimeService); executor.setSkillRuntimeService(skillRuntimeService);
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
// Optional: route child-agent denied-tool audit events through // Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test). // the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) { if (auditEventService != null) {
@ -876,13 +886,13 @@ public class AgentGraphBuilder {
String reasoningEffort, ModelConfigEntity primaryModelConfig, String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId, SkillCatalogRenderer skillCatalogRenderer) { Long agentId, SkillCatalogRenderer skillCatalogRenderer) {
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort,
primaryModelConfig, agentId, skillCatalogRenderer, null); primaryModelConfig, agentId, skillCatalogRenderer, null, Set.of());
} }
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig, String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId, SkillCatalogRenderer skillCatalogRenderer, Long agentId, SkillCatalogRenderer skillCatalogRenderer,
PrefixBudgetPlan prefixBudgetPlan) { PrefixBudgetPlan prefixBudgetPlan, 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(
@ -905,6 +915,7 @@ public class AgentGraphBuilder {
// LLM mis-calls a skill name as a tool, the response tells it // LLM mis-calls a skill name as a tool, the response tells it
// the right invocation pattern instead of a dead-end error. // the right invocation pattern instead of a dead-end error.
executor.setSkillRuntimeService(skillRuntimeService); executor.setSkillRuntimeService(skillRuntimeService);
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
// Optional: route child-agent denied-tool audit events through // Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test). // the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) { if (auditEventService != null) {
@ -920,6 +931,7 @@ public class AgentGraphBuilder {
streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService, streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService,
skillCatalogRenderer, toolDisclosureService, progressLedgerService); skillCatalogRenderer, toolDisclosureService, progressLedgerService);
reasoningNode.setPrefixBudgetPlan(prefixBudgetPlan); reasoningNode.setPrefixBudgetPlan(prefixBudgetPlan);
reasoningNode.setAutoDemotedTools(autoDemotedTools);
ActionNode actionNode = new ActionNode(executor, streamTracker); ActionNode actionNode = new ActionNode(executor, streamTracker);
ObservationProcessor observationProcessor = new ObservationProcessor(graphObservationProperties); ObservationProcessor observationProcessor = new ObservationProcessor(graphObservationProperties);
ObservationNode observationNode = new ObservationNode(observationProcessor, streamTracker); ObservationNode observationNode = new ObservationNode(observationProcessor, streamTracker);

View File

@ -19,7 +19,8 @@ public record PrefixBudgetPlan(
int wikiTokens, int wikiTokens,
int skillCatalogTokens, int skillCatalogTokens,
int extensionCatalogTokens, int extensionCatalogTokens,
int ledgerTokens) { int ledgerTokens,
int toolSchemaBudgetTokens) {
/** Window-size tier. Small windows tighten the injection ratio. */ /** Window-size tier. Small windows tighten the injection ratio. */
public enum Profile { public enum Profile {
@ -35,6 +36,6 @@ public record PrefixBudgetPlan(
public static PrefixBudgetPlan unlimited(int effectiveMaxTokens) { public static PrefixBudgetPlan unlimited(int effectiveMaxTokens) {
return new PrefixBudgetPlan(false, effectiveMaxTokens, Profile.NORMAL, return new PrefixBudgetPlan(false, effectiveMaxTokens, Profile.NORMAL,
Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE,
Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE); Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE);
} }
} }

View File

@ -72,7 +72,8 @@ public class PrefixBudgetPlanner {
(int) (injectionBudget * shares.getWiki() / sum), (int) (injectionBudget * shares.getWiki() / sum),
(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()));
if (profile != PrefixBudgetPlan.Profile.NORMAL) { if (profile != PrefixBudgetPlan.Profile.NORMAL) {
log.info("[PrefixBudget] 窗口 {} tokens 进入 {} 档:注入预算 {} tokens" 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.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.disclosure.ToolUsageRecencyTracker;
import vip.mate.agent.AgentToolSet; import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ChatOrigin;
@ -251,6 +252,13 @@ public class ToolExecutionExecutor {
*/ */
private vip.mate.skill.runtime.SkillRuntimeService skillRuntimeService; private vip.mate.skill.runtime.SkillRuntimeService skillRuntimeService;
/** Optional recency feed for budget-driven tool-disclosure demotion. */
private ToolUsageRecencyTracker usageRecencyTracker;
public void setUsageRecencyTracker(ToolUsageRecencyTracker tracker) {
this.usageRecencyTracker = tracker;
}
public void setSkillRuntimeService(vip.mate.skill.runtime.SkillRuntimeService s) { public void setSkillRuntimeService(vip.mate.skill.runtime.SkillRuntimeService s) {
this.skillRuntimeService = s; this.skillRuntimeService = s;
} }
@ -885,6 +893,12 @@ public class ToolExecutionExecutor {
ToolExecutionContext.clear(); ToolExecutionContext.clear();
} }
// Recency feed for budget-driven disclosure demotion: recently used
// tools keep their advertised schema, never-used ones demote first.
if (usageRecencyTracker != null) {
usageRecencyTracker.recordUse(toolName);
}
int rawLen = result != null ? result.length() : 0; int rawLen = result != null ? result.length() : 0;
// RFC-052: returnDirect tools bypass spill / truncation / LLM context. // RFC-052: returnDirect tools bypass spill / truncation / LLM context.
// Their full text goes to the user verbatim and is never persisted to // Their full text goes to the user verbatim and is never persisted to

View File

@ -363,6 +363,19 @@ public class ReasoningNode implements NodeAction {
this.prefixBudgetPlan = prefixBudgetPlan; this.prefixBudgetPlan = prefixBudgetPlan;
} }
/**
* Core-tier tools auto-demoted to the extension catalog because the
* advertised schemas exceeded the window's tool-schema budget. Decided
* once at agent-build time (kept stable for prompt caching); the baked
* extension catalog lists them so {@code enable_tool} can surface any of
* them back.
*/
private Set<String> autoDemotedTools = Set.of();
public void setAutoDemotedTools(Set<String> autoDemotedTools) {
this.autoDemotedTools = autoDemotedTools == null ? Set.of() : autoDemotedTools;
}
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort, public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
NodeStreamingChatHelper streamingHelper, NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager, ConversationWindowManager conversationWindowManager,
@ -728,7 +741,8 @@ public class ReasoningNode implements NodeAction {
// so an enable_tool call earlier in this loop takes effect immediately. // so an enable_tool call earlier in this loop takes effect immediately.
// Falls back to the full tool set when no disclosure service is wired. // Falls back to the full tool set when no disclosure service is wired.
List<ToolCallback> activeCallbacks = (toolDisclosureService != null && toolSet != null) List<ToolCallback> activeCallbacks = (toolDisclosureService != null && toolSet != null)
? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools()).activeCallbacks() ? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools(), autoDemotedTools)
.activeCallbacks()
: toolCallbacks; : toolCallbacks;
ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks); ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks);

View File

@ -47,6 +47,14 @@ public class PrefixBudgetProperties {
*/ */
private double compactTriggerRatioOverride = 0.85; private double compactTriggerRatioOverride = 0.85;
/**
* 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.
*/
private double toolSchemaRatio = 0.25;
/** 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();

View File

@ -6,6 +6,7 @@ import org.springframework.ai.tool.ToolCallback;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import vip.mate.agent.AgentToolSet; import vip.mate.agent.AgentToolSet;
import vip.mate.agent.context.TokenEstimator;
import vip.mate.tool.ToolRegistry; import vip.mate.tool.ToolRegistry;
import vip.mate.tool.mcp.model.McpServerEntity; import vip.mate.tool.mcp.model.McpServerEntity;
import vip.mate.tool.mcp.service.McpServerService; import vip.mate.tool.mcp.service.McpServerService;
@ -15,6 +16,7 @@ import vip.mate.tool.service.AvailableToolService;
import vip.mate.tool.service.ToolService; import vip.mate.tool.service.ToolService;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
@ -53,6 +55,7 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
private final McpServerService mcpServerService; private final McpServerService mcpServerService;
private final AvailableToolService availableToolService; private final AvailableToolService availableToolService;
private final ToolRegistry toolRegistry; private final ToolRegistry toolRegistry;
private final ToolUsageRecencyTracker usageRecencyTracker;
@Value("${mateclaw.tools.disclosure.mode:progressive}") @Value("${mateclaw.tools.disclosure.mode:progressive}")
private String disclosureMode; private String disclosureMode;
@ -97,17 +100,25 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
@Override @Override
public ToolDisclosureSplit split(AgentToolSet baseSet, Set<String> enabledExtensions) { public ToolDisclosureSplit split(AgentToolSet baseSet, Set<String> enabledExtensions) {
return split(baseSet, enabledExtensions, Set.of());
}
@Override
public ToolDisclosureSplit split(AgentToolSet baseSet, Set<String> enabledExtensions,
Set<String> autoDemoted) {
List<ToolCallback> all = baseSet == null ? List.of() : baseSet.callbacks(); List<ToolCallback> all = baseSet == null ? List.of() : baseSet.callbacks();
if (legacyMode()) { if (legacyMode()) {
return new ToolDisclosureSplit(all, List.of()); return new ToolDisclosureSplit(all, List.of());
} }
Set<String> enabled = enabledExtensions == null ? Set.of() : enabledExtensions; Set<String> enabled = enabledExtensions == null ? Set.of() : enabledExtensions;
Set<String> demoted = autoDemoted == null ? Set.of() : autoDemoted;
List<ToolCallback> active = new ArrayList<>(all.size()); List<ToolCallback> active = new ArrayList<>(all.size());
List<ToolCallback> extensionCatalog = new ArrayList<>(); List<ToolCallback> extensionCatalog = new ArrayList<>();
for (ToolCallback cb : all) { for (ToolCallback cb : all) {
if (resolveTier(cb) == DisclosureTier.EXTENSION) { String name = cb.getToolDefinition().name();
if (resolveTier(cb) == DisclosureTier.EXTENSION || demoted.contains(name)) {
extensionCatalog.add(cb); extensionCatalog.add(cb);
if (enabled.contains(cb.getToolDefinition().name())) { if (enabled.contains(name)) {
active.add(cb); active.add(cb);
} }
} else { } else {
@ -117,12 +128,79 @@ public class DefaultToolDisclosureService implements ToolDisclosureService {
return new ToolDisclosureSplit(active, extensionCatalog); return new ToolDisclosureSplit(active, extensionCatalog);
} }
/**
* {@inheritDoc}
*
* <p>Protection set: {@link #ALWAYS_CORE} meta-tools and builtin tools
* with an explicit {@code disclosure_tier = core} row. MCP tools remain
* demotable the server-level tier cannot distinguish an explicit core
* choice from the default, and MCP schemas are typically the heaviest
* part of the advertisement.
*/
@Override
public Set<String> computeAutoDemotions(AgentToolSet baseSet, Integer budgetTokens) {
if (legacyMode() || baseSet == null || budgetTokens == null
|| budgetTokens <= 0 || budgetTokens == Integer.MAX_VALUE) {
return Set.of();
}
List<ToolCallback> core = split(baseSet, Set.of()).activeCallbacks();
int coreTokens = TokenEstimator.estimateToolsTokens(core);
if (coreTokens <= budgetTokens) {
return Set.of();
}
Snapshot snap = snapshot();
List<ToolCallback> candidates = core.stream()
.filter(cb -> isDemotable(cb.getToolDefinition().name(), snap))
.sorted(demotionOrder())
.toList();
Set<String> demoted = new LinkedHashSet<>();
int remainingTokens = coreTokens;
for (ToolCallback cb : candidates) {
if (remainingTokens <= budgetTokens) {
break;
}
remainingTokens -= TokenEstimator.estimateToolsTokens(List.of(cb));
demoted.add(cb.getToolDefinition().name());
}
if (!demoted.isEmpty()) {
log.info("[ToolDisclosure] 工具 schema 估算 {} tokens 超出预算 {}——已将 {} 个最少使用的工具"
+ "降级到扩展目录(enable_tool 可找回): {}",
coreTokens, budgetTokens, demoted.size(), demoted);
}
return demoted;
}
private boolean isDemotable(String toolName, Snapshot snap) {
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;
}
/** Never-used tools demote first, then least recently used; name-tiebreak keeps builds deterministic. */
private Comparator<ToolCallback> demotionOrder() {
return Comparator
.<ToolCallback, Long>comparing(cb -> {
Long lastUsed = usageRecencyTracker == null
? null : usageRecencyTracker.lastUsedAt(cb.getToolDefinition().name());
return lastUsed == null ? Long.MIN_VALUE : lastUsed;
})
.thenComparing(cb -> cb.getToolDefinition().name());
}
@Override @Override
public String renderExtensionCatalog(AgentToolSet baseSet, Integer maxInputTokens) { public String renderExtensionCatalog(AgentToolSet baseSet, Integer maxInputTokens) {
return renderExtensionCatalog(baseSet, maxInputTokens, Set.of());
}
@Override
public String renderExtensionCatalog(AgentToolSet baseSet, Integer maxInputTokens,
Set<String> autoDemoted) {
if (legacyMode() || baseSet == null) { if (legacyMode() || baseSet == null) {
return ""; return "";
} }
List<ToolCallback> extension = split(baseSet, Set.of()).extensionCatalog(); List<ToolCallback> extension = split(baseSet, Set.of(), autoDemoted).extensionCatalog();
if (extension.isEmpty()) { if (extension.isEmpty()) {
return ""; return "";
} }

View File

@ -31,6 +31,29 @@ public interface ToolDisclosureService {
*/ */
ToolDisclosureSplit split(AgentToolSet baseSet, Set<String> enabledExtensions); ToolDisclosureSplit split(AgentToolSet baseSet, Set<String> enabledExtensions);
/**
* Budget-aware variant: tools in {@code autoDemoted} are treated as
* extension tier for this split even when their resolved tier is core.
* The demotion set is decided once per agent build (see
* {@link #computeAutoDemotions}) so the runtime split, the baked catalog
* and the prompt-cache prefix stay consistent with each other.
*/
default ToolDisclosureSplit split(AgentToolSet baseSet, Set<String> enabledExtensions,
Set<String> autoDemoted) {
return split(baseSet, enabledExtensions);
}
/**
* 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
* {@code budgetTokens} is null, or in legacy disclosure mode.
*/
default Set<String> computeAutoDemotions(AgentToolSet baseSet, Integer budgetTokens) {
return Set.of();
}
/** /**
* Render the {@code ## Extension Tools} system-prompt segment for the * Render the {@code ## Extension Tools} system-prompt segment for the
* agent's extension tools, or an empty string when there are none / when * agent's extension tools, or an empty string when there are none / when
@ -38,6 +61,15 @@ public interface ToolDisclosureService {
*/ */
String renderExtensionCatalog(AgentToolSet baseSet, Integer maxInputTokens); String renderExtensionCatalog(AgentToolSet baseSet, Integer maxInputTokens);
/**
* Budget-aware variant: auto-demoted tools are listed in the catalog too,
* so the model can discover and {@code enable_tool} them back.
*/
default String renderExtensionCatalog(AgentToolSet baseSet, Integer maxInputTokens,
Set<String> autoDemoted) {
return renderExtensionCatalog(baseSet, maxInputTokens);
}
/** Drop the cached tier snapshot so the next resolve re-reads the DB. */ /** Drop the cached tier snapshot so the next resolve re-reads the DB. */
void invalidate(); void invalidate();

View File

@ -0,0 +1,33 @@
package vip.mate.tool.disclosure;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* In-memory recency signal for tool usage. Feeds the budget-driven
* auto-demotion ranking: never-used tools demote first, then the least
* recently used ones.
*
* <p>Deliberately process-local and unpersisted this is an advisory
* ranking, not an audit trail. A restart resets everything to "never used",
* which merely makes the first demotion pass alphabetical.
*/
@Component
public class ToolUsageRecencyTracker {
private final Map<String, Long> lastUsedAtMs = new ConcurrentHashMap<>();
/** Record a successful execution of {@code toolName}. */
public void recordUse(String toolName) {
if (toolName != null && !toolName.isBlank()) {
lastUsedAtMs.put(toolName, System.currentTimeMillis());
}
}
/** @return epoch millis of the last recorded use, or null when never used. */
public Long lastUsedAt(String toolName) {
return toolName == null ? null : lastUsedAtMs.get(toolName);
}
}

View File

@ -12,6 +12,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@ -38,7 +39,7 @@ class ReasoningNodePtlPromptTest {
@Test @Test
void prefixIncludesSystemRuntimeAndWikiSegments() { void prefixIncludesSystemRuntimeAndWikiSegments() {
WikiContextService wikiContextService = mock(WikiContextService.class); WikiContextService wikiContextService = mock(WikiContextService.class);
when(wikiContextService.buildRelevantContext(eq(42L), anyString())) when(wikiContextService.buildRelevantContext(eq(42L), anyString(), isNull()))
.thenReturn(WIKI_RELEVANT_TEXT); .thenReturn(WIKI_RELEVANT_TEXT);
ReasoningNode node = newNode(wikiContextService); ReasoningNode node = newNode(wikiContextService);
@ -67,7 +68,7 @@ class ReasoningNodePtlPromptTest {
// that two independent calls with the same inputs produce // that two independent calls with the same inputs produce
// structurally identical output. // structurally identical output.
WikiContextService wikiContextService = mock(WikiContextService.class); WikiContextService wikiContextService = mock(WikiContextService.class);
when(wikiContextService.buildRelevantContext(eq(42L), anyString())) when(wikiContextService.buildRelevantContext(eq(42L), anyString(), isNull()))
.thenReturn(WIKI_RELEVANT_TEXT); .thenReturn(WIKI_RELEVANT_TEXT);
ReasoningNode node = newNode(wikiContextService); ReasoningNode node = newNode(wikiContextService);
@ -125,7 +126,7 @@ class ReasoningNodePtlPromptTest {
@Test @Test
void blankWikiResultSkipsWikiSegment() { void blankWikiResultSkipsWikiSegment() {
WikiContextService wikiContextService = mock(WikiContextService.class); WikiContextService wikiContextService = mock(WikiContextService.class);
when(wikiContextService.buildRelevantContext(eq(42L), anyString())) when(wikiContextService.buildRelevantContext(eq(42L), anyString(), isNull()))
.thenReturn(" "); // blank drop the layer .thenReturn(" "); // blank drop the layer
ReasoningNode node = newNode(wikiContextService); ReasoningNode node = newNode(wikiContextService);

View File

@ -7,6 +7,7 @@ import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.Tool;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import vip.mate.agent.AgentToolSet; import vip.mate.agent.AgentToolSet;
import vip.mate.agent.context.TokenEstimator;
import vip.mate.tool.ToolRegistry; import vip.mate.tool.ToolRegistry;
import vip.mate.tool.mcp.model.McpServerEntity; import vip.mate.tool.mcp.model.McpServerEntity;
import vip.mate.tool.mcp.service.McpServerService; import vip.mate.tool.mcp.service.McpServerService;
@ -88,7 +89,7 @@ class ToolDisclosureServiceTest {
lenient().when(ms.listAll()).thenReturn(servers); lenient().when(ms.listAll()).thenReturn(servers);
lenient().when(as.listAvailable()).thenReturn(available); lenient().when(as.listAvailable()).thenReturn(available);
lenient().when(tr.getEnabledToolSet()).thenReturn(globalSet()); lenient().when(tr.getEnabledToolSet()).thenReturn(globalSet());
return new DefaultToolDisclosureService(ts, ms, as, tr); return new DefaultToolDisclosureService(ts, ms, as, tr, new ToolUsageRecencyTracker());
} }
@Test @Test
@ -202,4 +203,105 @@ class ToolDisclosureServiceTest {
private static List<String> names(List<ToolCallback> cbs) { private static List<String> names(List<ToolCallback> cbs) {
return cbs.stream().map(c -> c.getToolDefinition().name()).toList(); return cbs.stream().map(c -> c.getToolDefinition().name()).toList();
} }
// ==================== budget-driven auto-demotion ====================
/** Three plain core tools for demotion-ranking tests. */
static class ManyCoreTools {
@Tool(description = "core tool a")
public String tool_a() { return ""; }
@Tool(description = "core tool b")
public String tool_b() { return ""; }
@Tool(description = "core tool c")
public String tool_c() { return ""; }
}
private static AgentToolSet manyCoreSet() {
return AgentToolSet.fromCallbacks(List.of(new ManyCoreTools()),
List.of(ToolCallbacks.from(new ManyCoreTools())));
}
@Test
@DisplayName("no demotion when the core schemas fit the budget, or when budget is absent")
void noDemotionWhenBudgetFits() {
var svc = service(List.of(), List.of(), List.of());
AgentToolSet set = manyCoreSet();
assertTrue(svc.computeAutoDemotions(set, Integer.MAX_VALUE).isEmpty());
assertTrue(svc.computeAutoDemotions(set, null).isEmpty());
assertTrue(svc.computeAutoDemotions(set, 1_000_000).isEmpty());
}
@Test
@DisplayName("tiny budget demotes every demotable tool, alphabetical when nothing was ever used")
void tinyBudgetDemotesAll() {
var svc = service(List.of(), List.of(), List.of());
var demoted = svc.computeAutoDemotions(manyCoreSet(), 1);
assertEquals(Set.of("tool_a", "tool_b", "tool_c"), demoted);
}
@Test
@DisplayName("budget one tool short demotes exactly the first never-used candidate")
void partialDemotionTakesFirstCandidate() {
var svc = service(List.of(), List.of(), List.of());
AgentToolSet set = manyCoreSet();
int coreTokens = TokenEstimator.estimateToolsTokens(svc.split(set, Set.of()).activeCallbacks());
var demoted = svc.computeAutoDemotions(set, coreTokens - 1);
assertEquals(Set.of("tool_a"), demoted);
}
@Test
@DisplayName("recently used tools demote last")
void recencyProtectsRecentlyUsed() {
ToolUsageRecencyTracker tracker = new ToolUsageRecencyTracker();
tracker.recordUse("tool_a");
ToolService ts = mock(ToolService.class);
McpServerService ms = mock(McpServerService.class);
AvailableToolService as = mock(AvailableToolService.class);
ToolRegistry tr = mock(ToolRegistry.class);
lenient().when(ts.listTools()).thenReturn(List.of());
lenient().when(ms.listAll()).thenReturn(List.of());
lenient().when(as.listAvailable()).thenReturn(List.of());
lenient().when(tr.getEnabledToolSet()).thenReturn(globalSet());
var svc = new DefaultToolDisclosureService(ts, ms, as, tr, tracker);
AgentToolSet set = manyCoreSet();
int coreTokens = TokenEstimator.estimateToolsTokens(svc.split(set, Set.of()).activeCallbacks());
// One tool over budget: the never-used tool_b (alphabetically first
// among never-used) demotes, the recently used tool_a survives.
var demoted = svc.computeAutoDemotions(set, coreTokens - 1);
assertEquals(Set.of("tool_b"), demoted);
}
@Test
@DisplayName("explicit core DB row and meta-tools are never demoted")
void explicitCoreProtected() {
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);
}
@Test
@DisplayName("auto-demoted tools behave as extension in split and can be enabled back")
void splitHonorsAutoDemotions() {
var svc = service(List.of(), List.of(), List.of());
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(List.of("tool_b"), names(split.extensionCatalog()));
var enabledBack = svc.split(set, Set.of("tool_b"), Set.of("tool_b"));
assertTrue(names(enabledBack.activeCallbacks()).contains("tool_b"));
}
@Test
@DisplayName("catalog rendering lists auto-demoted tools for discoverability")
void catalogListsAutoDemoted() {
var svc = service(List.of(), List.of(), List.of());
String catalog = svc.renderExtensionCatalog(manyCoreSet(), 8192, Set.of("tool_b"));
assertTrue(catalog.contains("tool_b"));
assertFalse(catalog.contains("| `tool_a`"), "non-demoted core tools stay out of the catalog");
}
} }