修复: system 锚点避让悬空 tool 调用头防 400

- df-ai 新增 ContextManager::push_system_anchor 统一出口:末条为悬空 ToolCallHead 时插其前,否则尾部追加(不位移前缀)
- 5 处 system 尾部 push 全量收口(压缩成功/兜底提交、警示单点、主题标记、续轮 nudge),从源头杜绝后台压缩摘要/警示楔入工具三元组致上游 400 insufficient tool messages
- 单测 push_system_anchor 两态锁定;cargo test -p df-ai --lib context 155 passed / cargo check -p devflow EXIT 0
- 看板归档:全量完成态条目与段落迁 docs/07-项目管理/todo归档/2026-10-10.md(零措辞改动机械搬运,未完成项110条零丢失/完成项37条全入归档/行数守恒)
This commit is contained in:
lxy
2026-10-10 00:21:13 +08:00
parent c71d0c4657
commit 1f5e6cce90
7 changed files with 258 additions and 180 deletions
+34
View File
@@ -1200,3 +1200,37 @@ fn eviction_no_user_text_no_summary() {
);
assert_eq!(mgr.all_messages_clone().len(), 16, "裁剪污染了内存全量");
}
// ── 源头守静(909):push_system_anchor 避开悬空 tool_call 头 ──
#[test]
fn push_system_anchor_appends_on_normal_tail() {
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问"));
mgr.push(ChatMessage::assistant("答"));
let avoided = mgr.push_system_anchor(ChatMessage::system("警示"));
assert!(!avoided, "常规尾部应走追加(不避让)");
assert_eq!(mgr.len(), 3);
assert_eq!(mgr.messages_mut()[2].message.content, "警示", "system 应在尾部");
}
#[test]
fn push_system_anchor_avoids_dangling_tool_call_head() {
// [user] [assistant(tool_calls)] ← 末条为悬空头(其 tool 响应尚未回填)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("跑工具"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("c1".to_string(), "t", "{}")],
));
let avoided = mgr.push_system_anchor(ChatMessage::system("## 摘要"));
assert!(avoided, "末条为悬空 tool_call 头应避让插入");
// 序列应为 [user, system(摘要), assistant(tool_calls)] —— 头仍是末条,三元组不被劈开
let msgs = mgr.all_messages_clone();
assert_eq!(msgs.len(), 3);
assert_eq!(msgs[1].content, "## 摘要", "摘要应插在悬空头之前");
assert!(
msgs[2].tool_calls.as_ref().is_some_and(|c| !c.is_empty()),
"悬空头应保持末条,其 tool 响应可紧随其后回填(协议不变式成立)"
);
}
+35
View File
@@ -567,6 +567,41 @@ impl ContextManager {
self.needs_full_rewrite = true;
}
/// 追加一条 system 锚点消息(警示文本/压缩摘要),自动避开**悬空 tool_call 头**。
///
/// **源头守静(909)**:OpenAI/内部协议不变式是「assistant(tool_calls) 之后必须紧随
/// 其 tool 响应」。主 loop 的顺序是 push 头(`finalize_round_output`)→ 释放锁 →
/// 执行工具 → 再取锁 push 结果;若在「头已入、结果未回」的窗口内从尾部追加 system
/// (后台压缩摘要/警示/nudge/主题标记),system 就楔进 head 与其响应之间 → 上游报
/// 400「insufficient tool messages following tool_calls message」。
///
/// 本出口统一处置:末条非悬空头 → 常规尾部追加(不位移前缀,保自动前缀缓存,行为与
/// 原 `push` 一致);末条是悬空头 → 改插在该头**之前**(index = len-1),不劈三元组,
/// 摘要/警示仍紧邻本轮内容对 LLM 可见。返回 `true` 表示走了避让插入(会置
/// `needs_full_rewrite`,需 DB 全量重写),供调用方记日志。
pub fn push_system_anchor(&mut self, message: ChatMessage) -> bool {
if self.ends_with_dangling_tool_call_head() {
let idx = self.messages.len() - 1;
self.insert_at(idx, message);
tracing::debug!(
idx,
"system 锚点避让悬空 tool_call 头插入(防楔入三元组致 400)"
);
true
} else {
self.push(message);
false
}
}
/// 末条是否为「悬空 tool_call 头」(assistant 带 tool_calls 且其 tool 响应尚未回填)。
/// 供 [`Self::push_system_anchor`] 判定避让位置:末条即头,说明头已入而结果未回。
fn ends_with_dangling_tool_call_head(&self) -> bool {
self.messages
.last()
.is_some_and(|m| m.group == MessageGroup::ToolCallHead)
}
/// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。
///
/// 返回每个单元的右开区间 end + token 总和,保证:
File diff suppressed because one or more lines are too long
+5 -175
View File
File diff suppressed because one or more lines are too long
@@ -266,7 +266,7 @@ pub(super) async fn maybe_auto_compress(
// 前缀字节,使 DeepSeek 等自动前缀缓存的 provider 全部缓存失效
// (命中率掉到个位数)。尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定
// 命中率最大化。JSON 卡片回填 WorkingContext,决策随压缩重锚定。
conv.messages.push(ChatMessage::system(&summary.nl_summary));
conv.messages.push_system_anchor(ChatMessage::system(&summary.nl_summary));
conv.messages.set_compressing(false);
conv.working_context.sync_after_compress(&summary.json_card);
}
@@ -369,7 +369,7 @@ async fn keyword_fallback_commit(
// 尾部追加(而非 insert_at(0) 头部插入):头部插入位移整条前缀字节,
// 使 DeepSeek 等自动前缀缓存 provider 全部缓存失效(命中率掉到个位数)。
// 尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定命中率最大化。
conv.messages.push(ChatMessage::system(keyword_fallback));
conv.messages.push_system_anchor(ChatMessage::system(keyword_fallback));
true
}
};
+1 -1
View File
@@ -41,7 +41,7 @@ pub(crate) async fn insert_system_notice(
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.messages.push(ChatMessage::system(text));
conv.messages.push_system_anchor(ChatMessage::system(text));
tracing::info!(conv_id = %conv_id, "[ai] {}", log_tag);
}
}
+2 -2
View File
@@ -1801,7 +1801,7 @@ async fn prepare_round_inputs(
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.messages.push(ChatMessage::system(&marker_text));
conv.messages.push_system_anchor(ChatMessage::system(&marker_text));
tracing::info!(
conv_id = %conv_id,
iteration,
@@ -2396,7 +2396,7 @@ async fn insert_goal_verify_nudge(
let conv = session.conv(conv_id);
// 尾部追加而非 insert_at(0):nudge 紧邻本轮内容语义不变,且不位移既有前缀字节
// (insert_at(0) 会把整条历史后移,自动前缀缓存/Anthropic 显式缓存全失效)。
conv.messages.push(ChatMessage::system(&nudge_text));
conv.messages.push_system_anchor(ChatMessage::system(&nudge_text));
tracing::info!(
conv_id = %conv_id, iteration, verify_rounds,
"[GOAL-VERIFY] 目标未验证,注入续轮 nudge(强制续轮)"