diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java index 345ba438..7015be02 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java @@ -3,6 +3,7 @@ package vip.mate.agent.context; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.ToolResponseMessage; @@ -118,6 +119,18 @@ public class ConversationWindowManager { private final MemoryManager memoryManager; private final ConversationService conversationService; + /** + * Optional — adaptive compaction trigger for small context windows. + * Setter-injected so the many direct test constructions keep the plain + * configured ratio (null → previous behavior). + */ + private PrefixBudgetPlanner prefixBudgetPlanner; + + @Autowired(required = false) + public void setPrefixBudgetPlanner(PrefixBudgetPlanner prefixBudgetPlanner) { + this.prefixBudgetPlanner = prefixBudgetPlanner; + } + /** * Optional spill store, injected via setter so unit tests and the two * existing 3-arg constructor callers in tests stay source-compatible. @@ -251,7 +264,12 @@ public class ConversationWindowManager { int effectiveMax = (maxInputTokens != null && maxInputTokens > 0) ? maxInputTokens : properties.getDefaultMaxInputTokens(); - int triggerThreshold = (int) (effectiveMax * properties.getCompactTriggerRatio()); + // Small windows compact later (higher trigger ratio): summarizing at + // 75% of an 8k window throws away room it cannot afford to lose. + double triggerRatio = prefixBudgetPlanner != null + ? prefixBudgetPlanner.compactTriggerRatioFor(effectiveMax, properties.getCompactTriggerRatio()) + : properties.getCompactTriggerRatio(); + int triggerThreshold = (int) (effectiveMax * triggerRatio); int systemTokens = TokenEstimator.estimateTokens(systemPrompt); int currentMsgTokens = TokenEstimator.estimateTokens(currentUserMessage) + TokenEstimator.PER_MESSAGE_OVERHEAD; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlanner.java b/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlanner.java index 1eda6a8f..fd9ab65a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlanner.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/PrefixBudgetPlanner.java @@ -30,6 +30,9 @@ import vip.mate.config.PrefixBudgetProperties; @EnableConfigurationProperties(PrefixBudgetProperties.class) public class PrefixBudgetPlanner { + /** COMPACT-profile ceiling for the wiki relevance injection (~one page). */ + static final int COMPACT_WIKI_TOKEN_CAP = 2000; + private final PrefixBudgetProperties properties; private final ConversationWindowProperties windowProperties; @@ -66,10 +69,21 @@ public class PrefixBudgetPlanner { sum = 1.0; } + // Profile-specific wiki clamps: knowledge-base reference pages are the + // most dispensable block on a small window — the wiki tools stay + // callable, only the automatic pre-injection shrinks. COMPACT caps it + // at roughly one page; MINIMAL disables it outright. + int wikiTokens = (int) (injectionBudget * shares.getWiki() / sum); + wikiTokens = switch (profile) { + case NORMAL -> wikiTokens; + case COMPACT -> Math.min(wikiTokens, COMPACT_WIKI_TOKEN_CAP); + case MINIMAL -> 0; + }; + PrefixBudgetPlan plan = new PrefixBudgetPlan( true, effectiveMax, profile, injectionBudget, (int) (injectionBudget * shares.getMemory() / sum), - (int) (injectionBudget * shares.getWiki() / sum), + wikiTokens, (int) (injectionBudget * shares.getSkill() / sum), (int) (injectionBudget * shares.getExtensionCatalog() / sum), (int) (injectionBudget * shares.getLedger() / sum), diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java index 2cd48158..4186f8a2 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java @@ -749,6 +749,27 @@ public class ReasoningNode implements NodeAction { Prompt prompt = new Prompt(promptMessages, options); + // Prefix accounting: how much of the window the never-trimmed prefix + // (system prompt + runtime context + wiki + skill catalog + ledger) + // and the advertised tool schemas consume. Logged on the turn's first + // call so a small-window overflow is diagnosable per block instead of + // surfacing as an opaque provider 400. + int prefixEstimateTokens = TokenEstimator.estimateTokens(nonHistoryPrefix); + int toolSchemaEstimateTokens = TokenEstimator.estimateToolsTokens(activeCallbacks); + if (accessor.llmCallCount() == 0) { + log.info("[ReasoningNode] Prefix accounting conv={}: window={} tokens, prefix={} " + + "(system+context+wiki+skills+ledger), toolSchemas={}, history={}", + conversationId, loopContextWindowTokens(), prefixEstimateTokens, + toolSchemaEstimateTokens, TokenEstimator.estimateTokens(messages)); + } + // The prefix cannot be compacted (history compaction is the only lever), + // so a prefix that alone exceeds the window makes the request doomed — + // fail fast with the same PROMPT_TOO_LONG shape a provider rejection + // would produce instead of sending it. Gated on budgeting being active + // (an estimation false-positive must not block requests otherwise). + boolean prefixOverflow = prefixBudgetPlan != null && prefixBudgetPlan.enabled() + && prefixEstimateTokens + toolSchemaEstimateTokens > prefixBudgetPlan.effectiveMaxTokens(); + // ======= LLM 调用区域 ======= // nextLlmCallCount 在首次 streamCall 之前计算。 // 所有退出路径(正常、stopped、fatal error、CancellationException)都必须写回此值。 @@ -778,7 +799,19 @@ public class ReasoningNode implements NodeAction { NodeStreamingChatHelper.StreamResult result; try { - result = streamingHelper.streamCall(chatModel, prompt, conversationId, "reasoning"); + if (prefixOverflow) { + String overflowMessage = "Prompt 前缀估算 " + (prefixEstimateTokens + toolSchemaEstimateTokens) + + " tokens(注入块 " + prefixEstimateTokens + " + 工具 schema " + toolSchemaEstimateTokens + + ")已超过模型上下文窗口 " + prefixBudgetPlan.effectiveMaxTokens() + + " tokens,历史压缩无法解决——请精简 Agent 身份 prompt、减少绑定工具/技能," + + "或换用更大窗口的模型"; + log.error("[ReasoningNode] {}", overflowMessage); + result = new NodeStreamingChatHelper.StreamResult(null, null, null, List.of(), false, + 0, 0, false, overflowMessage, + NodeStreamingChatHelper.ErrorType.PROMPT_TOO_LONG, false, 0, 0, 0); + } else { + result = streamingHelper.streamCall(chatModel, prompt, conversationId, "reasoning"); + } // PTL 处理:结构化压缩后重试。复用 nonHistoryPrefix 保证重试 // Prompt 仍带 wiki / runtime context;早期的 tail-only 路径会把 diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/PrefixBudgetPlannerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/PrefixBudgetPlannerTest.java index 21ae3a87..c23cffc0 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/context/PrefixBudgetPlannerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/PrefixBudgetPlannerTest.java @@ -44,11 +44,35 @@ class PrefixBudgetPlannerTest { } @Test - @DisplayName("4k window → MINIMAL profile") + @DisplayName("4k window → MINIMAL profile, wiki injection disabled outright") void minimalProfile() { PrefixBudgetPlan plan = planner.plan(4096, 500, 500); assertEquals(PrefixBudgetPlan.Profile.MINIMAL, plan.profile()); assertEquals(Math.max(0, (int) (4096 * 0.15) - 1000), plan.injectionBudgetTokens()); + assertEquals(0, plan.wikiTokens()); + } + + @Test + @DisplayName("COMPACT profile caps the wiki injection at roughly one page") + void compactCapsWiki() { + properties.getShares().setWiki(1.0); + properties.getShares().setMemory(0.0); + properties.getShares().setSkill(0.0); + properties.getShares().setExtensionCatalog(0.0); + properties.getShares().setLedger(0.0); + PrefixBudgetPlan plan = planner.plan(20000, 0, 0); + assertEquals(PrefixBudgetPlan.Profile.COMPACT, plan.profile()); + assertTrue(plan.injectionBudgetTokens() > PrefixBudgetPlanner.COMPACT_WIKI_TOKEN_CAP); + assertEquals(PrefixBudgetPlanner.COMPACT_WIKI_TOKEN_CAP, plan.wikiTokens()); + } + + @Test + @DisplayName("plan carries an independent tool-schema budget from toolSchemaRatio") + void toolSchemaBudget() { + PrefixBudgetPlan plan = planner.plan(16384, 0, 0); + assertEquals((int) (16384 * 0.25), plan.toolSchemaBudgetTokens()); + properties.setEnabled(false); + assertEquals(Integer.MAX_VALUE, planner.plan(16384, 0, 0).toolSchemaBudgetTokens()); } @Test