修复: 子代理报告双缺陷——熔断补收尾轮 + 报告新近度豁免

① 熔断无收尾轮: max_rounds 打满(达 effective_max 上界且未收敛)时, 旧逻辑直接 teardown 截断, 父任务/消费方拿到的是末轮思考独白而非任务报告。现补一次无工具收尾轮: system 指令「输出最终任务总结报告」+ tools=None(stream_round 空工具表), 成功则 push 报告文本+落库再走原收尾出口; 失败(全候选耗尽/MidStream/Stale)不重试不停靠, 静默落回原截断路径——报告是增益非闸门, 预算仅 1 轮 max_retries=0。

② 报告被视图压缩吞掉: summarize_tool_results 加新近度豁免(RECENCY_EXEMPT_TOOL_RESULTS=2), 末尾 2 条 tool_result 恒不压缩——豁免判定在冻结决策之前且不进决策表, 无前缀缓存漂移; 委托完成报告/末轮工具结果全文保留给 LLM 下一轮消费, 根治「>2KB envelope 下一轮即被 JSON 分支折叠 512 字符, 父 LLM 从未见过报告全文」。

测试: 新增 summarize_recency_exempt_keeps_last_two_tool_results 锁定豁免行为; cargo test -p devflow --lib 712 passed 0 failed; todo.md 两项销账。
This commit is contained in:
lxy
2026-10-06 16:47:07 +08:00
parent 438d47bc9f
commit 9ff26f0c8f
7 changed files with 3338 additions and 198 deletions
+62 -1
View File
@@ -1374,8 +1374,69 @@ pub(crate) async fn run_agentic_loop(
tracing::warn!(
conv_id = %conv_id,
max_iter = max_iterations,
"[ai] agentic 循环达最大轮次仍未收敛,自动完成(incomplete=true)",
"[ai] agentic 循环达最大轮次仍未收敛,补一次无工具收尾轮再自动完成(incomplete=true)",
);
// ── 熔断收尾轮(2026-10-06):max_rounds 打满是常态,旧逻辑直接截断时父任务/
// 消费方拿到的是「末轮思考独白甚至半截工具调用」,而非任务报告。修法:补一次
// 无工具(tools=None)收尾请求——LLM 只能输出文本,即最终总结报告。失败(全候选
// 耗尽/网络断)不重试不停靠,静默落回原截断收尾(报告是增益非闸门)。
{
let (wrapping, est, req) = match prepare_round_inputs(
&session_arc, &db, &app_handle, &conv_id, effective_max.saturating_sub(1),
&stop_flag, &loop_epoch_arc, my_epoch,
&mut guard, &mut tokens, &mut saved_token_snapshot,
last_round_estimated, &provider_config, &llm_concurrency,
&pinned_goals_snapshot, sys_tokens, &system_prompt, &volatile_tail, &intent,
).await {
PreparedRound::Ready { messages, estimated_prompt, agentic_req } => {
(messages, estimated_prompt, agentic_req)
}
// 收尾轮入口闸门不过(stop/stale):guard/收尾已由 prepare 内部处理,直接退
PreparedRound::Exit => return,
};
// 注入收尾指令:明确告知「这是最后一轮,输出任务总结报告」(无工具可调)
let mut wrap_msgs = wrapping;
wrap_msgs.push(ChatMessage::system(
"【任务收尾】本轮次预算已用完,这是最后一轮。请基于以上全部工作输出最终任务总结报告:已完成事项/关键结论/遗留问题与建议。不要再尝试调用任何工具。",
));
match stream_round(
&session_arc, &db, &conv_id,
&wrap_msgs, &[], &app_handle,
&stop_flag, &notify,
0, &model_override, &req, est,
&mut last_round_estimated, &mut last_reasoning_content,
&mut resolved_model, &mut provider_config,
&mut provider_saturation,
&mut tokens, &mut saved_token_snapshot, &mut guard,
&candidates, &llm_concurrency,
&pinned_goals_snapshot,
&loop_epoch_arc, my_epoch,
).await {
// 收尾轮成功:push 报告文本 + 落库(报告进会话,消费方可见)。无工具调用,
// finalize 走收敛分支不会再起轮;此处手动 push(不经 finalize,其会 emit 终态)。
RoundOutcome::Continue { full_text, .. } => {
let text = full_text.trim();
if !text.is_empty() {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) {
let conv = session.conv(&conv_id);
let mut msg = ChatMessage::assistant(text);
msg.model = Some(resolved_model.clone());
conv.messages.push(msg);
}
drop(session);
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
save_conversation(&session_arc, &db, &conv_id, Some(&save_usage), Some(&resolved_model), false).await;
}
}
// 收尾轮任何失败(MidStream/Fatal/Stale):不重试不停靠,
// 落回下方原截断收尾路径(guard 可能已被 stream 内部处理,teardown 幂等)。
_ => {
tracing::warn!(conv_id = %conv_id, "[ai] 熔断收尾轮失败,落回截断收尾");
}
}
}
// save_usage 增量;emit_usage 累计快照(前端展示本 loop 总量)
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = tokens_snapshot(&tokens);
@@ -275,6 +275,12 @@ fn is_failure_content(content: &str) -> bool {
// 压缩决策冻结(前缀稳定):判定结果按消息内容哈希记入 `view_decisions`(首次见到时按
// 当轮阈值判定并写入),后续轮只查表沿用、不再随 history_tokens 增长翻转——否则同一条
// 历史第 N 轮发全文、第 N+1 轮变摘要,轮中前缀必断(自动前缀缓存/显式缓存全失效)。
/// 新近度豁免窗口:末尾 N 条 tool_result 恒不压缩。
/// 依据:子代理委托完成报告经 summarize_tool_results 无差别压缩,>2KB envelope 下一轮
/// 即被折叠成摘要头,父 loop LLM 只见"报告已折叠"不见正文。末尾 2 条=最近一轮工具结果
/// + 委托报告(典型场景),是 LLM 即将消费的最关键上下文。
pub(crate) const RECENCY_EXEMPT_TOOL_RESULTS: usize = 2;
pub(crate) fn summarize_tool_results(
messages: Vec<ChatMessage>,
history_tokens_snapshot: u32,
@@ -293,13 +299,35 @@ pub(crate) fn summarize_tool_results(
// read_file 豁免永远命中不了。
let id_to_name = build_id_to_name(&messages);
// 新近度豁免预计算:Tool 消息在 messages 中的原始下标列表(顺序即时间序),
// 末尾 RECENCY_EXEMPT_TOOL_RESULTS 条内不压缩(报告/末轮 tool_result 是 LLM
// 即将消费的关键上下文,压缩=报告被摘要头吞掉)。
let tool_indices: Vec<usize> = messages
.iter()
.enumerate()
.filter(|(_, m)| matches!(m.role, df_ai::provider::MessageRole::Tool))
.map(|(i, _)| i)
.collect();
let tool_total = tool_indices.len();
let msgs: Vec<ChatMessage> = messages
.into_iter()
.map(|mut m| {
.enumerate()
.map(|(idx, mut m)| {
if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
return m;
}
let content_len = m.content.len();
// 新近度豁免(2026-10-06):末尾 2 条 tool_result 不压缩——末轮工具结果/
// 子代理报告是 LLM 下一轮即将消费的关键上下文,压缩后报告 envelope 被折叠,
// LLM 只见摘要头不见报告正文(实证:委托任务完成报告 >2KB 即被吞)。
// 豁免判定在冻结决策之前:豁免消息不进决策表,且不受历史决策翻转影响。
let tool_idx = tool_indices.iter().position(|&i| i == idx);
if let Some(pos) = tool_idx {
if tool_total - pos <= RECENCY_EXEMPT_TOOL_RESULTS {
return m;
}
}
// 解析真实工具名:tool_call_id 查表(值含参数指纹,此处只取名);查不到退化为 "tool"。
let resolved_name = m
.tool_call_id
@@ -450,4 +478,28 @@ mod tests {
// 含 error 字样的成功结果(读代码/搜失败字样)仍不判失败。
assert!(!is_failure_content(r#"{"exit_code":0,"stdout":"// error handling"}"#));
}
// 新近度豁免:末尾 2 条 tool_result 恒不压缩(委托报告保护),更早的照常压缩。
#[test]
fn summarize_recency_exempt_keeps_last_two_tool_results() {
let big = "x".repeat(8_000); // 远超 2KB 阈值
let msgs = vec![
ChatMessage::tool_result("c1", &big), // 最旧 → 应被压缩
ChatMessage::tool_result("c2", &big), // 倒数第 2 → 豁免
ChatMessage::assistant("中间轮次"),
ChatMessage::tool_result("c3", &big), // 倒数第 1 → 豁免(报告)
ChatMessage::assistant("末轮独白"),
];
let mut decisions = std::collections::HashMap::new();
let mut cache = std::collections::HashMap::new();
let out = summarize_tool_results(msgs, 100_000, &mut decisions, &mut cache, "test", 1);
// c1(最旧)被压缩:content 明显短于原文
let compressed = out.iter().find(|m| m.tool_call_id.as_deref() == Some("c1")).unwrap();
assert!(compressed.content.len() < big.len(), "最旧 tool_result 应被压缩");
// c2/c3(末尾 2 条)豁免:原样保留
for cid in ["c2", "c3"] {
let m = out.iter().find(|m| m.tool_call_id.as_deref() == Some(cid)).unwrap();
assert_eq!(m.content, big, "{cid} 在新近度豁免窗口内,不应压缩");
}
}
}