From fcdb3fc15ee779805035dbb0770071b435536d81 Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 27 Apr 2026 07:51:01 +0800 Subject: [PATCH] fix(agent): break self-replicating 400, narration, args truncation, queue drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A series of cross-cutting stability fixes that surfaced together during a long debugging session. reasoning_content / Claude prefill self-replicating 400: - ChatController persists typed errors (content starts with '[错误] ') with status='error', so the failure text stops being re-sent as multi-turn context — DeepSeek thinking 400 ('reasoning_content must be passed back') and Claude 400 ('does not support assistant message prefill') used to recursively re-create themselves every retry by polluting history. - BaseAgent.sanitizeForLlm filters status='error' / '[错误] ' prefix assistant messages from history before LLM dispatch. - BaseAgent.fetchHistoryMessages defensively drops trailing AssistantMessages — Claude rejects assistant-tail prompts. - NodeStreamingChatHelper.dropTrailingAssistant runs the same defense at every doStreamCall pre-egress, so the in-turn summarizing→reasoning transition (which leaves an assistant scaffold at the tail) doesn't trip Claude either. - AgentGraphBuilder.FallbackPolicy.DEEPSEEK switched (null,true,true) → (' ',false,true), aligning with KIMI/OPENAI's tolerant ' ' fallback. The previous 'force explicit 400' design was the self-replicating loop's prime mover. narration + tool args truncation: - ReasoningNode.DEFAULT_MAX_OUTPUT_TOKENS 4096 → 16384. The 4k cap was decapitating renderDocx tool_call args mid-stream when the model emitted a long content field on top of thinking content; the resulting 'invalid JSON' aborted execution silently. - ReasoningNode appends a hermes-style TOOL_USE_ENFORCEMENT clause to every system prompt: 'when you say you will perform an action, call the tool now in the same response — narration is a protocol violation'. Treats 'now I will generate the docx' (and never actually calling renderDocx) as a forbidden pattern. - ToolExecutionExecutor.normalizeToolExecutionError reframes the JSON-truncated error as actionable instructions: 're-call the same tool now with shorter content or split into multiple sequential calls; do NOT describe the result as text'. side fixes from the same evening: - ChatController doOnComplete skips completionPublisher.publish when isError=true, keeping memory extraction off the garbage path. - ChatController doOnComplete queued-message guard simplified to 'cr.queuedInput() != null', matching the other 4 sites in the controller. The previous 'isInterruptFollowup || !wasStopped' guard silently dropped queued messages when the user did Stop-then-Enqueue (wasStopped=true && interruptType=null), losing the freshly-typed follow-up message. - prompts/graph/summarize-system.txt now distinguishes 'single task' (default; output one cohesive summary) from 'multiple independent sub-tasks' (use the子任务 N format). Stops the summarizer from inventing '子任务 1: PRO-027' decomposition for unitary requests like 'write me a project proposal'. --- .../vip/mate/agent/AgentGraphBuilder.java | 13 +++++- .../main/java/vip/mate/agent/BaseAgent.java | 33 ++++++++++++++ .../agent/graph/NodeStreamingChatHelper.java | 41 +++++++++++++++++ .../graph/executor/ToolExecutionExecutor.java | 16 ++++++- .../mate/agent/graph/node/ReasoningNode.java | 45 ++++++++++++++++++- .../vip/mate/channel/web/ChatController.java | 35 +++++++++++++-- .../prompts/graph/summarize-system.txt | 38 +++++++++++----- 7 files changed, 202 insertions(+), 19 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 058adb7c..b438256a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -1589,7 +1589,18 @@ public class AgentGraphBuilder { * those into new 400s. */ private enum FallbackPolicy { - DEEPSEEK(null, true, true), + // RFC-049 follow-up (2026-04-27): DEEPSEEK previously used (null, true, true) + // to "surface explicit provider error" when the producer-side relay had no + // captured reasoning_content. In practice this kept failing every multi-tool + // turn that crossed a summarizing boundary — the summarizer-produced + // assistant message has no reasoning_content by construction, the relay + // iterator has no entry for it, and DeepSeek returns 400 inside the same + // turn (not just multi-turn replay), aborting the whole graph at the + // reasoning step right after summarizing. Switching to the same " " + // tolerance KIMI/OPENAI use restores forward progress; the producer-side + // capture gap remains a real bug to fix in RFC-049 PR-3 but doesn't + // belong on the user-facing failure path. + DEEPSEEK(" ", false, true), KIMI (" ", false, false), OPENAI (" ", false, false), DEFAULT (" ", false, false); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java index 76aba9a5..5fd5058f 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -222,6 +222,20 @@ public abstract class BaseAgent { messages.add(springMessage); } } + + // Tail guard: a few providers reject prompts whose history ends with an + // assistant message. Anthropic Claude returns 400 "does not support + // assistant message prefill"; DeepSeek thinking mode requires the + // last assistant turn's reasoning_content (which we may not have). + // The trailing-user-dedup above can already produce an assistant tail + // when the immediately-prior turn was an error / placeholder that got + // dropped by stage 1 / 1.5 of sanitizeForLlm. Strip remaining assistant + // tails defensively — the current user message is fed in separately as + // the final prompt by the caller, so dropping these assistant entries + // never loses information the LLM needs. + while (!messages.isEmpty() && messages.get(messages.size() - 1) instanceof AssistantMessage) { + messages.remove(messages.size() - 1); + } return messages; } @@ -264,6 +278,25 @@ public abstract class BaseAgent { return null; } + // Stage 1.5: drop typed-error assistant messages. These are persisted + // by ChatController.doOnComplete with status='error' (or carry the + // "[错误] " prefix injected by NodeStreamingChatHelper for legacy + // rows). Re-sending them as multi-turn context drives a self-replicating + // failure loop: + // - DeepSeek thinking mode → 400 "reasoning_content must be passed back" + // (we never captured a real reasoning_content for the failed turn) + // - Anthropic Claude → 400 "does not support assistant message prefill" + // (the trailing-user-dedup at the call site can leave an assistant + // tail when the prior turn errored) + // Both providers' 400 then re-persist a fresh "[错误] " row, repeat. + if ("assistant".equals(entity.getRole()) + && ("error".equals(entity.getStatus()) + || (entity.getContent() != null && entity.getContent().startsWith("[错误] ")))) { + log.debug("[{}] Filtering error assistant message from history: msgId={} status={}", + agentName, entity.getId(), entity.getStatus()); + return null; + } + // Delegate stages 2-4 to toSpringMessage; the stage 3 scrub is applied // there so the rendered content is replaced before the typed Message // wrapper is constructed. diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java index 7cf4d910..f189105f 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java @@ -575,6 +575,16 @@ public class NodeStreamingChatHelper { // The returned Prompt shares `options` by reference with the input prompt. Prompt outbound = stripThinkingFromPrompt(prompt); + // RFC-049 follow-up (2026-04-27): trim trailing AssistantMessage from the + // outbound prompt. Triggered in practice by the summarizing→reasoning + // graph transition: the summarizer emits an in-turn AssistantMessage, + // graph state ends with it, reasoning's next LLM call sends history + // ending with assistant. Anthropic Claude returns 400 "does not + // support assistant message prefill"; some DeepSeek model variants 400 + // similarly. The dropped assistant is summarizer scaffolding, not + // user-relevant content, so removing it before egress is safe. + outbound = dropTrailingAssistant(outbound); + // PR-2 L3 (RFC-049 §2.3.2): producer-side relay stash. Extract per-assistant // thinking from the normalized prompt (cross-turn positions are already "" due // to strip), stash with the caller's original `user` field, and overwrite @@ -1064,6 +1074,37 @@ public class NodeStreamingChatHelper { return new Prompt(cleaned, prompt.getOptions()); } + /** + * Drop trailing {@link AssistantMessage} entries from a Prompt's instructions. + * Most LLM providers reject prompts whose history ends with an assistant turn — + * Anthropic Claude with a 400 "does not support assistant message prefill", + * DeepSeek thinking-mode variants with reasoning_content errors. The + * trailing assistant is typically a summarizer-emitted scaffold message that + * shouldn't be sent as the final user-facing prompt anyway. Returns the + * input unchanged if there's nothing to drop. + */ + static Prompt dropTrailingAssistant(Prompt prompt) { + List messages = prompt.getInstructions(); + if (messages.isEmpty()) { + return prompt; + } + int end = messages.size(); + while (end > 0 && messages.get(end - 1) instanceof AssistantMessage) { + end--; + } + if (end == messages.size()) { + return prompt; + } + if (end == 0) { + // Refuse to produce an empty prompt — caller's bug; let provider error surface. + log.warn("[dropTrailingAssistant] all messages were AssistantMessage; skipping trim to avoid empty prompt"); + return prompt; + } + log.debug("[dropTrailingAssistant] trimmed {} trailing AssistantMessage(s) from prompt (size {} -> {})", + messages.size() - end, messages.size(), end); + return new Prompt(new ArrayList<>(messages.subList(0, end)), prompt.getOptions()); + } + private StreamResult buildErrorResult(String errorMsg, String conversationId, String phase) { log.error("[{}] Building error result for conversation {}: {}", phase, conversationId, errorMsg); if (streamTracker != null && conversationId != null) { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java index c56edf07..c3a7a15a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java @@ -739,8 +739,20 @@ public class ToolExecutionExecutor { || lower.contains("unexpected character escape sequence") || lower.contains("json parse error") || lower.contains("malformed json")) { - return "Tool execution failed: model generated invalid JSON for tool arguments. " - + "或在字符串转义位置被截断。请改为分步骤写入,拆成多个文件,或缩小单次 write_file/edit_file 的内容后重试。"; + // Truncated tool_call args — typically from max_tokens being hit while + // streaming a large `content` field (e.g. renderDocx with 7000+ char + // markdown body). The fix MUST come from the model: re-emit the same + // tool call with smaller content per call, OR split the work across + // multiple sequential tool calls. We tell the LLM directly so the + // next reasoning iteration knows what to do — without this, models + // tend to fall back to narrating the result as final_answer text. + return "Tool execution failed: your tool_call arguments JSON was truncated mid-stream " + + "(very likely you hit max_tokens while emitting a long content field). " + + "Action required: re-call the SAME tool now in your next response, but " + + "(1) make the content field shorter, OR (2) split the work into multiple " + + "sequential tool calls (e.g. write the doc in 2-3 chunks via separate calls). " + + "Do NOT describe the result as text — you must call the tool again to actually " + + "produce the output."; } if (lower.contains("access denied") && lower.contains("path outside allowed directories")) { 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 ea324c46..32a4792e 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 @@ -52,8 +52,37 @@ public class ReasoningNode implements NodeAction { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - /** 单次 LLM 调用的默认最大输出 token 数,防止退化输出无限生成 */ - private static final int DEFAULT_MAX_OUTPUT_TOKENS = 4096; + /** + * 单次 LLM 调用的默认最大输出 token 数,防止退化输出无限生成。 + *

