fix(agent): break self-replicating 400, narration, args truncation, queue drop

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'.
This commit is contained in:
matevip 2026-04-27 07:51:01 +08:00
parent 187197e804
commit fcdb3fc15e
7 changed files with 202 additions and 19 deletions

View File

@ -1589,7 +1589,18 @@ public class AgentGraphBuilder {
* those into new 400s. * those into new 400s.
*/ */
private enum FallbackPolicy { 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), KIMI (" ", false, false),
OPENAI (" ", false, false), OPENAI (" ", false, false),
DEFAULT (" ", false, false); DEFAULT (" ", false, false);

View File

@ -222,6 +222,20 @@ public abstract class BaseAgent {
messages.add(springMessage); 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; return messages;
} }
@ -264,6 +278,25 @@ public abstract class BaseAgent {
return null; 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 // Delegate stages 2-4 to toSpringMessage; the stage 3 scrub is applied
// there so the rendered content is replaced before the typed Message // there so the rendered content is replaced before the typed Message
// wrapper is constructed. // wrapper is constructed.

View File

@ -575,6 +575,16 @@ public class NodeStreamingChatHelper {
// The returned Prompt shares `options` by reference with the input prompt. // The returned Prompt shares `options` by reference with the input prompt.
Prompt outbound = stripThinkingFromPrompt(prompt); Prompt outbound = stripThinkingFromPrompt(prompt);
// RFC-049 follow-up (2026-04-27): trim trailing AssistantMessage from the
// outbound prompt. Triggered in practice by the summarizingreasoning
// 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 // 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 // thinking from the normalized prompt (cross-turn positions are already "" due
// to strip), stash with the caller's original `user` field, and overwrite // 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()); 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<Message> 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) { private StreamResult buildErrorResult(String errorMsg, String conversationId, String phase) {
log.error("[{}] Building error result for conversation {}: {}", phase, conversationId, errorMsg); log.error("[{}] Building error result for conversation {}: {}", phase, conversationId, errorMsg);
if (streamTracker != null && conversationId != null) { if (streamTracker != null && conversationId != null) {

View File

@ -739,8 +739,20 @@ public class ToolExecutionExecutor {
|| lower.contains("unexpected character escape sequence") || lower.contains("unexpected character escape sequence")
|| lower.contains("json parse error") || lower.contains("json parse error")
|| lower.contains("malformed json")) { || lower.contains("malformed json")) {
return "Tool execution failed: model generated invalid JSON for tool arguments. " // Truncated tool_call args typically from max_tokens being hit while
+ "或在字符串转义位置被截断。请改为分步骤写入,拆成多个文件,或缩小单次 write_file/edit_file 的内容后重试。"; // 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")) { if (lower.contains("access denied") && lower.contains("path outside allowed directories")) {

View File

@ -52,8 +52,37 @@ public class ReasoningNode implements NodeAction {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/** 单次 LLM 调用的默认最大输出 token 数,防止退化输出无限生成 */ /**
private static final int DEFAULT_MAX_OUTPUT_TOKENS = 4096; * 单次 LLM 调用的默认最大输出 token 防止退化输出无限生成
* <p>
* 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 ChatModel chatModel;
private final List<ToolCallback> toolCallbacks; private final List<ToolCallback> toolCallbacks;
@ -203,6 +232,18 @@ public class ReasoningNode implements NodeAction {
// ======= 构建 Prompt ======= // ======= 构建 Prompt =======
String systemPrompt = accessor.systemPrompt(); 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<Message> messages = accessor.messages(); List<Message> messages = accessor.messages();
// Guard against runaway message list growth. // Guard against runaway message list growth.

View File

@ -455,16 +455,28 @@ public class ChatController {
}) })
.doOnComplete(() -> { .doOnComplete(() -> {
if (!finalized.compareAndSet(false, true)) return; if (!finalized.compareAndSet(false, true)) return;
// 区分种完成语义 // 区分种完成语义
// 1. 正常完成stopRequested=false completed // 1. 正常完成stopRequested=false completed
// 2. 用户主动停止 stopped // 2. 用户主动停止 stopped
// 3. 用户中断后续跑interrupt-with-followup interrupted // 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 立即 400Claude 不接受 assistant
// prefill 400二者循环复制错误
boolean wasStopped = streamTracker.isStopRequested(conversationId); boolean wasStopped = streamTracker.isStopRequested(conversationId);
ChatStreamTracker.InterruptType interruptType = streamTracker.getInterruptType(conversationId); ChatStreamTracker.InterruptType interruptType = streamTracker.getInterruptType(conversationId);
boolean isInterruptFollowup = interruptType == ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP; boolean isInterruptFollowup = interruptType == ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP;
boolean isError = accumulator.getContent() != null
&& accumulator.getContent().startsWith("[错误] ");
String persistStatus; String persistStatus;
if (accumulator.isAwaitingApproval()) { if (accumulator.isAwaitingApproval()) {
persistStatus = "awaiting_approval"; persistStatus = "awaiting_approval";
} else if (isError) {
persistStatus = "error";
} else if (!wasStopped) { } else if (!wasStopped) {
persistStatus = "completed"; persistStatus = "completed";
} else { } else {
@ -488,8 +500,11 @@ public class ChatController {
savedAssistant = conversationService.saveMessage(conversationId, "assistant", savedAssistant = conversationService.saveMessage(conversationId, "assistant",
isInterruptFollowup ? "[已中断]" : "[已停止生成]", null, persistStatus); 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"); completionPublisher.publish(agentId, conversationId, message, assistantText, "web");
} }
@ -520,7 +535,19 @@ public class ChatController {
streamTracker.clearInterruptState(conversationId); streamTracker.clearInterruptState(conversationId);
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId); ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) { 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); startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username);
} else { } else {
conversationService.updateStreamStatus(conversationId, "idle"); conversationService.updateStreamStatus(conversationId, "idle");

View File

@ -1,14 +1,32 @@
你是一个信息整理助手。请基于用户的原始问题和多轮工具调用的观察结果,生成一份结构化的上下文摘要。 你是一个信息整理助手。请基于用户的原始问题和多轮工具调用的观察结果,生成一份结构化的上下文摘要。
要求: ## 任务结构判断(先做这一步)
1. 识别用户问题中的每个独立子任务或子问题
2. 每个子任务的结果必须保留为独立段落,使用编号或标题区分,不要合并不同子任务的结果 判断用户的原始问题包含的是单一任务还是多个独立子任务:
3. 每个子任务段落中保留关键结论、具体数据和支撑证据
4. 删除每个子任务内部的重复或冗余内容,但不要跨子任务合并 - **单一任务**:例如"帮我写一份立项材料"、"生成一份周报"、"重构这个文件" — 用户表达的是一个完整目标,即使涉及读多个文件、多步操作,也仍然是一个目标。这种情况下**不要**强行拆分子任务。
5. 对信息不确定的地方明确标注 - **多个独立子任务**:例如"先调研 A 再调研 B 然后对比"、"分别给我 X 的信息、Y 的信息、Z 的信息" — 用户的问题里有 2 个及以上明显独立、可以独立完成的子任务。
6. 不要包含原始工具调用日志或技术细节 - 当不确定时,倾向于按"单一任务"处理。
7. 每个子任务的摘要控制在 400 字以内,总输出不超过 2000 字
8. 使用清晰的结构化格式:先列出子任务标题,再给出该子任务的要点列表 ## 输出要求
### 单一任务(默认情况)
- 输出**一份连贯的摘要**,结构清晰即可,不要套用"子任务 1 / 子任务 2"格式。
- 保留关键结论、关键数据、关键决策依据。
- 删除重复或冗余内容。
- 对信息不确定的地方明确标注。
- 不要包含原始工具调用日志或技术细节。
- 总输出不超过 2000 字。
### 多个独立子任务
- 每个子任务的结果必须保留为独立段落,使用编号或标题区分,不要合并不同子任务的结果。
- 每个子任务段落中保留关键结论、具体数据和支撑证据。
- 删除每个子任务内部的重复或冗余内容,但不要跨子任务合并。
- 对信息不确定的地方明确标注。
- 每个子任务的摘要控制在 400 字以内,总输出不超过 2000 字。
- 使用清晰的结构化格式:先列出子任务标题,再给出该子任务的要点列表。
格式示例: 格式示例:
### 子任务 1: [任务描述] ### 子任务 1: [任务描述]