新增: 无人值守P0任务边界全链
- 任务级授权边界判定与审批旁路单点插桩 - boundary_json 随会话落库(V45 可逆迁移) - 六路非正常终局统一安全停靠与托盘通知 - 每五轮进度事件与会话头部进度条 - 前端边界配置卡侧栏徽标及 UX 校验补齐
This commit is contained in:
@@ -86,6 +86,18 @@ pub const DEFAULT_MAX_AGENT_RETRIES: usize = 3;
|
||||
/// N=3 对齐 DEFAULT_MAX_AGENT_RETRIES 量级,3 轮连续饱和才判持续过载,防抖动误降级。
|
||||
pub const PROVIDER_SATURATION_DEMOTE_THRESHOLD: usize = 3;
|
||||
|
||||
/// 无人值守进度可观测(设计 §4.3):主 loop 每 N 轮 emit AiTaskProgress 一次。
|
||||
/// 5=设计拍板点 4 建议 A;每轮 emit 噪音大,不做则牺牲静默期可观测。实测后可调。
|
||||
pub const PROGRESS_EVERY_N_ROUNDS: usize = 5;
|
||||
|
||||
/// 跨轮任务级重入(深查报告 P1-4):单轮流前重试共享 30s 挂钟预算,一次 >30s 的网络故障
|
||||
/// (WiFi 切换/VPN 重连/上游限流窗口)即整任务失败。本层加「轮间」耐受力:轮失败不立即
|
||||
/// 终局,指数退避后从当前轮重入(上下文已落库,每轮 save 天然支持;iteration 不推进,
|
||||
/// 不烧 max_rounds 预算)。连续重入 ROUND_REENTRY_MAX 次,退避 1min/5min/15min,
|
||||
/// 仍失败转停靠(fatal)。单轮内 30s 预算不动(防死循环),只加轮间容忍。
|
||||
pub const ROUND_REENTRY_MAX: usize = 3;
|
||||
pub const ROUND_REENTRY_BACKOFF_SECS: [u64; ROUND_REENTRY_MAX] = [60, 300, 900];
|
||||
|
||||
/// 改进3 B 常量开关 KEYWORD_FALLBACK_ENABLED 已随压缩逻辑迁至 context_lifecycle.rs
|
||||
/// (在该模块内部为私有 const,文档见 context_lifecycle.rs 顶部)。
|
||||
///
|
||||
@@ -154,10 +166,15 @@ pub const MAX_GOALS: usize = 5;
|
||||
/// system_prompt 零变化,退回改动前行为(单点回退,不影响 augmentation 的 / 联想)。
|
||||
pub const SKILL_LIST_INJECT_ENABLED: bool = true;
|
||||
|
||||
/// 技能清单注入最大条数(默认 20)。
|
||||
/// 技能清单注入最大条数(默认 120)。
|
||||
///
|
||||
/// 防本机大量技能全量进 system_prompt 占 sys_tokens 预算;取前 N 条(name 去重序),截断描述。
|
||||
pub const SKILL_LIST_MAX_ITEMS: usize = 20;
|
||||
/// 自动触发(方案3):本机实扫 38 home skills + 1 command + 72 plugins ≈ 111 个技能,
|
||||
/// 原上限 20 会把 90+ 个技能对 LLM 完全隐藏(publish-moyu-uat 之后全部不可见),
|
||||
/// "任务命中技能 → AI 自主 read_skill"的自动触发链路因清单不可见而断裂。
|
||||
/// 清单单条为「名称+80字描述」索引卡片(约 30 token/条),120 条 ≈ 3.6K token,
|
||||
/// 且注入位于 system 稳定段(跨轮缓存命中,只付一次成本),120 上限兼顾覆盖与预算。
|
||||
pub const SKILL_LIST_MAX_ITEMS: usize = 120;
|
||||
|
||||
/// 技能清单单条描述截断长度(默认 80 字符)。
|
||||
///
|
||||
@@ -1039,6 +1056,10 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 仅 per-provider 层非阻塞降级,不影响 global/per_conv 阻塞限流。
|
||||
let mut provider_saturation: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
// 跨轮任务级重入(ROUND_REENTRY_MAX):连续轮失败计数,Continue 成功即归零
|
||||
// (只认连续失败,偶发一轮网络故障不消耗后续耐受力)。
|
||||
let mut consecutive_round_failures: usize = 0;
|
||||
|
||||
// 0 = 不限:effective_max=usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出(下方达上限暂停分支不触发)
|
||||
let effective_max = if max_iterations == 0 { usize::MAX } else { max_iterations };
|
||||
|
||||
@@ -1053,6 +1074,20 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 依赖 panic=unwind(tokio 默认),panic=abort 构建下 catch_unwind 不生效(尽力兜底)。
|
||||
let outcome = unwind_to_result(async {
|
||||
for iteration in start_iteration..effective_max {
|
||||
// ── 进度可观测段(设计 §4.3):每 N 轮 emit AiTaskProgress ──
|
||||
// round 1 起算对齐 AiAgentRound 口径;max_rounds=0=不限轮次(前端按无上限处理)。
|
||||
// owner 守卫:被接管的旧 loop 不发(防串会话进度)。事件即逝不落库,终局不补发。
|
||||
if iteration % PROGRESS_EVERY_N_ROUNDS == 0
|
||||
&& loop_epoch_arc.load(Ordering::SeqCst) == my_epoch
|
||||
{
|
||||
emit_ai_chat_event(&app_handle, AiChatEvent::AiTaskProgress {
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
round: (iteration + 1) as u32,
|
||||
max_rounds: max_iterations as u32,
|
||||
tokens_used: tokens.total(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── 轮次准备段:入口 stop/stale 收尾闸门 → 主题标记 → conv 一致性(iteration_used/epoch/心跳)
|
||||
// → 轮通知 → 自动压缩 → 请求消息构建(build_for_request + namespace 展开 + 易变尾段
|
||||
// + tool_result 摘要 + 占位断言)→ estimated_prompt + agentic_req 重建 ──
|
||||
@@ -1070,29 +1105,81 @@ pub(crate) async fn run_agentic_loop(
|
||||
PreparedRound::Exit => return,
|
||||
};
|
||||
|
||||
// ── 流式消费段:单轮流式调用(candidate 链 + MidStream 保文)──
|
||||
// ── 流式消费段:单轮流式调用(candidate 链 + MidStream 保文 + 跨轮重入)──
|
||||
// 实现见 stream_relay::stream_round。RoundOutcome 四态:Continue 携输出继续;
|
||||
// StaleAfterStream(流后 epoch 变,guard 已 disarm)/ MidStreamSaved(保文已完整收尾)/
|
||||
// Fatal(候选 Fatal 或全耗尽,guard.reset + emit + save 已完成)→ 本轮终止 return。
|
||||
let (full_text, tool_calls_acc, round_usage, round_is_estimated) = match stream_round(
|
||||
&session_arc, &db, &conv_id,
|
||||
&messages, &tool_defs, &app_handle,
|
||||
&stop_flag, ¬ify,
|
||||
max_retries, &model_override, &agentic_req, estimated_prompt,
|
||||
&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 {
|
||||
RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated } => {
|
||||
(full_text, tool_calls, round_usage, round_is_estimated)
|
||||
// Fatal(候选 Fatal 或全耗尽,guard.reset + emit + save 已完成)。
|
||||
// 'stream_round 重入层(ROUND_REENTRY_MAX):Fatal 不再立即终局,指数退避后从
|
||||
// 当前轮重入(stream_relay 每 per-permit 重取重入获全新 30s 预算 + 全新候选链)。
|
||||
let (full_text, tool_calls_acc, round_usage, round_is_estimated, round_truncated) = 'stream_round: loop {
|
||||
match stream_round(
|
||||
&session_arc, &db, &conv_id,
|
||||
&messages, &tool_defs, &app_handle,
|
||||
&stop_flag, ¬ify,
|
||||
max_retries, &model_override, &agentic_req, estimated_prompt,
|
||||
&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 {
|
||||
RoundOutcome::Continue { full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens } => {
|
||||
// 本轮成功:连续失败计数归零(重入只认连续失败)。
|
||||
consecutive_round_failures = 0;
|
||||
break (full_text, tool_calls, round_usage, round_is_estimated, truncated_by_max_tokens);
|
||||
}
|
||||
RoundOutcome::StaleAfterStream => return,
|
||||
RoundOutcome::MidStreamSaved => {
|
||||
// 安全停靠(设计 §四):MidStream 原裸 return=整任务静默终止(保文
|
||||
// AiCompleted(incomplete) 无停靠语义,托盘零感知)。对齐 Fatal 路径补
|
||||
// AiTaskParked + NeedAction 通知。teardown/save/guard 复位已由
|
||||
// stream_relay 保文路径完成(stop_reason="network"),此处不重复 save。
|
||||
crate::commands::ai::emit_task_parked(
|
||||
&app_handle, &conv_id,
|
||||
"响应流中途断开,已接收的部分内容已保存;任务安全停靠,重发消息可继续",
|
||||
"midstream",
|
||||
);
|
||||
return;
|
||||
}
|
||||
RoundOutcome::Fatal { error } => {
|
||||
// P2-4:error 已由 stream_round emit 错误气泡,此处仅留诊断日志
|
||||
// (真实原因不再只进子会话事件面,排查有据可查)。
|
||||
consecutive_round_failures += 1;
|
||||
if consecutive_round_failures > ROUND_REENTRY_MAX {
|
||||
// 连续重入预算耗尽:转停靠(网络故障已超出轮间退避可覆盖范围)。
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
failures = consecutive_round_failures,
|
||||
error = %error,
|
||||
"[ai] 连续 {} 轮失败,重入预算耗尽,任务停靠",
|
||||
consecutive_round_failures,
|
||||
);
|
||||
crate::commands::ai::emit_task_parked(
|
||||
&app_handle, &conv_id,
|
||||
&format!("模型调用连续 {} 轮失败(重入退避后仍未恢复),任务安全停靠:{}", consecutive_round_failures, error),
|
||||
"fatal",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let delay = ROUND_REENTRY_BACKOFF_SECS[consecutive_round_failures - 1];
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
attempt = consecutive_round_failures,
|
||||
max_attempts = ROUND_REENTRY_MAX,
|
||||
delay_secs = delay,
|
||||
error = %error,
|
||||
"[ai] 本轮失败,{}s 后从当前轮重入({}/{}): {}",
|
||||
delay, consecutive_round_failures, ROUND_REENTRY_MAX, error,
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
||||
// 退避后不显式查 stop/epoch:stream_round 内部闸门已兜
|
||||
// (stop_flag→停止路径由 finalize 收尾、epoch 后校验→StaleAfterStream),
|
||||
// 重入即被正确接管,无需重复判定。
|
||||
continue 'stream_round;
|
||||
}
|
||||
}
|
||||
RoundOutcome::StaleAfterStream => return,
|
||||
RoundOutcome::MidStreamSaved => return,
|
||||
RoundOutcome::Fatal => return,
|
||||
};
|
||||
// global/per_conv permit 已上移 loop 入口整 loop 持有,
|
||||
// stream 后不再立即释放(会话级并发语义:工具执行期间也占槽)。per-provider permit
|
||||
@@ -1110,6 +1197,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
&full_text, &tool_calls_acc, &round_usage, round_is_estimated,
|
||||
&last_reasoning_content,
|
||||
&mut goal_verify_rounds,
|
||||
round_truncated,
|
||||
).await {
|
||||
// RunTools → 继续工具调用段(工具调用数由该段内部取,与原时序一致)
|
||||
RoundEpilogue::RunTools => {}
|
||||
@@ -1182,6 +1270,14 @@ pub(crate) async fn run_agentic_loop(
|
||||
&loop_epoch_arc, my_epoch,
|
||||
false,
|
||||
).await;
|
||||
// 安全停靠(设计 §四):预算(轮次)耗尽=五路非正常终局之一。teardown 已完成
|
||||
// save + generating 复位 + AiCompleted(incomplete)+ NeedAction 通知(5s 节流
|
||||
// 与停靠通知同组合并不会刷屏),此处补结构化 AiTaskParked 供前端操作卡消费。
|
||||
crate::commands::ai::emit_task_parked(
|
||||
&app_handle, &conv_id,
|
||||
&format!("已达最大轮次({}),任务安全停靠:未完成事项已随会话保存,点继续或重发消息可追加授权续跑", max_iterations),
|
||||
"max_rounds",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1661,6 +1757,8 @@ async fn finalize_round_output(
|
||||
round_is_estimated: bool,
|
||||
last_reasoning_content: &Option<String>,
|
||||
goal_verify_rounds: &mut u32,
|
||||
// 本轮流因 provider max_tokens 上限截断(P2-1),收敛时据实提示不冒充完整
|
||||
round_truncated_by_max_tokens: bool,
|
||||
) -> RoundEpilogue {
|
||||
// 累加本轮 token:用 provider 真实 usage。GLM 等流式不报 completion_tokens 时为 0,如实反映(不预估)。
|
||||
// 分项 token(2026-08-02):cache/reasoning 透传 round。三真相源收口:prompt 估算兜底 +
|
||||
@@ -1773,6 +1871,20 @@ async fn finalize_round_output(
|
||||
);
|
||||
}
|
||||
// 正常收敛(loop 主体置 converged 后 break)
|
||||
// P2-1 主会话侧最小修复:收敛文本若被 max_tokens 腰斩,追加系统提示消息
|
||||
// (对齐 MidStream 保文的系统提示机制),用户/下轮请求能看见截断而非当完整回复。
|
||||
if round_truncated_by_max_tokens {
|
||||
let mut session = session_arc.lock().await;
|
||||
if session.per_conv.contains_key(conv_id) {
|
||||
let conv = session.conv(conv_id);
|
||||
let mut notice = ChatMessage::system(
|
||||
"⚠ 回复因 max_tokens 上限被截断,以上内容可能不完整;可要求模型继续输出。",
|
||||
);
|
||||
notice.model = Some(resolved_model.to_string());
|
||||
conv.messages.push(notice);
|
||||
}
|
||||
tracing::warn!(conv_id = %conv_id, "[ai] 收敛回复因 max_tokens 截断,已追加系统提示");
|
||||
}
|
||||
return RoundEpilogue::Converged;
|
||||
}
|
||||
}
|
||||
@@ -1817,7 +1929,7 @@ async fn process_round_tools(
|
||||
// AC-2 ①:记录本轮工具调用数(供超限警告判定;process_tool_calls 会 move 走 tool_calls_acc)。
|
||||
let round_tool_call_count = tool_calls_acc.len();
|
||||
// 处理工具调用(Low 自动执行 / Medium+High 待审批;主对话恒 Normal 策略,子循环走 SubAgent)
|
||||
let pending_count = process_tool_calls(session_arc, tool_calls_acc, tools_arc, db, app_handle, conv_id, ToolExecPolicy::Normal).await;
|
||||
let pending_count = process_tool_calls(session_arc, tool_calls_acc, tools_arc, db, app_handle, conv_id, ToolExecPolicy::Normal, None).await;
|
||||
// DIRAUTH 审批链已闭环。原 eprintln 诊断降级为 tracing::debug,
|
||||
// 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。
|
||||
tracing::debug!(
|
||||
@@ -1864,6 +1976,13 @@ async fn process_round_tools(
|
||||
return ToolsOutcome::Exit;
|
||||
}
|
||||
emit_circuit_breaker_tripped(app_handle, conv_id, max_count, &sample_key);
|
||||
// 安全停靠(设计 §四):熔断即终局,补发 AiTaskParked(原状只 emit 求助卡,
|
||||
// 托盘场景无独立事件;guard.reset 已在上方完成,现场已 save 于前一轮)。
|
||||
crate::commands::ai::emit_task_parked(
|
||||
app_handle, conv_id,
|
||||
&format!("连续同类失败 {} 次已熔断,任务安全停靠;已完成进度已保存,调整思路后重发消息可继续", max_count),
|
||||
"circuit_breaker",
|
||||
);
|
||||
return ToolsOutcome::Exit;
|
||||
}
|
||||
}
|
||||
@@ -1881,6 +2000,12 @@ async fn process_round_tools(
|
||||
*loop_warned = new_loop_warned;
|
||||
if tripped {
|
||||
guard.reset().await;
|
||||
// 安全停靠(设计 §四):探索熔断从「等用户」改「存档+通知」,语义对齐五路统一出口。
|
||||
crate::commands::ai::emit_task_parked(
|
||||
app_handle, conv_id,
|
||||
"探索熔断(连续无进展的重复探索),任务安全停靠;已完成进度已保存,补充新方向后重发消息可继续",
|
||||
"stall_breaker",
|
||||
);
|
||||
return ToolsOutcome::Exit;
|
||||
}
|
||||
}
|
||||
@@ -2309,13 +2434,47 @@ async fn insert_tool_call_over_limit_warning(
|
||||
.await;
|
||||
}
|
||||
|
||||
// ── classify_permanent_provider_error: 可判定的永久性 provider 错误识别(词法,仅此一处) ──
|
||||
// 命中 → ProviderConfig(前端隐藏「重试」改显「去设置」CTA,重试必再败不再循环);
|
||||
// 未命中维持 Network(现状口径,可重试)。只认高置信特征,宁漏勿误(误判会把可恢复错误
|
||||
// 锁死成「不可重试」)。输入是 fmt_diag 聚合诊断([provider] 场景(status): body),
|
||||
// 关键词对 provider body 原文匹配,大小写不敏感。
|
||||
fn classify_permanent_provider_error(error: &str) -> bool {
|
||||
let lower = error.to_lowercase();
|
||||
// 模型不存在: "model is not found" / "The model `x` does not exist" / 中文「模型不存在」
|
||||
let model_missing = lower.contains("model")
|
||||
&& (lower.contains("not found")
|
||||
|| lower.contains("not_found")
|
||||
|| lower.contains("does not exist")
|
||||
|| lower.contains("不存在"));
|
||||
// 令牌无权访问模型: 「该令牌无权访问模型」/ no permission / access denied
|
||||
let no_access = lower.contains("无权")
|
||||
|| lower.contains("无权限")
|
||||
|| lower.contains("no permission")
|
||||
|| lower.contains("access denied")
|
||||
|| lower.contains("permission denied");
|
||||
// 参数拒绝类 400: sensenova "field Temperature invalid" 实锤 / OpenAI invalid_request_error
|
||||
let param_reject = lower.contains("invalid_request_error")
|
||||
|| ((lower.contains("invalid") || lower.contains("非法") || lower.contains("无效"))
|
||||
&& (lower.contains("temperature")
|
||||
|| lower.contains("parameter")
|
||||
|| lower.contains("field")
|
||||
|| lower.contains("request")));
|
||||
model_missing || no_access || param_reject
|
||||
}
|
||||
|
||||
// ── emit_fatal_error: 致命错误统一 emit AiError(扁平抽自原 Fatal/Exhausted 双分支重复代码) ──
|
||||
// 双写经 emit_ai_chat_event。error_type=Network 对齐原 Fatal 分支(鉴权/4xx归类);
|
||||
// 两端统一 Network(原 publish 端漏传 None 属漂移,2026-10-01 顺带修)。
|
||||
// 双写经 emit_ai_chat_event。error_type: 可判定的永久性 provider 错误(模型不存在/令牌无权/
|
||||
// 参数拒绝类 400)归 ProviderConfig,其余维持 Network 现状(2026-10-01 两端统一时的口径)。
|
||||
pub(crate) async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, error: &str) {
|
||||
let error_type = if classify_permanent_provider_error(error) {
|
||||
ErrorType::ProviderConfig
|
||||
} else {
|
||||
ErrorType::Network
|
||||
};
|
||||
emit_ai_chat_event(app_handle, AiChatEvent::AiError {
|
||||
error: error.to_string(),
|
||||
error_type: Some(ErrorType::Network),
|
||||
error_type: Some(error_type),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
// P1 bg 化:失败系统通知(条件发送,失败静默)。正文截断防长堆栈撑爆系统气泡。
|
||||
@@ -2584,8 +2743,9 @@ async fn build_runtime_prompt(
|
||||
.collect();
|
||||
if !lines.is_empty() {
|
||||
system_prompt = format!(
|
||||
"{}\n\n## 可用技能\n以下为本机 Claude 技能/命令(名称: 描述)。用户请求命中某技能用途时,提示用户输入 /技能名 触发,或引导其按需使用:\n{}",
|
||||
"{}\n\n## 可用技能\n以下为本机 Claude 技能/命令(名称: 描述,已去重,上限 {} 条)。**自动触发规则**:用户请求(含普通任务指令)与某技能用途匹配时,你应先调 read_skill 工具读取该技能规程全文,按其流程直接执行,无需等待用户显式输入 /技能名;仅当技能涉及高风险操作或不确定用户意图时,才提示用户输入 /技能名 确认。未列出的技能也可用 read_skill 按名查询:\n{}",
|
||||
system_prompt,
|
||||
SKILL_LIST_MAX_ITEMS,
|
||||
lines.join("\n")
|
||||
);
|
||||
tracing::debug!(
|
||||
@@ -2974,3 +3134,24 @@ mod panic_guard_tests {
|
||||
assert!(dropped.load(std::sync::atomic::Ordering::SeqCst), "unwind 时 RAII Drop 应执行");
|
||||
}
|
||||
}
|
||||
|
||||
// 跨轮任务级重入(深查 P1-4)退避调度契约:重入循环按 `BACKOFF_SECS[failures - 1]` 取值,
|
||||
// 表长必须覆盖 ROUND_REENTRY_MAX(越界 panic 面)且单调递增(指数退避语义)。
|
||||
#[cfg(test)]
|
||||
mod round_reentry_tests {
|
||||
use super::{ROUND_REENTRY_BACKOFF_SECS, ROUND_REENTRY_MAX};
|
||||
|
||||
#[test]
|
||||
fn backoff_schedule_covers_max_attempts_and_ascending() {
|
||||
assert_eq!(
|
||||
ROUND_REENTRY_BACKOFF_SECS.len(),
|
||||
ROUND_REENTRY_MAX,
|
||||
"退避表长须覆盖重入上限(取值索引 failures-1 不越界)",
|
||||
);
|
||||
for w in ROUND_REENTRY_BACKOFF_SECS.windows(2) {
|
||||
assert!(w[0] < w[1], "退避须单调递增(指数退避语义): {:?}", w);
|
||||
}
|
||||
// 深查处方锚定:1min/5min/15min,覆盖 WiFi 切换/VPN 重连/上游限流窗口量级
|
||||
assert_eq!(ROUND_REENTRY_BACKOFF_SECS, [60, 300, 900]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user