mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 11:37:31 +08:00
feat(tool): 工具 schema 预算阈值门——超出窗口预算时按使用频度自动降级到扩展目录,enable_tool 可找回
This commit is contained in:
parent
56737e197f
commit
bf0d64e46a
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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 "";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user