+ * RFC-049 follow-up (2026-04-27): bumped 4096 → 16384. 4096 was hitting + * the cap when models emit large generative tool_call args (e.g. renderDocx + * with a multi-thousand-character markdown body) on top of thinking + * content for reasoning_effort=high — the JSON args got truncated mid- + * stream, the tool failed to parse, the docx was never generated. 16k is + * the conservative ceiling that covers typical "write a long document" + * tool calls without enabling true runaway loops (those are bounded by + * iteration count, not per-call tokens). + */ + private static final int DEFAULT_MAX_OUTPUT_TOKENS = 16384; + + /** + * Hermes-agent style enforcement clause appended to every ReasoningNode + * system prompt. Treats narration ("I will now …") as a protocol violation + * to prevent the recurring failure mode where a model says it will call a + * tool but emits the description as final_answer text instead. + */ + private static final String TOOL_USE_ENFORCEMENT = "\n\n" + + "## 工具调用纪律(必读)\n\n" + + "- 你**必须**直接调用工具来产生结果,不允许只用文字描述\"接下来要做什么\"。\n" + + "- 当你说要执行某个动作(如生成文件、发送消息、调用接口、生成 docx),\n" + + " 你**必须**在同一条回复里**立即发出对应的 tool_call**,不允许只写文字承诺。\n" + + "- 禁止以\"现在 / 接下来 / 我将 / 直接生成 / 我直接\"+动作描述结束本轮回复——\n" + + " 这种叙述会让系统误判任务已完成,**实际上工具没被调用**,结果文件不会产生。\n" + + "- 如果上一次工具调用因 args JSON 截断(max_tokens 超限)失败,\n" + + " 请重新调用同一工具但**缩小内容**,或拆成多次顺序调用,**不要改成纯文字回答**。\n" + + "- 只在确实没有合适工具,或所有工具步骤都已完成、可以最终回答用户时,\n" + + " 才输出无 tool_call 的纯文字回答。\n"; private final ChatModel chatModel; private final List toolCallbacks; @@ -203,6 +232,18 @@ public class ReasoningNode implements NodeAction { // ======= 构建 Prompt ======= String systemPrompt = accessor.systemPrompt(); + // RFC-049 follow-up: append a tool-use enforcement clause to every + // ReasoningNode call. Without this, models (especially DeepSeek thinking + // and Claude Opus) tend to "narrate" — emit a final_answer like "现在 + // 直接生成立项材料 docx" instead of actually calling renderDocx, which + // makes the graph silently terminate at final_answer_node with the + // narration as the user-facing reply. + // + // Pattern adopted from hermes-agent's TOOL_USE_ENFORCEMENT_GUIDANCE + // (`/agent/prompt_builder.py:179-191`). Appended to systemPrompt rather + // than woven into the AgentEntity-stored prompt so it stays out of the + // user-editable agent UI but is still always-on at runtime. + systemPrompt = systemPrompt + TOOL_USE_ENFORCEMENT; List messages = accessor.messages(); // Guard against runaway message list growth. diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index 7b4af081..b6f97a74 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -455,16 +455,28 @@ public class ChatController { }) .doOnComplete(() -> { if (!finalized.compareAndSet(false, true)) return; - // 区分三种完成语义: + // 区分四种完成语义: // 1. 正常完成(stopRequested=false)→ completed // 2. 用户主动停止 → stopped // 3. 用户中断后续跑(interrupt-with-followup)→ interrupted + // 4. LLM 客户端错误 / typed error → error + // (NodeStreamingChatHelper 把 typed error 序列化为 "[错误] " + // 前缀的文本作为 content_delta 注入,accumulator 不区分, + // 这里靠前缀识别。打标 status='error' 后,BaseAgent + // 的 history sanitization 阶段会跳过这类消息,避免下次 + // prompt 被污染——DeepSeek thinking mode 缺 + // reasoning_content 立即 400,Claude 不接受 assistant + // prefill 也 400,二者循环复制错误。) boolean wasStopped = streamTracker.isStopRequested(conversationId); ChatStreamTracker.InterruptType interruptType = streamTracker.getInterruptType(conversationId); boolean isInterruptFollowup = interruptType == ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP; + boolean isError = accumulator.getContent() != null + && accumulator.getContent().startsWith("[错误] "); String persistStatus; if (accumulator.isAwaitingApproval()) { persistStatus = "awaiting_approval"; + } else if (isError) { + persistStatus = "error"; } else if (!wasStopped) { persistStatus = "completed"; } else { @@ -488,8 +500,11 @@ public class ChatController { savedAssistant = conversationService.saveMessage(conversationId, "assistant", isInterruptFollowup ? "[已中断]" : "[已停止生成]", null, persistStatus); } - // 发布对话完成事件(仅正常完成时,停止/中断不触发记忆提取) - if (!wasStopped) { + // 发布对话完成事件(仅正常完成时;停止/中断/错误均不触发记忆提取) + // RFC-049 follow-up: also skip on isError — error turns persist + // garbage like "[错误] Bad request..." as the assistant reply, + // which would pollute the memory extraction pipeline if propagated. + if (!wasStopped && !isError) { completionPublisher.publish(agentId, conversationId, message, assistantText, "web"); } @@ -520,7 +535,19 @@ public class ChatController { streamTracker.clearInterruptState(conversationId); ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); if (cr.allDone()) { - if (cr.queuedInput() != null && (isInterruptFollowup || !wasStopped)) { + // RFC follow-up (2026-04-27): the previous guard + // cr.queuedInput() != null && (isInterruptFollowup || !wasStopped) + // dropped legitimate queued messages when the user stopped + // the running turn and then sent a new message via the + // enqueue path (not the interrupt-with-followup path) — + // wasStopped=true + isInterruptFollowup=false made the + // guard false, the consumed queuedInput was discarded, + // and the user's new message vanished. The other 4 sites + // in this controller already use the simpler "if queued, + // run it" condition; align with them. If the user + // genuinely doesn't want continuation, no message would + // have been in messageQueue to begin with. + if (cr.queuedInput() != null) { startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username); } else { conversationService.updateStreamStatus(conversationId, "idle"); diff --git a/mateclaw-server/src/main/resources/prompts/graph/summarize-system.txt b/mateclaw-server/src/main/resources/prompts/graph/summarize-system.txt index 1a83da75..187ac46f 100644 --- a/mateclaw-server/src/main/resources/prompts/graph/summarize-system.txt +++ b/mateclaw-server/src/main/resources/prompts/graph/summarize-system.txt @@ -1,14 +1,32 @@ 你是一个信息整理助手。请基于用户的原始问题和多轮工具调用的观察结果,生成一份结构化的上下文摘要。 -要求: -1. 识别用户问题中的每个独立子任务或子问题 -2. 每个子任务的结果必须保留为独立段落,使用编号或标题区分,不要合并不同子任务的结果 -3. 每个子任务段落中保留关键结论、具体数据和支撑证据 -4. 删除每个子任务内部的重复或冗余内容,但不要跨子任务合并 -5. 对信息不确定的地方明确标注 -6. 不要包含原始工具调用日志或技术细节 -7. 每个子任务的摘要控制在 400 字以内,总输出不超过 2000 字 -8. 使用清晰的结构化格式:先列出子任务标题,再给出该子任务的要点列表 +## 任务结构判断(先做这一步) + +判断用户的原始问题包含的是单一任务还是多个独立子任务: + +- **单一任务**:例如"帮我写一份立项材料"、"生成一份周报"、"重构这个文件" — 用户表达的是一个完整目标,即使涉及读多个文件、多步操作,也仍然是一个目标。这种情况下**不要**强行拆分子任务。 +- **多个独立子任务**:例如"先调研 A 再调研 B 然后对比"、"分别给我 X 的信息、Y 的信息、Z 的信息" — 用户的问题里有 2 个及以上明显独立、可以独立完成的子任务。 +- 当不确定时,倾向于按"单一任务"处理。 + +## 输出要求 + +### 单一任务(默认情况) + +- 输出**一份连贯的摘要**,结构清晰即可,不要套用"子任务 1 / 子任务 2"格式。 +- 保留关键结论、关键数据、关键决策依据。 +- 删除重复或冗余内容。 +- 对信息不确定的地方明确标注。 +- 不要包含原始工具调用日志或技术细节。 +- 总输出不超过 2000 字。 + +### 多个独立子任务 + +- 每个子任务的结果必须保留为独立段落,使用编号或标题区分,不要合并不同子任务的结果。 +- 每个子任务段落中保留关键结论、具体数据和支撑证据。 +- 删除每个子任务内部的重复或冗余内容,但不要跨子任务合并。 +- 对信息不确定的地方明确标注。 +- 每个子任务的摘要控制在 400 字以内,总输出不超过 2000 字。 +- 使用清晰的结构化格式:先列出子任务标题,再给出该子任务的要点列表。 格式示例: ### 子任务 1: [任务描述] @@ -17,4 +35,4 @@ ### 子任务 2: [任务描述] - 结论要点 1 -- 结论要点 2 \ No newline at end of file +- 结论要点 2