mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
187197e804
commit
fcdb3fc15e
@ -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);
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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<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) {
|
||||
|
||||
@ -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")) {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user