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.
*/
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);

View File

@ -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.

View File

@ -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 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
// 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<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) {
log.error("[{}] Building error result for conversation {}: {}", phase, conversationId, errorMsg);
if (streamTracker != null && conversationId != null) {

View File

@ -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")) {

View File

@ -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 防止退化输出无限生成
* <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 List<ToolCallback> 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<Message> messages = accessor.messages();
// Guard against runaway message list growth.

View File

@ -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 立即 400Claude 不接受 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");

View File

@ -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
- 结论要点 2