新增: 工具结果落盘瘦身与空轮自愈
- 超阈值工具结果正文落盘+句柄可读回(治 read_file 29.6% 体量裸奔) - read_file 改小文件豁免(8KB), 超阈值压缩+落盘句柄, 无损可找回 - 空轮判据自愈: 连续空轮先 nudge 续轮, 达阈值停靠止损(开关可回退) - 零 token 收敛信号透传(非截断语义分离) - 上下文清单描述按字符截断, 防清单体量无限膨胀
This commit is contained in:
@@ -283,6 +283,21 @@ pub const GOAL_VERIFY_ENABLED: bool = false;
|
||||
/// 保证 loop 必然终止,绝不死循环。LLM 续轮内调了工具(无论成败)计数器重置(说明在行动)。
|
||||
pub const GOAL_VERIFY_MAX_ROUNDS: u32 = 2;
|
||||
|
||||
/// D5 空轮判据自愈开关(默认 true,u-claw 空轮自愈经验,看板 B 类)。
|
||||
///
|
||||
/// 会话轮次「无产出」(无工具调用且文本纯空白)连续 EMPTY_ROUND_PARK_THRESHOLD 次
|
||||
/// → 判定卡死,走停靠止损(emit_task_parked 正典出口,停靠卡「继续」一键续跑),
|
||||
/// 而非静默空收敛(改动前:空轮直接判 converged,用户收到零内容的「完成」)。
|
||||
/// 未达阈值的空轮注入 nudge 续轮自愈(先自愈后止损,对齐 u-claw 经验)。
|
||||
/// false(回退):整块跳过,空轮行为逐字节等价改动前(静默收敛),排障/对比用。
|
||||
pub const EMPTY_ROUND_GUARD_ENABLED: bool = true;
|
||||
|
||||
/// D5 空轮停靠阈值(默认 3)。
|
||||
///
|
||||
/// 对齐 STALL_BREAKER_THRESHOLD/CIRCUIT_BREAKER_THRESHOLD=3 量级:连续 3 次无产出才判卡死;
|
||||
/// 单次空轮(偶发 provider 抖动/纯 reasoning 输出未成文)由 nudge 续轮自愈,不触发停靠。
|
||||
pub const EMPTY_ROUND_PARK_THRESHOLD: u32 = 3;
|
||||
|
||||
/// G2 警示是否回顾目标(默认 true,需 G1 goal 字段)。
|
||||
///
|
||||
/// true(默认):警示文本引用 pinned_goal(若存在)提示「回顾目标: {goal}」,精准;
|
||||
@@ -885,6 +900,12 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常
|
||||
let mut converged = false;
|
||||
|
||||
// 看板 953:收敛轮截断标志。finalize_round_output 判定正常收敛时若该轮流被 provider
|
||||
// max_tokens 上限腰斩(RoundOutcome::Continue 的 truncated_by_max_tokens),把标志带出 loop,
|
||||
// 正常完成 AiCompleted 透传前端渲染截断提示条。仅 Converged 分支写;stop/保文/达上限等
|
||||
// 退出路径不经过该分支,保持 false(截断只对「用户看到的最终回复」有意义)。
|
||||
let mut converged_truncated = false;
|
||||
|
||||
// L1 断路器:连续同类工具失败滚动窗口(VecDeque<key>,长度封顶 CIRCUIT_BREAKER_WINDOW)。
|
||||
// key=结构化信号(tool_name + 错误类别)。每轮 process_tool_calls 后追加末尾失败 key,
|
||||
// 超窗自动淘汰最旧。窗口内同 key 计数达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。
|
||||
@@ -913,6 +934,9 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 且无 applied.matched_target 证据时强制续轮,每续 +1。达 GOAL_VERIFY_MAX_ROUNDS 强制收敛
|
||||
// + AiCompleted 标注 goal_unverified。续轮内调了工具则重置(说明 LLM 在行动非空转)。
|
||||
let mut goal_verify_rounds: u32 = 0;
|
||||
// D5 空轮连续计数(EMPTY_ROUND_GUARD_ENABLED 门控):连续「无产出」轮数,
|
||||
// finalize_round_output 内记账——空轮 +1,有产出轮清零;达阈值停靠止损。
|
||||
let mut empty_round_streak: u32 = 0;
|
||||
|
||||
// DeepSeek thinking 模式推理内容跨轮透传
|
||||
let mut last_reasoning_content: Option<String> = None;
|
||||
@@ -1102,6 +1126,8 @@ pub(crate) async fn run_agentic_loop(
|
||||
&coord_usage,
|
||||
// Coordinator 路径无 round(未 stream),无估算语义 → false。
|
||||
false,
|
||||
// Coordinator 路径无 round,无截断语义 → false(看板 953)。
|
||||
false,
|
||||
None, None, None, true,
|
||||
&pinned_goals_snapshot,
|
||||
&app_handle,
|
||||
@@ -1267,6 +1293,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
Some(&save_usage), Some(&resolved_model),
|
||||
false, &provider_config, &llm_concurrency, &mut guard,
|
||||
&emit_usage, last_round_estimated,
|
||||
false,
|
||||
Some(true), None, Some(reason), true,
|
||||
&pinned_goals_snapshot, &app_handle, &loop_epoch_arc, my_epoch, false,
|
||||
).await;
|
||||
@@ -1299,6 +1326,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
Some(&save_usage), Some(&resolved_model),
|
||||
false, &provider_config, &llm_concurrency, &mut guard,
|
||||
&emit_usage, last_round_estimated,
|
||||
false,
|
||||
Some(true), None, Some("external_change"), true,
|
||||
&pinned_goals_snapshot, &app_handle, &loop_epoch_arc, my_epoch, false,
|
||||
).await;
|
||||
@@ -1434,12 +1462,15 @@ pub(crate) async fn run_agentic_loop(
|
||||
&full_text, &tool_calls_acc, &round_usage, round_is_estimated,
|
||||
&last_reasoning_content,
|
||||
&mut goal_verify_rounds,
|
||||
&mut empty_round_streak,
|
||||
round_truncated,
|
||||
).await {
|
||||
// RunTools → 继续工具调用段(工具调用数由该段内部取,与原时序一致)
|
||||
RoundEpilogue::RunTools => {}
|
||||
RoundEpilogue::Converged => {
|
||||
// 无工具调用且无续轮需求 → 正常收敛退出(原 converged = true; break,语义不变)
|
||||
// 看板 953:收敛轮被 max_tokens 截断时把标志带出 loop(正常完成 emit 透传前端)
|
||||
converged_truncated = round_truncated;
|
||||
converged = true;
|
||||
break;
|
||||
}
|
||||
@@ -1562,6 +1593,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
&mut guard,
|
||||
&emit_usage,
|
||||
last_round_estimated,
|
||||
false,
|
||||
Some(true), None, Some("max_rounds"), false,
|
||||
&pinned_goals_snapshot,
|
||||
&app_handle,
|
||||
@@ -1626,6 +1658,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
emit_ai_completed_once(
|
||||
&app_handle, &conv_id, &normal_usage,
|
||||
last_round_estimated,
|
||||
converged_truncated,
|
||||
None, None, None, true,
|
||||
&pinned_goals_snapshot,
|
||||
true,
|
||||
@@ -1649,6 +1682,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
&mut guard,
|
||||
&emit_usage,
|
||||
last_round_estimated,
|
||||
false,
|
||||
None, None, None, true,
|
||||
&pinned_goals_snapshot,
|
||||
&app_handle,
|
||||
@@ -1766,6 +1800,8 @@ async fn prepare_round_inputs(
|
||||
&emit_usage,
|
||||
// 入口 stop:本轮尚未 stream,last_round_estimated 仍为初始 false(无估算)。
|
||||
last_round_estimated,
|
||||
// 入口 stop:停止语义非截断,恒 false(看板 953)。
|
||||
false,
|
||||
None, None, None, true,
|
||||
pinned_goals_snapshot,
|
||||
app_handle,
|
||||
@@ -2062,6 +2098,8 @@ async fn finalize_round_output(
|
||||
round_is_estimated: bool,
|
||||
last_reasoning_content: &Option<String>,
|
||||
goal_verify_rounds: &mut u32,
|
||||
// D5 空轮连续计数:空轮 +1 / 有产出轮清零,达 EMPTY_ROUND_PARK_THRESHOLD 停靠止损。
|
||||
empty_round_streak: &mut u32,
|
||||
// 本轮流因 provider max_tokens 上限截断(P2-1),收敛时据实提示不冒充完整
|
||||
round_truncated_by_max_tokens: bool,
|
||||
) -> RoundEpilogue {
|
||||
@@ -2073,6 +2111,11 @@ async fn finalize_round_output(
|
||||
|
||||
// 追加 assistant 消息到历史 + G1 目标提取(扁平重构,原嵌套 8 层 → 3 层)
|
||||
let has_tool_calls = !tool_calls_acc.is_empty();
|
||||
// D5 空轮记账:有产出轮(有工具调用或文本非空)清零连续空轮计数;
|
||||
// 空轮(+1/止损/nudge)在下方 !has_tool_calls 分支处理。
|
||||
if EMPTY_ROUND_GUARD_ENABLED && !round_is_barren(has_tool_calls, full_text) {
|
||||
*empty_round_streak = 0;
|
||||
}
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
if !session.per_conv.contains_key(conv_id) {
|
||||
@@ -2162,6 +2205,8 @@ async fn finalize_round_output(
|
||||
guard,
|
||||
&emit_usage,
|
||||
last_round_estimated,
|
||||
// stop 语义非截断,恒 false(看板 953)。
|
||||
false,
|
||||
None, None, None, true,
|
||||
pinned_goals_snapshot,
|
||||
app_handle,
|
||||
@@ -2173,6 +2218,43 @@ async fn finalize_round_output(
|
||||
|
||||
// 无工具调用 → 最终文本响应。正常收敛退出,或目标验证续轮(GOAL_VERIFY_ENABLED)。
|
||||
if !has_tool_calls {
|
||||
// D5 空轮判据自愈(先于目标验证:空轮无任何产出,谈不上验证目标):
|
||||
// 本轮无产出(纯空白文本 + 无工具调用,push_assistant_message 已跳过落库)→ 连续计数 +1;
|
||||
// 达阈值 → 停靠止损(正典 park 序列对齐 H2 挂钟闸:先 emit_task_parked 再 teardown
|
||||
// 正典收尾,停靠卡「继续」一键续跑),替代改动前的「静默空收敛完成」;
|
||||
// 未达阈值 → 注入 nudge 续轮自愈(先自愈后止损),落到 RunTools(空 acc)即纯续轮。
|
||||
if EMPTY_ROUND_GUARD_ENABLED && round_is_barren(has_tool_calls, full_text) {
|
||||
*empty_round_streak += 1;
|
||||
if empty_round_should_park(*empty_round_streak, EMPTY_ROUND_PARK_THRESHOLD) {
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id, iteration, streak = *empty_round_streak,
|
||||
"[ai] 连续 {} 轮无产出,判定卡死,任务停靠止损", *empty_round_streak
|
||||
);
|
||||
let message = format!(
|
||||
"连续 {} 轮无任何产出(空文本且无工具调用),疑似模型卡死,已安全停靠:点继续或重发消息可续跑",
|
||||
*empty_round_streak
|
||||
);
|
||||
crate::commands::ai::emit_task_parked(
|
||||
&app_handle, &conv_id, &message, "empty_convergence",
|
||||
);
|
||||
// teardown 正典收尾(save + guard 复位 + AiCompleted(incomplete))再退,
|
||||
// 口径对齐 H2 闸停靠(spawn_title=false / emit_incomplete=Some(true))。
|
||||
let save_usage = usage_delta_since(tokens, saved_token_snapshot);
|
||||
let emit_usage = tokens_snapshot(tokens);
|
||||
teardown_round(
|
||||
session_arc, db, conv_id,
|
||||
Some(&save_usage), Some(resolved_model),
|
||||
false, provider_config, llm_concurrency, guard,
|
||||
&emit_usage, last_round_estimated,
|
||||
false,
|
||||
Some(true), None, Some("empty_convergence"), true,
|
||||
pinned_goals_snapshot, app_handle, loop_epoch_arc, my_epoch, false,
|
||||
).await;
|
||||
return RoundEpilogue::Exit;
|
||||
}
|
||||
insert_empty_round_nudge(session_arc, conv_id, iteration, *empty_round_streak).await;
|
||||
return RoundEpilogue::RunTools;
|
||||
}
|
||||
// 目标验证续轮:开关开 + 有钉扎目标 + 本轮无 applied.matched_target 证据 + 未达上限
|
||||
// → 不收敛,注入 nudge 强制续轮(治过早宣称,机制非 prompt 说教)。
|
||||
// 任一条件不满足 → 正常收敛(逐字节等价改动前)。
|
||||
@@ -2433,6 +2515,53 @@ async fn insert_goal_verify_nudge(
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// D5 空轮判据自愈(u-claw 空轮自愈经验,看板 B 类)
|
||||
//
|
||||
// 会话轮次「无产出」(无工具调用 + 文本纯空白)连续 N 次 → 判卡死,停靠止损而非静默空收敛。
|
||||
// 与既有熔断的边界(实施前查重,确认无重复覆盖):
|
||||
// - G2/stall_detector/L1 断路器:工具层信号(签名重复/连续同类失败),空轮无工具调用,
|
||||
// 不进其计数面;
|
||||
// - ROUND_REENTRY/H2 三闸:网络失败与预算兜底;空轮是「流成功返回但零内容」,两者均不覆盖;
|
||||
// - sub_agent EmptyConvergence(P3-7):子代理侧同型判据已落(空收敛降级 Truncated),
|
||||
// 本闸补主会话侧对齐(stop_kind 沿用 empty_convergence 词表,零新契约)。
|
||||
// ============================================================
|
||||
|
||||
/// 纯函数:单轮是否「无产出」。无工具调用且文本纯空白 = 无产出
|
||||
/// (判据对齐 push_assistant_message 的空轮不落库口径:纯空白且无 tool_calls 即无任何信息)。
|
||||
fn round_is_barren(has_tool_calls: bool, text: &str) -> bool {
|
||||
!has_tool_calls && text.trim().is_empty()
|
||||
}
|
||||
|
||||
/// 纯函数:连续无产出是否达停靠阈值(u-claw 判据:连续 N 次空轮 → 卡死止损)。
|
||||
fn empty_round_should_park(streak: u32, threshold: u32) -> bool {
|
||||
streak >= threshold
|
||||
}
|
||||
|
||||
/// 注入空轮自愈 nudge(强制续轮,非软提示):直给结论或调工具,二选一。
|
||||
/// 复用 insert_goal_verify_nudge 的尾部追加范式(nudge 紧邻本轮内容语义不变,
|
||||
/// 不位移既有前缀字节,自动前缀缓存不失效)。
|
||||
async fn insert_empty_round_nudge(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
iteration: usize,
|
||||
streak: u32,
|
||||
) {
|
||||
let nudge_text = format!(
|
||||
"⚠ 你上一轮没有输出任何内容(无文本也无工具调用),已连续 {} 轮。请立即给出有效动作:要么直接输出当前结论与进展,要么调用工具推进任务;不得再次空响应,否则任务将被安全停靠。",
|
||||
streak
|
||||
);
|
||||
let mut session = session_arc.lock().await;
|
||||
if session.per_conv.contains_key(conv_id) {
|
||||
let conv = session.conv(conv_id);
|
||||
conv.messages.push_system_anchor(ChatMessage::system(&nudge_text));
|
||||
tracing::info!(
|
||||
conv_id = %conv_id, iteration, streak,
|
||||
"[EMPTY-ROUND] 空轮未达停靠阈值,注入自愈 nudge 续轮"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── emit_circuit_breaker_tripped: L1 断路器熔断统一 emit(扁平抽自原嵌套块) ──
|
||||
// 开关 CIRCUIT_BREAKER_HELP_EVENT: true=求助卡(默认), false=AiError 错误气泡(兜底回退)。
|
||||
fn emit_circuit_breaker_tripped(app_handle: &AppHandle, conv_id: &str, max_count: u32, sample_key: &str) {
|
||||
@@ -2909,6 +3038,8 @@ pub(crate) async fn emit_ai_completed_once(
|
||||
conv_id: &str,
|
||||
usage: &df_ai::provider::TokenUsage,
|
||||
is_estimated: bool,
|
||||
// 看板 953:最终收敛回复因 provider max_tokens 截断(仅正常收敛出口传 true,其余 false)
|
||||
truncated_by_max_tokens: bool,
|
||||
emit_incomplete: Option<bool>,
|
||||
publish_incomplete: Option<bool>,
|
||||
incomplete_reason: Option<&str>,
|
||||
@@ -2945,7 +3076,7 @@ pub(crate) async fn emit_ai_completed_once(
|
||||
// publish 端:do_publish=false(max_iterations)跳过 publish(保留原无 publish 行为);
|
||||
// MidStream incomplete 分叉(emit=Some(true)/publish=None)事件值不同,不能并成单次
|
||||
// emit_ai_chat_event 调用,分路构造(其余 5 路径两端同值,走单次双写)。
|
||||
let ev = completed_event(usage, is_estimated, emit_incomplete, incomplete_reason, conv_id, pinned_goals);
|
||||
let ev = completed_event(usage, is_estimated, truncated_by_max_tokens, emit_incomplete, incomplete_reason, conv_id, pinned_goals);
|
||||
if do_publish && emit_incomplete == publish_incomplete {
|
||||
emit_ai_chat_event(app_handle, ev);
|
||||
// P0 修复(2026-10-05):正常完成恒走此早退,完成通知桥必须在此投递——
|
||||
@@ -2958,7 +3089,7 @@ pub(crate) async fn emit_ai_completed_once(
|
||||
let _ = app_handle.emit("ai-chat-event", ev);
|
||||
if do_publish {
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(
|
||||
completed_event(usage, is_estimated, publish_incomplete, None, conv_id, pinned_goals),
|
||||
completed_event(usage, is_estimated, truncated_by_max_tokens, publish_incomplete, None, conv_id, pinned_goals),
|
||||
);
|
||||
}
|
||||
// P1 bg 化:完成出口系统通知(条件发送,失败静默;stop 出口经调用方传 false 关闭)
|
||||
@@ -3003,9 +3134,12 @@ pub(crate) async fn last_active_assistant_text(
|
||||
/// 构造 AiCompleted 事件(emit_ai_completed_once 内部去重用,字段口径见其文档)。
|
||||
/// incomplete_reason 与 incomplete 配对:emit 侧事件随 emit_incomplete 取值,publish 侧
|
||||
/// 分叉事件(与 emit 不一致时)incomplete 为 None,reason 同步 None。
|
||||
/// truncated_by_max_tokens(看板 953):最终收敛回复是否被 provider max_tokens 截断,
|
||||
/// 透传前端标截断提示条;非正常收敛出口恒 false。
|
||||
fn completed_event(
|
||||
usage: &df_ai::provider::TokenUsage,
|
||||
is_estimated: bool,
|
||||
truncated_by_max_tokens: bool,
|
||||
incomplete: Option<bool>,
|
||||
incomplete_reason: Option<&str>,
|
||||
conv_id: &str,
|
||||
@@ -3019,6 +3153,7 @@ fn completed_event(
|
||||
prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
|
||||
reasoning_tokens: usage.reasoning_tokens,
|
||||
is_estimated,
|
||||
truncated_by_max_tokens,
|
||||
incomplete,
|
||||
incomplete_reason: incomplete_reason.map(str::to_string),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
@@ -3242,6 +3377,9 @@ pub(crate) fn usage_delta_since(
|
||||
// - save_usage/save_model:透传 save_conversation;Coordinator 传 (None, None) 仍会调 save(幂等)
|
||||
// - emit_usage:emit AiCompleted 的 token 三元组来源(各退出点经 tokens.add/round-derived 后传入)
|
||||
// - emit_estimated(G4.3, 2026-08-05):本轮 token 用量是否估算值,透传 emit_ai_completed_once 仅展示
|
||||
// - truncated_by_max_tokens(看板 953):最终收敛回复因 provider max_tokens 截断,透传
|
||||
// emit_ai_completed_once 仅展示标注;本 helper 覆盖的退出点(stop/保文/达上限等)均非
|
||||
// 「正常收敛被腰斩」语义,调用方恒传 false(唯一 true 出口是正常完成直调 emit_ai_completed_once)
|
||||
// - spawn_title:true → spawn_ensure_title(provider_config 后台生成,失败有 extract_title 兜底)
|
||||
// - emit_incomplete/publish_incomplete/do_publish:透传 emit_ai_completed_once(见该 helper 文档)
|
||||
// - loop_epoch_arc/my_epoch:F1 并发 epoch——旧 loop(epoch 不匹配)只 disarm 跳过 save/reset/emit,
|
||||
@@ -3260,6 +3398,7 @@ pub(crate) async fn teardown_round(
|
||||
guard: &mut GeneratingGuard,
|
||||
emit_usage: &df_ai::provider::TokenUsage,
|
||||
emit_estimated: bool,
|
||||
truncated_by_max_tokens: bool,
|
||||
emit_incomplete: Option<bool>,
|
||||
publish_incomplete: Option<bool>,
|
||||
// 不完整原因(emit 侧,与 emit_incomplete 配对):Some("max_rounds")=达最大轮次自动收尾,
|
||||
@@ -3295,6 +3434,7 @@ pub(crate) async fn teardown_round(
|
||||
// generating 复位后再 emit Completed(对齐原各路径顺序)
|
||||
emit_ai_completed_once(
|
||||
app_handle, conv_id, emit_usage, emit_estimated,
|
||||
truncated_by_max_tokens,
|
||||
emit_incomplete, publish_incomplete, incomplete_reason, do_publish,
|
||||
pinned_goals,
|
||||
notify,
|
||||
@@ -3651,3 +3791,25 @@ mod inject_extra_rounds_tests {
|
||||
assert_eq!(inject_extra_rounds_decision(usize::MAX, 0, 5), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod empty_round_guard_tests {
|
||||
use super::{empty_round_should_park, round_is_barren};
|
||||
|
||||
#[test]
|
||||
fn barren_needs_no_tools_and_blank_text() {
|
||||
// 空文本/纯空白 + 无工具调用 = 无产出;任一维度有内容即有产出
|
||||
assert!(round_is_barren(false, ""));
|
||||
assert!(round_is_barren(false, " \n\t "));
|
||||
assert!(!round_is_barren(false, "结论:已完成"));
|
||||
assert!(!round_is_barren(true, ""), "有工具调用即有产出(工具轮)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn park_exactly_at_consecutive_threshold() {
|
||||
assert!(!empty_round_should_park(1, 3));
|
||||
assert!(!empty_round_should_park(2, 3));
|
||||
assert!(empty_round_should_park(3, 3), "达阈值即停靠止损");
|
||||
assert!(empty_round_should_park(4, 3));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user