新增: 无人值守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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ mod diff;
|
||||
// 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。
|
||||
// 模块提为 pub(crate):state 层 allowed_dirs 的 is_authorized 第三道门需直接读放行标志,私有模块路径段不可穿越
|
||||
pub(crate) mod path_auth;
|
||||
pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome, PATH_AUTH_BYPASS_KEY};
|
||||
pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome, PathBypass, PATH_AUTH_BYPASS_KEY};
|
||||
// 黑名单放行判据:tool_registry 的 validate_path 无 bypass 参数,经此跨模块读进程级标志
|
||||
pub(super) use path_auth::is_blacklist_bypassed;
|
||||
// 测试专用:改写标志验证 bypass 放行语义 + 串行闸,防依赖标志结果的测试并行互踩
|
||||
@@ -78,7 +78,7 @@ pub(crate) use finalize::audit_finalize;
|
||||
// cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。
|
||||
// 第三批从本文件抽离,行为零变更。
|
||||
mod cache;
|
||||
pub(super) use cache::{cache_hit_warning, detect_listing_bypass, find_cached_readonly_result, insert_listing_bypass_warning, pending_placeholder_for, readonly_cache_args_key};
|
||||
pub(super) use cache::{cache_hit_warning, detect_listing_bypass, find_cached_readonly_result, insert_listing_bypass_warning, mark_readonly_cache_stale, pending_placeholder_for, readonly_cache_args_key};
|
||||
|
||||
// data_change(audit/data_change.rs):AR-11 数据变更联动刷新。
|
||||
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
|
||||
@@ -166,8 +166,69 @@ async fn deny_sub_agent_tool(
|
||||
"rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id).await;
|
||||
}
|
||||
|
||||
/// 任务边界拒绝单点(无人值守,设计 §三):结构化失败 tool_result + emit Completed + 审计留痕。
|
||||
/// 不 insert pending_approvals、不 emit AiApprovalRequired/AiDirAuthRequired——边界会话
|
||||
/// 零新挂起(P1-1 治本面:挂起等输入是无人值守的主要敌人),LLM 从 envelope 的
|
||||
/// denial_layer + 行动提示自纠(换路径/拆低风险步骤/写入报告未尽事项),fail-closed
|
||||
/// 同构子代理拒绝协议。decided_by 与 denial_layer 同值留痕(boundary-dirs/boundary-risk,
|
||||
/// 设计 §3.2:事后全量追溯)。
|
||||
async fn deny_boundary_tool(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
draft: &ToolCallDraft,
|
||||
tools_arc: &Arc<AiToolRegistry>,
|
||||
audit_repo: &AiToolExecutionRepo,
|
||||
layer: &str,
|
||||
reason: &str,
|
||||
current_message_id: Option<&str>,
|
||||
app_handle: &AppHandle,
|
||||
) {
|
||||
// 各拒绝层的可改尝试方向(行动提示,让 LLM 下一步有路可走)
|
||||
let hint = match layer {
|
||||
"boundary-dirs" => "该路径不在本任务目录白名单内,改用白名单内路径完成等价操作,或在报告未尽事项中说明",
|
||||
_ => "该工具风险超出本任务风险上限,拆解为上限内的低风险步骤,或在报告未尽事项中说明由用户后续处理",
|
||||
};
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
let msg = serde_json::json!({
|
||||
"status": "rejected",
|
||||
"reason": "boundary_exceeded",
|
||||
"tool_name": draft.name,
|
||||
"risk_level": risk_level,
|
||||
"denial_layer": layer,
|
||||
"message": format!("任务边界外操作已拒绝(无人值守模式零打断):{}。{}", reason, hint)
|
||||
}).to_string();
|
||||
// 短 lock 段:push tool_result(纯写,无 await)
|
||||
{
|
||||
let __lock_t = std::time::Instant::now();
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg));
|
||||
log_slow_lock(__lock_t, "process_tool_calls:deny_boundary");
|
||||
}
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(msg.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
emit_ai_chat_event(app_handle, ev);
|
||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
"rejected", risk_level, Some(msg), Some(layer), current_message_id).await;
|
||||
}
|
||||
|
||||
/// P2-1 执行期白名单判定(纯函数可单测):SubAgent 策略下工具是否被白名单裁掉。
|
||||
/// spawn 入口的白名单钳制只裁工具定义面(LLM 可见 tools 列表),拦不住幻觉调用面外工具;
|
||||
/// 此判定让裁剪在执行期同样生效。allowed=None(主会话调用方)恒放行,行为与历史版本一致。
|
||||
fn sub_agent_tool_clamped(allowed: Option<&[String]>, tool_name: &str) -> bool {
|
||||
match allowed {
|
||||
None => false,
|
||||
Some(list) => !list.iter().any(|n| n == tool_name),
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
|
||||
/// 返回待审批的工具数量(0 = 全部自动执行完成)
|
||||
///
|
||||
/// `allowed`(P2-1):SubAgent 策略的执行期工具白名单(spawn 入口钳制产物)。
|
||||
/// Low 风险工具不在名单同样就地拒绝(layer=whitelist-clamp);主会话调用方传 None,语义不变。
|
||||
/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs
|
||||
/// stream_llm select! 心跳语义),execute 完 abort 心跳 task。
|
||||
///
|
||||
@@ -481,6 +542,33 @@ async fn cancel_expired_approval(
|
||||
.await;
|
||||
// 5. 落库会话状态变更(占位消息替换后需持久化,防重启后丢失)
|
||||
super::conversation::save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await;
|
||||
// 6. P1-1 修:清 generating 幽灵态。挂起时主 loop 已 return 且 guard.disarm()
|
||||
// (保持 Generating 等审批恢复),超时取消后无人复位 → conv_state 恒 Generating,
|
||||
// 会话永久卡生成态(消息发不进,can_accept_request=false)。迁回 Idle + emit 状态事件,
|
||||
// 写法对齐 ai_chat_stop 审批分支(transition 非法仅 warn 不阻断停靠)。
|
||||
if let Err(e) = state
|
||||
.conv_states
|
||||
.transition(conv_id, crate::commands::ai::agentic::conv_state::ConvState::Idle)
|
||||
{
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
error = %e,
|
||||
"[ai] 审批超时取消 conv_states→Idle 迁移非法(不阻断停靠事件)"
|
||||
);
|
||||
}
|
||||
// 7. 安全停靠(设计 §四):审批超时取消属非正常终局,补发 AiTaskParked + NeedAction
|
||||
// 通知。原状只取消不复跑不留痕——用户(尤其托盘场景)对任务已死毫不知情。
|
||||
// 不自动续跑(设计拍板点 5:恢复须可人工确认);恢复入口=既有重发/ai_chat_force_send。
|
||||
let (park_reason, park_kind) = approval_timeout_park_parts(timeout_minutes);
|
||||
super::emit_task_parked(app, conv_id, &park_reason, park_kind);
|
||||
}
|
||||
|
||||
/// 审批超时停靠文案构造(纯函数,单测锚点;stop_kind 复用 AiTaskParked 词表)。
|
||||
pub(crate) fn approval_timeout_park_parts(timeout_minutes: u64) -> (String, &'static str) {
|
||||
(
|
||||
format!("工具审批超时({}分钟)自动取消,任务已安全停靠:被拒操作已回执给 AI,重发消息或补充指令即可继续", timeout_minutes),
|
||||
"approval_timeout",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn process_tool_calls(
|
||||
@@ -491,6 +579,7 @@ pub(crate) async fn process_tool_calls(
|
||||
app_handle: &AppHandle,
|
||||
conv_id: &str,
|
||||
policy: ToolExecPolicy,
|
||||
allowed: Option<&[String]>,
|
||||
) -> usize {
|
||||
// 策略快照(SubAgent:须人工介入的路径全部就地拒绝,见 ToolExecPolicy 文档)
|
||||
let is_sub_agent = matches!(policy, ToolExecPolicy::SubAgent);
|
||||
@@ -598,6 +687,18 @@ pub(crate) async fn process_tool_calls(
|
||||
.await
|
||||
.unwrap_or_else(|| "low".to_string());
|
||||
|
||||
// ── 无人值守任务边界快照(设计 §三判定链输入)──
|
||||
// per_conv.boundary 即会话级单真相源,None=未声明:下方两处插桩分支全部跳过,
|
||||
// auto_exec_mode/trust/审批/路径授权全链路现状零变更(渐进增强零倒退)。
|
||||
// 边界优先于全局三档:有边界会话上限内零打断,超上限结构化拒绝(不挂起不弹卡)。
|
||||
let boundary: Option<crate::commands::ai::boundary::TaskBoundary> = {
|
||||
let __lock_t = std::time::Instant::now();
|
||||
let session = session_arc.lock().await;
|
||||
let b = session.conv_read(conv_id).and_then(|c| c.boundary.clone());
|
||||
log_slow_lock(__lock_t, "process_tool_calls:boundary_snapshot");
|
||||
b
|
||||
};
|
||||
|
||||
// ── 文件工具路径授权预校验 ──
|
||||
// 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search)
|
||||
// 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单):
|
||||
@@ -623,6 +724,14 @@ pub(crate) async fn process_tool_calls(
|
||||
// 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单;
|
||||
// 每轮工具执行前刷新,与下方 check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。
|
||||
path_auth::set_blacklist_bypass(path_auth_bypass);
|
||||
// P3-4 bypass 按策略分流:主会话=All(体验优先,黑名单一并放行,产品拍板);
|
||||
// SubAgent=KeepBlacklist——bypass 只豁免白名单外挂起,黑名单保持硬拒。子代理无审批
|
||||
// 通道也无人监督,黑名单是安全线,不得被体验开关解除。关=Off 两策略行为一致。
|
||||
let path_bypass = if path_auth_bypass {
|
||||
if is_sub_agent { PathBypass::KeepBlacklist } else { PathBypass::All }
|
||||
} else {
|
||||
PathBypass::Off
|
||||
};
|
||||
// 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
|
||||
let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new();
|
||||
let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
|
||||
@@ -630,11 +739,13 @@ pub(crate) async fn process_tool_calls(
|
||||
// 第三元=denial_layer:needs-auth(待授权)与 path-blacklist(系统黑名单)分开标注——
|
||||
// 黑名单主会话也放不了行,混标会误导子 LLM 走授权通道。
|
||||
let mut sub_agent_path_denied: Vec<(ToolCallDraft, String, &'static str)> = Vec::new();
|
||||
// 任务边界会话:白名单外待授权目录的拒绝收集(结构化拒绝,不挂起,见下方处理)
|
||||
let mut boundary_dir_denied: Vec<(ToolCallDraft, String)> = Vec::new();
|
||||
let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new();
|
||||
for (idx, draft, args) in drafts {
|
||||
// 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空),
|
||||
// handler 闭包 read lock 与此处预校验读同一字段,两端授权判定一致。
|
||||
match check_file_tool_auth(&draft.name, &args, &allowed_guard, path_auth_bypass) {
|
||||
match check_file_tool_auth(&draft.name, &args, &allowed_guard, path_bypass) {
|
||||
FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)),
|
||||
FileToolAuthOutcome::NeedsAuth(req) => {
|
||||
// SubAgent 策略:子代理无授权弹窗通道,挂起即永久停摆 → 就地拒绝。
|
||||
@@ -642,6 +753,21 @@ pub(crate) async fn process_tool_calls(
|
||||
sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string(), "needs-auth"));
|
||||
continue;
|
||||
}
|
||||
// 任务边界优先(无人值守,设计 §3.1②):req.dirs 是三档白名单外的待授权
|
||||
// 父目录集合,边界白名单全覆盖 → 直接放行(免弹 AiDirAuthRequired);
|
||||
// 未全覆盖 → 结构化拒绝(boundary-dirs),不挂起——边界会话零新挂起。
|
||||
if let Some(b) = &boundary {
|
||||
if crate::commands::ai::boundary::covers_dirs(b, &req.dirs) {
|
||||
authorized_drafts.push((idx, draft, args));
|
||||
} else {
|
||||
let uncovered: Vec<String> = req.dirs.iter()
|
||||
.filter(|d| !crate::commands::ai::boundary::dir_covered(&b.dir_allowlist, d))
|
||||
.map(|d| d.to_string_lossy().to_string())
|
||||
.collect();
|
||||
boundary_dir_denied.push((draft, format!("待授权目录 {} 不在任务白名单内", uncovered.join(", "))));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。
|
||||
// 黑名单 Denied 不受影响——系统敏感目录是绝对安全线,全接管也不放行。
|
||||
// 放行的 draft 走 authorized_drafts → RiskLevel 分类 → all 放行 → 审计留痕闭环。
|
||||
@@ -702,6 +828,15 @@ pub(crate) async fn process_tool_calls(
|
||||
).await;
|
||||
}
|
||||
|
||||
// 任务边界会话:白名单外路径结构化拒绝(boundary-dirs),零挂起零弹卡。
|
||||
// LLM 拿 envelope 换路径或写入报告未尽事项;审计 decided_by=boundary-dirs 留痕。
|
||||
for (draft, reason) in boundary_dir_denied {
|
||||
deny_boundary_tool(
|
||||
session_arc, conv_id, &draft, tools_arc, &audit_repo,
|
||||
"boundary-dirs", &reason, current_message_id, app_handle,
|
||||
).await;
|
||||
}
|
||||
|
||||
// Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。
|
||||
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
|
||||
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。
|
||||
@@ -811,6 +946,12 @@ pub(crate) async fn process_tool_calls(
|
||||
// trust_hits 收集:AE-04 会话信任命中(同会话已批准同类操作),真实执行但移到锁外 spawn
|
||||
// (对齐 Low risk 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。
|
||||
let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new();
|
||||
// boundary_auto 收集(无人值守,设计 §3.1③):边界会话上限内非破坏性工具,归入独立
|
||||
// 并行执行向量(join_all,与 trust_hits 同款执行+审计链)。不并入 low_risk 向量的原因:
|
||||
// low_risk 回填的 decided_by 按风险等级映射(auto/auto_takeover_*),边界放行的来源留痕
|
||||
// 须为 boundary_auto(审计可追溯授权来自任务边界而非全局档),且不动共享路径签名
|
||||
// (无边界会话零倒退优先)。
|
||||
let mut boundary_auto: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::new();
|
||||
for (_, draft, args) in drafts {
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
// SubAgent 策略:Medium/High 就地拒绝(无审批通道,挂起即子循环永久停摆)。
|
||||
@@ -825,6 +966,39 @@ pub(crate) async fn process_tool_calls(
|
||||
).await;
|
||||
continue;
|
||||
}
|
||||
// P2-1 执行期白名单:Low 风险工具不在 spawn 白名单同样就地拒绝(定义面裁剪
|
||||
// 拦不住幻觉调用)。layer 恒 whitelist-clamp——是名单问题非风险问题,行动提示
|
||||
// 指向改用名单内工具。主会话 allowed=None 判定恒 false,零行为变更。
|
||||
if is_sub_agent && sub_agent_tool_clamped(allowed, &draft.name) {
|
||||
deny_sub_agent_tool(
|
||||
session_arc, conv_id, &draft, tools_arc, &audit_repo,
|
||||
"whitelist-clamp", "工具不在 spawn 白名单内", current_message_id, app_handle,
|
||||
).await;
|
||||
continue;
|
||||
}
|
||||
// 任务边界判定(无人值守,设计 §3.1③④;SubAgent 策略不受影响维持 fail-closed:
|
||||
// 子代理 per_conv 新建恒无边界,此处再 gate 一道,防误设边界放宽子代理——拍板点 6):
|
||||
// 上限内 ∧ 非破坏性命令 → boundary_auto 并行执行向量(零审批零挂起);
|
||||
// 超上限(含 High、破坏性命令)→ 结构化拒绝(boundary-risk),不 insert pending。
|
||||
// 黑名单恒拒不在此层——Denied 分支在 path_auth 预校验先行硬拒,边界永不参与。
|
||||
if !is_sub_agent {
|
||||
if let Some(b) = &boundary {
|
||||
if crate::commands::ai::boundary::risk_allowed(b, &draft.name, &args, risk_level) {
|
||||
boundary_auto.push((draft, args, risk_level));
|
||||
} else {
|
||||
let reason = if risk_level == RiskLevel::High {
|
||||
"工具风险 High 超出任务风险上限".to_string()
|
||||
} else {
|
||||
"工具风险超出任务风险上限".to_string()
|
||||
};
|
||||
deny_boundary_tool(
|
||||
session_arc, conv_id, &draft, tools_arc, &audit_repo,
|
||||
"boundary-risk", &reason, current_message_id, app_handle,
|
||||
).await;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。
|
||||
// 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录,
|
||||
// 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常
|
||||
@@ -893,6 +1067,7 @@ pub(crate) async fn process_tool_calls(
|
||||
}
|
||||
})).await;
|
||||
|
||||
|
||||
// 串行回填 tool_result + 审计(短 lock push + 锁外 audit)
|
||||
for (draft, risk_level, outcome) in results {
|
||||
let (status, content) = match outcome {
|
||||
@@ -914,6 +1089,12 @@ pub(crate) async fn process_tool_calls(
|
||||
content.clone()
|
||||
};
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg_content));
|
||||
// 写后失效:成功完成的非只读工具改变了世界状态,推进缓存失效下界
|
||||
// (floor 之前的历史只读结果不可信,find_cached_readonly_result 过滤)。仅
|
||||
// completed 才失效——failed 未改世界(命令失败即无副作用面),不误伤。
|
||||
if status == "completed" {
|
||||
mark_readonly_cache_stale(&mut session.conv(conv_id), &draft.name);
|
||||
}
|
||||
}
|
||||
// 锁外:审计(trust 放行仍记一条,decided_by=auto_trust),留痕可追溯
|
||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust"), current_message_id).await;
|
||||
@@ -1067,6 +1248,12 @@ pub(crate) async fn process_tool_calls(
|
||||
content.clone()
|
||||
};
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg_content));
|
||||
// 写后失效:成功完成的非只读工具改变了世界状态,推进缓存失效下界
|
||||
// (floor 之前的历史只读结果不可信,find_cached_readonly_result 过滤)。仅
|
||||
// completed 才失效——failed 未改世界(命令失败即无副作用面),不误伤。
|
||||
if status == "completed" {
|
||||
mark_readonly_cache_stale(&mut session.conv(conv_id), &draft.name);
|
||||
}
|
||||
}
|
||||
// F-#97 审计留痕:low_risk 向量中 risk_level != Low 即 mode 放行(Med 仅 medium/all、High 仅 all 才进)。
|
||||
// decided_by 区分接管来源,审计表可追溯 all 模式执行了多少高危命令(治 securityReview blocker:
|
||||
@@ -1135,32 +1322,98 @@ pub(crate) async fn process_tool_calls(
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &warned_content));
|
||||
}
|
||||
dupe_audit_records.push(build_audit_record(
|
||||
conv_id, &draft.id, &draft.name, &draft.args, "completed", risk_level,
|
||||
Some(warned_content), Some("cache_hit"), current_message_id,
|
||||
conv_id, &draft.id, &draft.name, &draft.args,
|
||||
"completed", risk_level, Some(warned_content), Some("cache_hit"),
|
||||
current_message_id,
|
||||
));
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
tool = %draft.name,
|
||||
tc_id = %draft.id,
|
||||
hit_count = new_count,
|
||||
"[ai] 只读工具轮内去重命中(第 {} 次同参,本轮首个已执行),跳过重复真执行",
|
||||
new_count
|
||||
);
|
||||
}
|
||||
if !dupe_audit_records.is_empty() {
|
||||
let dup_len = dupe_audit_records.len();
|
||||
let batch_len = dupe_audit_records.len();
|
||||
if let Err(e) = audit_repo.insert_batch(dupe_audit_records).await {
|
||||
tracing::error!(
|
||||
conv_id = %conv_id,
|
||||
batch = dup_len,
|
||||
"[ai] insert_batch 轮内去重审计批量写失败(已回滚,共 {} 条): {}",
|
||||
dup_len,
|
||||
batch = batch_len,
|
||||
"[ai] insert_batch(轮内重复)批量写审计记录失败(已回滚,共 {} 条): {}",
|
||||
batch_len,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// boundary_auto 并行执行(无人值守,设计 §3.1③):与 low_risk 同构——execute + emit 在
|
||||
// 闭包内(锁外),push/audit 串行回填;区别仅审计 decided_by=boundary_auto
|
||||
// (授权来自任务边界,事后全量可追溯),emit_data_changed 同步联动(数据变更类)。
|
||||
// execute/emit 全在闭包内不持锁,push tool_result / audit 在 join_all 后串行回填。
|
||||
if !boundary_auto.is_empty() {
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
count = boundary_auto.len(),
|
||||
"[ai] 任务边界放行 {} 个工具进入并行执行(decided_by=boundary_auto)",
|
||||
boundary_auto.len()
|
||||
);
|
||||
let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>)> =
|
||||
futures::future::join_all(boundary_auto.into_iter().map(|(draft, args, risk_level)| {
|
||||
let tools = tools_arc.clone();
|
||||
let app_clone = app_handle.clone();
|
||||
let conv_clone = conv_id.to_string();
|
||||
async move {
|
||||
match execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await {
|
||||
Ok(val) => {
|
||||
// L3 emit 双写:边界放行工具执行成功 emit Completed 双路发布。
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: val.clone(),
|
||||
conversation_id: Some(conv_clone),
|
||||
};
|
||||
emit_ai_chat_event(&app_clone, ev);
|
||||
emit_data_changed(&app_clone, &draft.name);
|
||||
(draft, risk_level, Ok(val.to_string()))
|
||||
}
|
||||
Err(e) => {
|
||||
// AR-6 同款:失败包进 tool_result 让 LLM 自纠,不 emit AiError 不断轮。
|
||||
let err_msg = format!("工具 {} 执行失败: {}", draft.name, e);
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(err_msg.clone()),
|
||||
conversation_id: Some(conv_clone),
|
||||
};
|
||||
emit_ai_chat_event(&app_clone, ev);
|
||||
(draft, risk_level, Err(err_msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
})).await;
|
||||
|
||||
for (draft, risk_level, outcome) in results {
|
||||
let (status, content) = match outcome {
|
||||
Ok(c) => ("completed", c),
|
||||
Err(c) => ("failed", c),
|
||||
};
|
||||
// 短 lock 段:namespace T2 + push tool_result(纯写,无 await)
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
let msg_content = if status == "failed" {
|
||||
serde_json::json!({"status":"failed","error":&content}).to_string()
|
||||
} else if df_ai::namespace_store::should_use_namespace(&content, &draft.name) {
|
||||
session.namespace_store.store(&draft.name, &content)
|
||||
} else {
|
||||
content.clone()
|
||||
};
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg_content));
|
||||
// 写后失效:边界放行的非只读工具成功同 Low 段推进 floor(口径一致)。
|
||||
if status == "completed" {
|
||||
mark_readonly_cache_stale(&mut session.conv(conv_id), &draft.name);
|
||||
}
|
||||
}
|
||||
// 锁外审计:risk 真实等级透传(F-#97 防失真),decided_by=boundary_auto
|
||||
// 标注授权来自任务边界(无人值守事后可全量追溯)。
|
||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
status, risk_level, Some(content), Some("boundary_auto"), current_message_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
|
||||
// 短 lock 段:读 pending_approvals 统计(纯读,无 await)
|
||||
let (path_count, risk_count, pending_approvals_len) = {
|
||||
@@ -1195,7 +1448,7 @@ mod tests {
|
||||
let allowed = crate::state::AllowedDirs::default();
|
||||
let args = serde_json::json!({ "pattern": "foo", "path": "C:/some/unauthorized/dir" });
|
||||
|
||||
let outcome = check_file_tool_auth("grep", &args, &allowed, false);
|
||||
let outcome = check_file_tool_auth("grep", &args, &allowed, PathBypass::Off);
|
||||
match outcome {
|
||||
FileToolAuthOutcome::NeedsAuth(_) => { /* 期望:grep 触发授权申请 */ }
|
||||
FileToolAuthOutcome::Authorized => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Authorized"),
|
||||
@@ -1203,7 +1456,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// 对照:search_files 同样未授权但被硬拒(Denied),锁定两工具差异
|
||||
let search_outcome = check_file_tool_auth("search_files", &args, &allowed, false);
|
||||
let search_outcome = check_file_tool_auth("search_files", &args, &allowed, PathBypass::Off);
|
||||
match search_outcome {
|
||||
FileToolAuthOutcome::Denied(_) => { /* 期望:search_files 盲拒 */ }
|
||||
FileToolAuthOutcome::NeedsAuth(_) => panic!("search_files 未授权应返 Denied(盲拒),实际 NeedsAuth"),
|
||||
@@ -1219,7 +1472,7 @@ mod tests {
|
||||
let allowed = crate::state::AllowedDirs::default_with_root();
|
||||
// .ssh 命中系统黑名单(is_in_system_blacklist)
|
||||
let args = serde_json::json!({ "pattern": "foo", "path": "/home/user/.ssh/config" });
|
||||
let outcome = check_file_tool_auth("grep", &args, &allowed, false);
|
||||
let outcome = check_file_tool_auth("grep", &args, &allowed, PathBypass::Off);
|
||||
match outcome {
|
||||
FileToolAuthOutcome::Denied(_) => { /* 期望:黑名单硬拒 */ }
|
||||
FileToolAuthOutcome::NeedsAuth(_) => panic!("grep 黑名单路径应返 Denied,实际 NeedsAuth"),
|
||||
@@ -1234,4 +1487,39 @@ mod tests {
|
||||
let paths = extract_file_tool_paths("grep", &args);
|
||||
assert_eq!(paths, vec!["src/main.rs".to_string()], "grep 应取 path 参数(忽略 glob 等其他参数)");
|
||||
}
|
||||
|
||||
/// P2-1 执行期白名单判定真值表:SubAgent 名单外工具(含已注册 Low)被裁,名单内放行,
|
||||
/// 主会话 None 恒放行(历史行为不变)。与 spawn 入口 clamp_tool_whitelist 组成两道闸;
|
||||
/// 判定命中后的拒绝落地走既有 deny_sub_agent_tool 单点(layer=whitelist-clamp,
|
||||
/// envelope 结构与被拒收集由 sub_agent.rs 测试锁定),此处锁判定本身。
|
||||
#[test]
|
||||
fn sub_agent_tool_clamped_truth_table() {
|
||||
let allowed = vec!["read_file".to_string(), "grep".to_string()];
|
||||
assert!(!sub_agent_tool_clamped(Some(&allowed), "read_file"), "名单内放行");
|
||||
assert!(
|
||||
sub_agent_tool_clamped(Some(&allowed), "list_tasks"),
|
||||
"名单外已注册 Low 工具同样被裁(定义面裁剪拦不住的幻觉调用残面)"
|
||||
);
|
||||
assert!(
|
||||
sub_agent_tool_clamped(Some(&allowed), "spawn_agent"),
|
||||
"spawn_agent 恒被入口钳制剔除,执行期也不得放行(禁递归)"
|
||||
);
|
||||
assert!(!sub_agent_tool_clamped(None, "run_command"), "主会话 None 恒放行");
|
||||
assert!(
|
||||
sub_agent_tool_clamped(Some(&[]), "read_file"),
|
||||
"空白名单全拒(白名单为空的子代理无可执行工具)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 审批超时停靠文案(stop_kind=approval_timeout):锁定 P1-1 修复的停靠出口字段,
|
||||
/// cancel_expired_approval 到点路径消费(发 AiTaskParked + NeedAction 通知)。
|
||||
#[test]
|
||||
fn approval_timeout_park_parts_contract() {
|
||||
let (reason, kind) = approval_timeout_park_parts(15);
|
||||
assert_eq!(kind, "approval_timeout", "stop_kind 词表(前端契约)");
|
||||
assert!(reason.contains("15"), "文案携带超时分钟数");
|
||||
assert!(reason.contains("停靠"), "文案含停靠语义(待办清单=stop_reason)");
|
||||
let (reason_0, _) = approval_timeout_park_parts(0);
|
||||
assert!(reason_0.contains("0"), "任意分钟数可格式化(0=禁用超时不会到点,防御)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,22 +59,38 @@ pub(crate) enum FileToolAuthOutcome {
|
||||
Denied(String),
|
||||
}
|
||||
|
||||
/// 「信任所有目录」总开关的放行面(P3-4 按策略分流)。
|
||||
///
|
||||
/// - `Off`:开关关闭(默认),白名单外挂起授权 + 黑名单硬拒,行为与历史版本一致;
|
||||
/// - `All`:主会话全放行(体验优先,黑名单一并放行,产品拍板);
|
||||
/// - `KeepBlacklist`:子代理策略——bypass 只豁免白名单外挂起(含 search_files 盲拒),
|
||||
/// 黑名单保持硬拒。子代理无审批通道也无人监督,黑名单是安全线,不可被体验开关解除。
|
||||
/// Copy:每条工具调用循环内按值传入 check_file_tool_auth,单元枚举按位复制免手动 clone。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum PathBypass {
|
||||
Off,
|
||||
All,
|
||||
KeepBlacklist,
|
||||
}
|
||||
|
||||
/// 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。
|
||||
///
|
||||
/// - 路径任一命中黑名单且 bypass 关:`Denied(reason)` 硬拒(工具返 Err tool_result,不挂起)
|
||||
/// - 路径任一命中黑名单且 bypass 非 `All`:`Denied(reason)` 硬拒(工具返 Err tool_result,不挂起)
|
||||
/// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`:
|
||||
/// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗
|
||||
/// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程
|
||||
/// - `bypass=true`(「信任所有目录」总开关):未授权目录不再产生 NeedsAuth,黑名单 Denied
|
||||
/// 也一并放行(产品拍板:开关语义为完全放行,含敏感目录,体验优先,search_files 盲拒
|
||||
/// 随 NeedsAuth 消失一并解除)。bypass 由调用方 process_tool_calls 读 KV 后传入;
|
||||
/// validate_path 无此参数才走全局标志 PATH_AUTH_BYPASS_ACTIVE(与入参同源等价,
|
||||
/// audit 层每轮刷新保证两处拦截点一致)。本函数同步签名,不自取异步 settings。
|
||||
/// - `bypass=All`(「信任所有目录」总开关,主会话):未授权目录不再产生 NeedsAuth,黑名单
|
||||
/// Denied 也一并放行(产品拍板:开关语义为完全放行,含敏感目录,体验优先,search_files 盲拒
|
||||
/// 随 NeedsAuth 消失一并解除)。`bypass=KeepBlacklist`(子代理):只豁免 NeedsAuth,
|
||||
/// 黑名单硬拒保持。bypass 由调用方 process_tool_calls 读 KV 后按策略分流传入;
|
||||
/// validate_path 无此参数才走全局标志 PATH_AUTH_BYPASS_ACTIVE(主会话语义,与 All 同源等价,
|
||||
/// audit 层每轮刷新保证两处拦截点一致;子代理文件工具在预校验层已硬拒,handler 级标志不生效)。
|
||||
/// 本函数同步签名,不自取异步 settings。
|
||||
pub(crate) fn check_file_tool_auth(
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
allowed: &crate::state::AllowedDirs,
|
||||
bypass: bool,
|
||||
bypass: PathBypass,
|
||||
) -> FileToolAuthOutcome {
|
||||
use crate::state::{check_path_authorization, PathAuthDecision};
|
||||
let paths = extract_file_tool_paths(tool_name, args);
|
||||
@@ -88,16 +104,17 @@ pub(crate) fn check_file_tool_auth(
|
||||
for p in &paths {
|
||||
match check_path_authorization(p, allowed) {
|
||||
PathAuthDecision::Denied { reason } => {
|
||||
// 总开关开启时黑名单一并放行(产品拍板:体验优先,用户知情选择);关闭时硬拒不变。
|
||||
// 总开关=All 时黑名单一并放行(产品拍板:体验优先,用户知情选择,仅主会话);
|
||||
// 关闭与 KeepBlacklist(子代理)时硬拒不变——黑名单是安全线。
|
||||
// 此处用入参 bypass;validate_path 无参才走全局标志,两者同源等价(audit 每轮刷同一 KV)。
|
||||
if bypass {
|
||||
if matches!(bypass, PathBypass::All) {
|
||||
continue;
|
||||
}
|
||||
return FileToolAuthOutcome::Denied(reason);
|
||||
}
|
||||
PathAuthDecision::NeedsAuthorization { dir } => {
|
||||
// 总开关开启:未授权目录直接放行(黑名单的放行在上一分支处理)
|
||||
if bypass {
|
||||
// 总开关开启(All 或 KeepBlacklist):未授权目录直接放行(黑名单的放行仅在 All 分支)
|
||||
if !matches!(bypass, PathBypass::Off) {
|
||||
continue;
|
||||
}
|
||||
if !pending_dirs.contains(&dir) {
|
||||
@@ -136,17 +153,43 @@ mod tests {
|
||||
let allowed = crate::state::AllowedDirs::default();
|
||||
let args = serde_json::json!({ "path": "C:/some/unauthorized/dir" });
|
||||
// 关(基线,行为不变):NeedsAuth
|
||||
match check_file_tool_auth("read_file", &args, &allowed, false) {
|
||||
match check_file_tool_auth("read_file", &args, &allowed, PathBypass::Off) {
|
||||
FileToolAuthOutcome::NeedsAuth(_) => {}
|
||||
_ => panic!("bypass 关时未授权路径应仍返 NeedsAuth"),
|
||||
}
|
||||
// 开:直接 Authorized,不挂起
|
||||
match check_file_tool_auth("read_file", &args, &allowed, true) {
|
||||
match check_file_tool_auth("read_file", &args, &allowed, PathBypass::All) {
|
||||
FileToolAuthOutcome::Authorized => {}
|
||||
_ => panic!("bypass 开时未授权路径应直接 Authorized"),
|
||||
}
|
||||
}
|
||||
|
||||
/// P3-4 SubAgent 分流:KeepBlacklist 只豁免白名单外挂起(不再弹授权),黑名单保持硬拒
|
||||
/// ——子代理无审批通道也无人监督,黑名单安全线不得被体验开关解除。
|
||||
#[test]
|
||||
fn test_bypass_keep_blacklist_passes_needs_auth_but_denies_blacklist() {
|
||||
let _guard = BLACKLIST_BYPASS_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let allowed = crate::state::AllowedDirs::default_with_root();
|
||||
// 白名单外普通路径:KeepBlacklist 直接放行(不挂起授权)
|
||||
let args = serde_json::json!({ "path": "C:/some/unauthorized/dir" });
|
||||
assert!(
|
||||
matches!(
|
||||
check_file_tool_auth("read_file", &args, &allowed, PathBypass::KeepBlacklist),
|
||||
FileToolAuthOutcome::Authorized
|
||||
),
|
||||
"KeepBlacklist 应豁免白名单外挂起"
|
||||
);
|
||||
// 黑名单路径:仍硬拒(与 All 的语义分界)
|
||||
let secret = serde_json::json!({ "path": "/home/user/.ssh/config" });
|
||||
assert!(
|
||||
matches!(
|
||||
check_file_tool_auth("read_file", &secret, &allowed, PathBypass::KeepBlacklist),
|
||||
FileToolAuthOutcome::Denied(_)
|
||||
),
|
||||
"KeepBlacklist 下黑名单应保持硬拒"
|
||||
);
|
||||
}
|
||||
|
||||
/// bypass 开:黑名单也放行(语义反转:总开关=完全放行,含敏感目录)。
|
||||
/// 进程级标志须持串行闸并在断言前复位(先取结果再复位再断言,防 panic 泄漏标志污染并行测试)。
|
||||
#[test]
|
||||
@@ -157,7 +200,7 @@ mod tests {
|
||||
// .ssh 命中系统黑名单(is_in_system_blacklist)
|
||||
let args = serde_json::json!({ "path": "/home/user/.ssh/config" });
|
||||
let outcome_allowed = matches!(
|
||||
check_file_tool_auth("read_file", &args, &allowed, true),
|
||||
check_file_tool_auth("read_file", &args, &allowed, PathBypass::All),
|
||||
FileToolAuthOutcome::Authorized
|
||||
);
|
||||
set_blacklist_bypass(false);
|
||||
@@ -171,7 +214,7 @@ mod tests {
|
||||
let _guard = BLACKLIST_BYPASS_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let allowed = crate::state::AllowedDirs::default_with_root();
|
||||
let args = serde_json::json!({ "path": "/home/user/.ssh/config" });
|
||||
match check_file_tool_auth("read_file", &args, &allowed, false) {
|
||||
match check_file_tool_auth("read_file", &args, &allowed, PathBypass::Off) {
|
||||
FileToolAuthOutcome::Denied(_) => {}
|
||||
_ => panic!("bypass 关时黑名单路径应 Denied(行为不变)"),
|
||||
}
|
||||
|
||||
@@ -296,15 +296,23 @@ fn match_task_audit_row(tool_name: &str, result: Option<&str>, task_id: &str) ->
|
||||
/// 故走专用 SELECT(与 state.rs 清理孤儿 pending 消息同款 app 层直查先例);SQL 里的工具名单
|
||||
/// 是 TASK_CONV_TOOLS 的硬拷贝,单测覆盖三个名字防漂移。
|
||||
///
|
||||
/// P2-3 task_id 预筛下沉 SQL:`result LIKE '%"id":"<task_id>"%'` 只作预筛(缩小扫描窗口,
|
||||
/// 参数化绑定防注入),精确判等仍在 Rust 侧 match_task_audit_row(解析 JSON 取顶层 id 字段
|
||||
/// 判等,「禁裸 LIKE 判等」不破)。预筛只会多放进相关行(嵌套 id/巧合同形文本),由 Rust
|
||||
/// 侧剔除;不会漏放——audit result 均由 serde_json 紧凑序列化落库(无空格),顶层 id 字段
|
||||
/// 必然以 `"id":"<id>"` 形态出现。若无预筛,LIMIT 窗口取的是全局最近 1000 行,高流量下
|
||||
/// 目标任务的关联行会被无关行挤出窗外(关联静默消失);预筛后窗口=含该任务 id 形文本的
|
||||
/// 最近 1000 行,早期关联仍可命中(有单测锁定)。
|
||||
///
|
||||
/// 过滤:① 命中行按会话去重(行按 requested_at 倒序,首见=最近一次);② 子代理会话
|
||||
/// (origin='sub_agent',V44)剔除,v1 只列主会话;③ 会话已不在库的 id 一并剔除
|
||||
/// (switchConversation 对不存在 id 会走"新建兜底",跳过去是空会话,不如不列)。
|
||||
/// LIMIT 1000 扫描上界:抽屉每次打开都查,封顶防超大审计表全表拖慢;取最近 1000 行,
|
||||
/// 更早的关联对"跳回最近上下文"场景无意义。
|
||||
/// LIMIT 1000 扫描上界:抽屉每次打开都查,封顶防超大审计表全表拖慢。
|
||||
pub(crate) async fn find_task_conversations(
|
||||
db: &Database,
|
||||
task_id: &str,
|
||||
) -> Result<Vec<TaskConversationRef>, String> {
|
||||
let pattern = format!("%\"id\":\"{}\"%", task_id);
|
||||
let conn = db.conn();
|
||||
let rows: Vec<(String, String, Option<String>, String)> =
|
||||
tokio::task::spawn_blocking(move || -> Result<Vec<(String, String, Option<String>, String)>, String> {
|
||||
@@ -315,11 +323,12 @@ pub(crate) async fn find_task_conversations(
|
||||
"SELECT conversation_id, tool_name, result, requested_at FROM ai_tool_executions \
|
||||
WHERE tool_name IN ('create_task','update_task','advance_task') \
|
||||
AND conversation_id IS NOT NULL \
|
||||
AND result LIKE ?1 \
|
||||
ORDER BY CAST(requested_at AS INTEGER) DESC LIMIT 1000",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mapped = stmt
|
||||
.query_map([], |row| {
|
||||
.query_map([&pattern], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
@@ -590,6 +599,7 @@ mod tests {
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
origin: origin.map(|s| s.to_string()),
|
||||
boundary_json: None,
|
||||
})
|
||||
.await
|
||||
.expect("测试会话插入应成功");
|
||||
@@ -637,6 +647,36 @@ mod tests {
|
||||
assert_eq!(out[0].requested_at, (t - 10).to_string());
|
||||
}
|
||||
|
||||
/// P2-3 窗口外早期关联仍可命中:目标任务行之后灌 1000+ 条更新的无关任务工具行,
|
||||
/// 旧的「全局最近 1000 条」窗口会把早期关联挤出(静默消失);task_id 预筛下沉后,
|
||||
/// 窗口按预筛结果截取,早期关联仍须命中(精确判等仍在 Rust 侧,误命中不发生)。
|
||||
#[tokio::test]
|
||||
async fn find_task_conversations_early_hit_survives_window_flood() {
|
||||
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||||
let repo = AiToolExecutionRepo::new(&db);
|
||||
let t_early = 1_000_000_000_000i64;
|
||||
|
||||
insert_conversation(&db, "conv-early", Some("早期会话"), None).await;
|
||||
insert_conv_audit(&repo, "conv-early", "create_task",
|
||||
Some(r#"{"id":"t-early","title":"E"}"#.into()), t_early).await;
|
||||
// 灌 1100 条更新的无关任务工具行(其他任务 id),把全表推过 1000 行窗口
|
||||
let t_flood = t_early + 1_000_000;
|
||||
for i in 0..1100i64 {
|
||||
insert_conv_audit(&repo, "conv-noise", "update_task",
|
||||
Some(format!(r#"{{"id":"noise-{}"}}"#, i)), t_flood + i).await;
|
||||
}
|
||||
|
||||
let out = find_task_conversations(&db, "t-early").await.unwrap();
|
||||
assert_eq!(out.len(), 1, "早期关联不得被 1000 行窗口挤出");
|
||||
assert_eq!(out[0].conversation_id, "conv-early");
|
||||
assert_eq!(out[0].title.as_deref(), Some("早期会话"));
|
||||
// 预筛不放无关行进来:noise 任务查不到任何关联
|
||||
assert!(
|
||||
find_task_conversations(&db, "noise-1").await.unwrap().is_empty(),
|
||||
"noise 会话不在库,即使 id 前缀巧合也不得列出"
|
||||
);
|
||||
}
|
||||
|
||||
/// S4 纯函数判定表:名单外工具 / 非 JSON / 缺 id / id 不等 均不命中,精确判等才命中。
|
||||
#[test]
|
||||
fn match_task_audit_row_truth_table() {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
//! 无人值守长任务 — 任务级授权边界(TaskBoundary)
|
||||
//!
|
||||
//! 设计: docs/02-架构设计/专项设计/无人值守长任务-设计-2026-10-04.md §二/§三。
|
||||
//! 拍板点 1: risk_ceiling 封顶 Medium,High 归 L3 完全信任档不经此通道。
|
||||
//! 生命周期 = 会话存续: 发起时一次声明,随会话同亡(落库 ai_conversations.boundary_json V45)。
|
||||
//! 无边界会话 = 现状逐次审批零倒退;子代理策略(SubAgent)不受影响维持 fail-closed(拍板点 6)。
|
||||
//!
|
||||
//! 判定函数全部纯函数(无 session/AppHandle 依赖),单测锁定真值表;
|
||||
//! 插桩点在 audit/process_tool_calls(会话边界快照 → path_auth NeedsAuth 分支 +
|
||||
//! 风险分类循环两处消费)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use df_ai::ai_tools::RiskLevel;
|
||||
|
||||
use crate::commands::ai::is_destructive_command;
|
||||
|
||||
/// 任务预算(复用子代理三熔断量纲: 轮次/token/挂钟)。None=不限(继承现网常量)。
|
||||
///
|
||||
/// P0 仅随行落库 + IPC 读写,主 loop 预算闸待后续批接线(设计拍板点 8: 预算可中途调)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TaskBudget {
|
||||
pub max_rounds: u32,
|
||||
pub max_tokens: u64,
|
||||
pub wall_secs: u64,
|
||||
}
|
||||
|
||||
fn default_risk_ceiling() -> RiskLevel {
|
||||
RiskLevel::Medium
|
||||
}
|
||||
|
||||
fn default_forbid_destructive() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 任务级授权边界(挂会话: per_conv.boundary 内存真相源 + boundary_json 落库随行)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TaskBoundary {
|
||||
/// 风险上限(Low|Medium)。set 入口经 [`TaskBoundary::normalized`] 把 High 钳到 Medium
|
||||
/// (拍板点 1: 无人看命令串的 High 风险不可控,确需 High 归 L3 完全信任档)。
|
||||
#[serde(default = "default_risk_ceiling")]
|
||||
pub risk_ceiling: RiskLevel,
|
||||
/// 任务目录白名单(目录级 starts_with 覆盖;判定时双侧 best-effort canonicalize 对齐形态,
|
||||
/// 与 AllowedDirs::is_authorized 同款归一口径)。存原始字符串: 白名单目录可能尚不存在
|
||||
/// (write_file 新建),规范化失败回退词法形态尽力匹配。
|
||||
#[serde(default)]
|
||||
pub dir_allowlist: Vec<String>,
|
||||
/// 破坏性命令禁令(恒 true,字段留作显式声明供 UI 展示)。
|
||||
/// 判定**不读本字段**——直接调 is_destructive_command,防手改 JSON 置 false 绕过
|
||||
/// (安全判定不信任输入)。
|
||||
#[serde(default = "default_forbid_destructive")]
|
||||
pub forbid_destructive: bool,
|
||||
/// 任务预算,None=不限。
|
||||
#[serde(default)]
|
||||
pub budget: Option<TaskBudget>,
|
||||
}
|
||||
|
||||
impl TaskBoundary {
|
||||
/// set 入口规范化: risk_ceiling=High 钳到 Medium(拍板点 1,边界声明不开放 High 档)。
|
||||
pub fn normalized(mut self) -> Self {
|
||||
if self.risk_ceiling == RiskLevel::High {
|
||||
tracing::warn!("[boundary] risk_ceiling=High 不开放(拍板点 1),已钳到 Medium");
|
||||
self.risk_ceiling = RiskLevel::Medium;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn risk_rank(r: RiskLevel) -> u8 {
|
||||
match r {
|
||||
RiskLevel::Low => 0,
|
||||
RiskLevel::Medium => 1,
|
||||
RiskLevel::High => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// 风险是否在边界上限内(High 恒超 Medium 上限;ceiling 已在 set 时钳到 Medium,
|
||||
/// 此函数对 High ceiling 不做特判——正常入口不会出现,留给出处可信的内存态)。
|
||||
pub fn risk_within_ceiling(boundary: &TaskBoundary, risk: RiskLevel) -> bool {
|
||||
risk_rank(risk) <= risk_rank(boundary.risk_ceiling)
|
||||
}
|
||||
|
||||
/// 路径是否被任务白名单覆盖(目录级 starts_with)。
|
||||
///
|
||||
/// 双侧 best-effort canonicalize + strip_verbatim 对齐形态(白名单存词法串/路径可能
|
||||
/// 不存在/candidate 可能带 \\?\ 前缀),归一口径与 AllowedDirs::is_authorized 一致。
|
||||
pub fn dir_covered(allowlist: &[String], path: &Path) -> bool {
|
||||
use crate::state::{best_effort_canonicalize, strip_verbatim};
|
||||
let cand = strip_verbatim(best_effort_canonicalize(path));
|
||||
allowlist.iter().any(|d| {
|
||||
let dir_norm = strip_verbatim(best_effort_canonicalize(Path::new(d)));
|
||||
cand.starts_with(&dir_norm)
|
||||
})
|
||||
}
|
||||
|
||||
/// NeedsAuth 待授权目录集合是否**全部**被任务白名单覆盖(全覆盖才放行,部分覆盖=拒;
|
||||
/// 部分放行会把未覆盖目录也带过闸,违背 fail-closed)。
|
||||
pub fn covers_dirs(boundary: &TaskBoundary, dirs: &[PathBuf]) -> bool {
|
||||
!dirs.is_empty() && dirs.iter().all(|d| dir_covered(&boundary.dir_allowlist, d))
|
||||
}
|
||||
|
||||
/// 边界对单条工具调用**风险面**的放行判定(路径维度在 path_auth NeedsAuth 分支单点判定,
|
||||
/// 不在本函数——两者判定时机不同,见 process_tool_calls 插桩注释)。
|
||||
///
|
||||
/// 条件: 风险 ≤ 上限 ∧ 非破坏性命令。run_command 注册为 High 本就恒超 Medium 上限,
|
||||
/// 破坏性闸是纵深防御(任务书要求 is_destructive_command 永不 auto,与 session_trust
|
||||
/// 放行门控同源同闸)。
|
||||
pub fn risk_allowed(
|
||||
boundary: &TaskBoundary,
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
risk: RiskLevel,
|
||||
) -> bool {
|
||||
if !risk_within_ceiling(boundary, risk) {
|
||||
return false;
|
||||
}
|
||||
if tool_name == "run_command" {
|
||||
if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) {
|
||||
if is_destructive_command(cmd) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn boundary(ceiling: RiskLevel, dirs: &[&str]) -> TaskBoundary {
|
||||
TaskBoundary {
|
||||
risk_ceiling: ceiling,
|
||||
dir_allowlist: dirs.iter().map(|s| s.to_string()).collect(),
|
||||
forbid_destructive: true,
|
||||
budget: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// serde roundtrip: 全字段 ↔ JSON 双向一致(落库 boundary_json 的序列化契约)。
|
||||
#[test]
|
||||
fn serde_roundtrip_full_fields() {
|
||||
let b = TaskBoundary {
|
||||
risk_ceiling: RiskLevel::Medium,
|
||||
dir_allowlist: vec!["E:/wk-lab/devflow".to_string(), "C:/temp".to_string()],
|
||||
forbid_destructive: true,
|
||||
budget: Some(TaskBudget { max_rounds: 50, max_tokens: 200_000, wall_secs: 900 }),
|
||||
};
|
||||
let json = serde_json::to_string(&b).unwrap();
|
||||
let back: TaskBoundary = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, b, "全字段 roundtrip 应一致");
|
||||
}
|
||||
|
||||
/// serde 缺省字段容错: 前端精简 JSON(缺 forbid_destructive/budget/risk_ceiling/
|
||||
/// dir_allowlist)反序列化落到安全默认(Medium/空名单/true/None),不报错。
|
||||
#[test]
|
||||
fn serde_missing_fields_fall_back_to_safe_defaults() {
|
||||
let b: TaskBoundary = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(b.risk_ceiling, RiskLevel::Medium, "缺省 ceiling=Medium(任务书拍板)");
|
||||
assert!(b.dir_allowlist.is_empty());
|
||||
assert!(b.forbid_destructive, "缺省禁破坏性命令");
|
||||
assert!(b.budget.is_none(), "缺省不限预算");
|
||||
}
|
||||
|
||||
/// 边界内 Medium 放行 / 边界外仍拒: Medium 上限下 Low/Medium 过闸,High 恒拒;
|
||||
/// Low 上限下 Medium 也拒(上限收紧生效)。
|
||||
#[test]
|
||||
fn risk_ceiling_truth_table() {
|
||||
let b = boundary(RiskLevel::Medium, &[]);
|
||||
let args = serde_json::json!({});
|
||||
assert!(risk_allowed(&b, "write_file", &args, RiskLevel::Low), "Low ≤ Medium 放行");
|
||||
assert!(risk_allowed(&b, "write_file", &args, RiskLevel::Medium), "Medium = 上限 放行(边界内 Medium 放行)");
|
||||
assert!(!risk_allowed(&b, "run_command", &args, RiskLevel::High), "High 超上限恒拒(边界外仍拒)");
|
||||
let low = boundary(RiskLevel::Low, &[]);
|
||||
assert!(!risk_allowed(&low, "write_file", &args, RiskLevel::Medium), "Medium 超 Low 上限拒");
|
||||
}
|
||||
|
||||
/// 目录覆盖判定: 白名单内放行 / 白名单外拒 / 部分覆盖拒(全 covered 才放行)。
|
||||
#[test]
|
||||
fn covers_dirs_all_or_nothing() {
|
||||
let b = boundary(RiskLevel::Medium, &["E:/wk-lab/devflow"]);
|
||||
assert!(covers_dirs(&b, &[PathBuf::from("E:/wk-lab/devflow/src/main.rs")]), "白名单内覆盖");
|
||||
assert!(covers_dirs(&b, &[PathBuf::from("E:/wk-lab/devflow/src"), PathBuf::from("E:/wk-lab/devflow/crates")]), "多个路径全在内");
|
||||
assert!(!covers_dirs(&b, &[PathBuf::from("E:/other/x")]), "白名单外拒(边界外仍拒)");
|
||||
assert!(
|
||||
!covers_dirs(&b, &[PathBuf::from("E:/wk-lab/devflow/src"), PathBuf::from("E:/other/x")]),
|
||||
"部分覆盖=拒(全 covered 才放行,fail-closed)"
|
||||
);
|
||||
assert!(!covers_dirs(&b, &[]), "空目录集不判覆盖(NeedsAuth 必带目录,空=异常数据保守拒)");
|
||||
}
|
||||
|
||||
/// 破坏性命令恒拒: 即使风险面在名义上限内(测试构造 High ceiling 模拟"上限失守"),
|
||||
/// is_destructive_command 命中仍拒——纵深防御闸独立于风险等级。
|
||||
#[test]
|
||||
fn destructive_command_never_auto() {
|
||||
let high = boundary(RiskLevel::High, &[]);
|
||||
let rm = serde_json::json!({ "command": "rm -rf /" });
|
||||
assert!(
|
||||
!risk_allowed(&high, "run_command", &rm, RiskLevel::High),
|
||||
"破坏性命令即使 High 上限也恒拒(纵深防御)"
|
||||
);
|
||||
let med = boundary(RiskLevel::Medium, &[]);
|
||||
assert!(
|
||||
!risk_allowed(&med, "run_command", &rm, RiskLevel::High),
|
||||
"Medium 上限下 run_command 双闸全拒"
|
||||
);
|
||||
// 非破坏性命令只受风险等级约束(首 token 精配不误伤 delta/delve)
|
||||
let cargo = serde_json::json!({ "command": "cargo check --workspace" });
|
||||
assert!(
|
||||
!risk_allowed(&med, "run_command", &cargo, RiskLevel::High),
|
||||
"Medium 上限下非破坏 High 命令仍超上限拒"
|
||||
);
|
||||
}
|
||||
|
||||
/// set 入口规范化: High ceiling 钳到 Medium(拍板点 1),其余字段原样保留。
|
||||
#[test]
|
||||
fn normalized_clamps_high_ceiling_to_medium() {
|
||||
let clamped = boundary(RiskLevel::High, &["E:/x"]).normalized();
|
||||
assert_eq!(clamped.risk_ceiling, RiskLevel::Medium, "High 钳到 Medium");
|
||||
assert_eq!(clamped.dir_allowlist, vec!["E:/x".to_string()], "其余字段不动");
|
||||
assert_eq!(boundary(RiskLevel::Low, &[]).normalized().risk_ceiling, RiskLevel::Low, "Low 不变");
|
||||
}
|
||||
|
||||
/// 黑名单恒拒与边界无关: .ssh 路径在 check_file_tool_auth 先行 Denied,
|
||||
/// 边界层永不参与 Denied 分支(即使 allowlist 看似覆盖其父目录)。
|
||||
/// 插桩结构保证: Denied 在 NeedsAuth 分支之前分流,boundary 判定只接 NeedsAuth。
|
||||
#[test]
|
||||
fn blacklist_denied_before_boundary_consulted() {
|
||||
let _guard = crate::commands::ai::audit::path_auth::BLACKLIST_BYPASS_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let allowed = crate::state::AllowedDirs::default_with_root();
|
||||
// allowlist 故意覆盖 /home/user:若边界误参与黑名单分支,这里会误判"覆盖"
|
||||
let b = boundary(RiskLevel::Medium, &["/home/user"]);
|
||||
let secret = serde_json::json!({ "path": "/home/user/.ssh/config" });
|
||||
assert!(
|
||||
matches!(
|
||||
crate::commands::ai::audit::path_auth::check_file_tool_auth(
|
||||
"read_file", &secret, &allowed, crate::commands::ai::audit::path_auth::PathBypass::Off,
|
||||
),
|
||||
crate::commands::ai::audit::path_auth::FileToolAuthOutcome::Denied(_)
|
||||
),
|
||||
"黑名单路径恒 Denied(不进 NeedsAuth,边界判定无机会放行)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 无边界零变化: PerConvState 默认 boundary=None,process_tool_calls 边界快照为 None
|
||||
/// 时全部分支跳过(auto_exec_mode/trust/审批/路径授权全链路现状)。
|
||||
#[test]
|
||||
fn per_conv_default_boundary_is_none() {
|
||||
let conv = crate::commands::ai::PerConvState::new();
|
||||
assert!(conv.boundary.is_none(), "新会话默认无边界(无边界=现状逐次审批零倒退)");
|
||||
}
|
||||
}
|
||||
@@ -400,6 +400,21 @@ pub async fn ai_conversation_switch(
|
||||
};
|
||||
conv.pinned_goals = pinned_goals;
|
||||
}
|
||||
// 无人值守任务边界随行读载(V45 boundary_json):DB 快照还原内存真相源。
|
||||
// 仅未在生成分支执行(already_live 时内存即真相,不覆盖)。解析失败按无边界降级
|
||||
// (fail-closed:无边界=逐次审批,绝不因坏数据放大授权)。
|
||||
conv.boundary = record.boundary_json.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|s| match serde_json::from_str::<crate::commands::ai::boundary::TaskBoundary>(s) {
|
||||
Ok(b) => Some(b),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"切换对话 {} 解析 boundary_json 失败(按无边界处理): {}",
|
||||
conversation_id, e
|
||||
);
|
||||
None
|
||||
}
|
||||
});
|
||||
// 首次切入才复位会话级临时态(信任/语言/模型覆盖保留,防切回重复审批);停止信号与迭代计数始终复位
|
||||
if !conv_was_present {
|
||||
conv.model_override = None;
|
||||
@@ -640,6 +655,80 @@ pub async fn ai_conversation_peek(
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 子代理轨迹列表(S3 完成卡备料:父会话 → 其子代理轨迹精简元数据,只读)
|
||||
// ============================================================
|
||||
|
||||
/// 子代理轨迹精简条目(前端完成卡/轨迹树回补用;零 schema 变更,父子关系由 id 约定推导)。
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct SubAgentRef {
|
||||
pub conversation_id: String,
|
||||
/// 轨迹标题(懒创建未命名时 None,前端回退显 agent_id)
|
||||
pub title: Option<String>,
|
||||
/// 创建时间(毫秒字符串,同 ai_conversations.created_at 口径)
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// 按父会话 id 反查其派生的子代理轨迹(供 IPC 与单测共用,不依赖 AppState)。
|
||||
///
|
||||
/// 子代理轨迹落库 origin='sub_agent'(V44);父关系无独立列,靠 child_id 约定
|
||||
/// `{parent}::agent-{n}`(agentic/sub_agent.rs next_child_id)推导:SQL LIKE 粗筛
|
||||
/// (参数化绑定)+ Rust 侧 starts_with 精确校验——LIKE 的 _/% 通配语义不作判等依据,
|
||||
/// 与 record.rs「禁裸 LIKE 判等」同口径。app 层直查先例:find_task_conversations。
|
||||
/// 按 created_at 升序返回(轨迹时间线序)。
|
||||
pub(crate) async fn find_sub_agent_conversations(
|
||||
db: &df_storage::db::Database,
|
||||
parent_conv_id: &str,
|
||||
) -> Result<Vec<SubAgentRef>, String> {
|
||||
let prefix = format!("{}::agent-", parent_conv_id);
|
||||
let like = format!("{}%", prefix);
|
||||
let conn = db.conn();
|
||||
let guard_prefix = prefix.clone();
|
||||
let rows: Vec<(String, Option<String>, String)> =
|
||||
tokio::task::spawn_blocking(move || -> Result<Vec<(String, Option<String>, String)>, String> {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT id, title, created_at FROM ai_conversations \
|
||||
WHERE origin = 'sub_agent' AND id LIKE ?1 \
|
||||
ORDER BY CAST(created_at AS INTEGER) ASC",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mapped = stmt
|
||||
.query_map([&like], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut out = Vec::new();
|
||||
for r in mapped {
|
||||
out.push(r.map_err(|e| e.to_string())?);
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("find_sub_agent_conversations join 失败: {}", e))??;
|
||||
|
||||
// starts_with 精确校验:父 id 含 LIKE 通配字符时粗筛会误命中他父条目,以约定前缀为准剔除
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter(|(id, _, _)| id.starts_with(&guard_prefix))
|
||||
.map(|(conversation_id, title, created_at)| SubAgentRef { conversation_id, title, created_at })
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 按父会话反查子代理轨迹精简元数据(前端完成卡回补数据源,只读无副作用)。
|
||||
#[tauri::command]
|
||||
pub async fn ai_sub_agent_list(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
) -> Result<Vec<SubAgentRef>, String> {
|
||||
find_sub_agent_conversations(&state.db, &conversation_id).await
|
||||
}
|
||||
|
||||
/// 删除对话
|
||||
#[tauri::command]
|
||||
pub async fn ai_conversation_delete(
|
||||
@@ -687,8 +776,10 @@ pub async fn ai_conversation_delete(
|
||||
// spawn_agent 级联取消:父会话删除,其派生的子代理同步置停(子循环停止检查点
|
||||
// 读 own stop_flag 即退出;父 per_conv 条目随下方 remove 消失是兜底判据)。
|
||||
// 短临界区纯内存操作(无 await),不延长 session 持锁。
|
||||
// Delete 形态:同锁内对 child 补 deleted_convs,出口 alive 判定放弃落库,防孤儿轨迹复活。
|
||||
crate::commands::ai::agentic::sub_agent::cascade_stop_children_locked(
|
||||
&mut session, &state.sub_agent_registry, &conversation_id,
|
||||
crate::commands::ai::agentic::sub_agent::CascadeStopForm::Delete,
|
||||
);
|
||||
// 删除 conv 时移除其 per_conv 条目(loop 检测 conv 不存在即退出)。
|
||||
session.per_conv.remove(&conversation_id);
|
||||
@@ -805,6 +896,79 @@ pub async fn ai_update_conversation_goals(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 无人值守任务边界 IPC(设计 §2.3):发起时一次拍板,存续期生效,随会话同亡。
|
||||
// 无边界=现状逐次审批零倒退;set 传 None 清除=回退逐次审批(接管转人工语义)。
|
||||
// ============================================================
|
||||
|
||||
/// 读取会话任务边界(发起卡回显/任务面板展示用)。
|
||||
///
|
||||
/// 内存优先(per_conv 即 live 真相),未加载(未切载/重启后未切)回退 DB 快照;
|
||||
/// 两处皆无返回 None(幂等读,不报错不惰性建会话)。
|
||||
#[tauri::command]
|
||||
pub async fn ai_task_boundary_get(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
) -> Result<Option<crate::commands::ai::boundary::TaskBoundary>, String> {
|
||||
{
|
||||
let session = state.ai_session.lock().await;
|
||||
if let Some(b) = session.conv_read(&conversation_id).and_then(|c| c.boundary.clone()) {
|
||||
return Ok(Some(b));
|
||||
}
|
||||
}
|
||||
let record = state.ai_conversations.get_by_id(&conversation_id).await.map_err(err_str)?;
|
||||
Ok(record.and_then(|r| {
|
||||
r.boundary_json.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|s| match serde_json::from_str(s) {
|
||||
Ok(b) => Some(b),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"读取对话 {} boundary_json 解析失败(按无边界返回): {}",
|
||||
conversation_id, e
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/// 设置会话任务边界(发起时一次声明;中途改边界=新拍板,本 IPC 不拦重复调用——
|
||||
/// 拍板点 8 修订:一次性约束的是 AI 不回头问用户,用户动自己的任务不受限)。
|
||||
///
|
||||
/// boundary=None 清除 → 回退逐次审批。set 即生效(per_conv 内存真相源),
|
||||
/// DB 快照 best-effort 同步:对话未落库(懒创建)时 UPDATE 影响 0 行属预期,
|
||||
/// 首条消息 save_conversation 首插会随行带上 boundary_json。
|
||||
#[tauri::command]
|
||||
pub async fn ai_task_boundary_set(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
boundary: Option<crate::commands::ai::boundary::TaskBoundary>,
|
||||
) -> Result<(), String> {
|
||||
// 入口规范化:ceiling=High 钳到 Medium(拍板点 1,High 归 L3 不开放)
|
||||
let normalized = boundary.map(|b| b.normalized());
|
||||
{
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.conv(&conversation_id).boundary = normalized.clone();
|
||||
}
|
||||
match normalized.as_ref() {
|
||||
Some(b) => {
|
||||
let json = serde_json::to_string(b).map_err(|e| format!("序列化任务边界失败: {e}"))?;
|
||||
if let Err(e) = state.ai_conversations.set_boundary_json(&conversation_id, Some(&json)).await {
|
||||
tracing::warn!(
|
||||
"写对话 {} boundary_json 失败(内存已生效,首插 save 随行补): {}",
|
||||
conversation_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// 清除:列写 NULL;未落库会话影响 0 行属预期,静默
|
||||
let _ = state.ai_conversations.set_boundary_json(&conversation_id, None).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 导出对话为指定格式对话导出)
|
||||
///
|
||||
/// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库)
|
||||
@@ -1271,4 +1435,69 @@ mod tests {
|
||||
assert_eq!(got[1].id, "m_new");
|
||||
assert_eq!(got[1].content, "压缩后新提问");
|
||||
}
|
||||
|
||||
/// ai_sub_agent_list 数据源:只列本父会话派生的子代理(origin='sub_agent' + id
|
||||
/// 前缀约定),按 created_at 升序;LIKE 通配误命中与无子代理父不混入。
|
||||
#[tokio::test]
|
||||
async fn sub_agent_list_scopes_to_parent_prefix() {
|
||||
use df_storage::crud::AiConversationRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::AiConversationRecord;
|
||||
|
||||
async fn insert_conv(
|
||||
db: &Database,
|
||||
id: &str,
|
||||
title: Option<&str>,
|
||||
origin: Option<&str>,
|
||||
created_at: &str,
|
||||
) {
|
||||
AiConversationRepo::new(db)
|
||||
.insert(AiConversationRecord {
|
||||
id: id.to_string(),
|
||||
title: title.map(|s| s.to_string()),
|
||||
messages: "[]".to_string(),
|
||||
provider_id: None,
|
||||
model: None,
|
||||
models: None,
|
||||
archived: false,
|
||||
pinned: false,
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
pinned_goals: None,
|
||||
pending_approvals: None,
|
||||
created_at: created_at.to_string(),
|
||||
updated_at: created_at.to_string(),
|
||||
origin: origin.map(|s| s.to_string()),
|
||||
boundary_json: None,
|
||||
})
|
||||
.await
|
||||
.expect("测试会话插入应成功");
|
||||
}
|
||||
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
insert_conv(&db, "conv-p", Some("父会话"), None, "100").await;
|
||||
insert_conv(&db, "conv-p::agent-1", Some("子一"), Some("sub_agent"), "300").await;
|
||||
insert_conv(&db, "conv-p::agent-2", None, Some("sub_agent"), "200").await;
|
||||
// 他父的子代理 / origin 形态不符 id 前缀的子代理行:均不得混入
|
||||
insert_conv(&db, "conv-q::agent-1", Some("他父子"), Some("sub_agent"), "400").await;
|
||||
insert_conv(&db, "conv-p::notagent", Some("形态不符"), Some("sub_agent"), "500").await;
|
||||
// 父 id 含 LIKE 下划线通配:粗筛会误命中他父条目,须被 starts_with 精确校验剔除
|
||||
insert_conv(&db, "p_1::agent-1", Some("真子"), Some("sub_agent"), "600").await;
|
||||
insert_conv(&db, "pX1::agent-9", Some("通配误命中"), Some("sub_agent"), "700").await;
|
||||
|
||||
let out = find_sub_agent_conversations(&db, "conv-p").await.unwrap();
|
||||
assert_eq!(out.len(), 2, "只列 conv-p 派生的子代理: {:?}", out);
|
||||
assert_eq!(out[0].conversation_id, "conv-p::agent-2", "created_at 升序");
|
||||
assert!(out[0].title.is_none());
|
||||
assert_eq!(out[1].conversation_id, "conv-p::agent-1");
|
||||
assert_eq!(out[1].title.as_deref(), Some("子一"));
|
||||
assert_eq!(out[1].created_at, "300");
|
||||
|
||||
let out2 = find_sub_agent_conversations(&db, "p_1").await.unwrap();
|
||||
assert_eq!(out2.len(), 1, "LIKE 下划线通配的误命中须被精确校验剔除: {:?}", out2);
|
||||
assert_eq!(out2[0].conversation_id, "p_1::agent-1");
|
||||
|
||||
// 无子代理的父返空
|
||||
assert!(find_sub_agent_conversations(&db, "conv-none").await.unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,18 @@ pub(crate) fn truncate_parts_for_persist(parts: &[df_ai::provider::ContentPart])
|
||||
}
|
||||
}
|
||||
|
||||
/// save 元数据快照(P3-5):子代理出口落库显式携带父会话的 provider_id/created_at。
|
||||
///
|
||||
/// save 内部默认读 `session.active_*` 决定首插的 provider_id/created_at——这对主会话
|
||||
/// 正确,对子代理错误:子代理后台长跑收尾时用户可能已切走会话,active 值已是别的会话的,
|
||||
/// 子轨迹会继承错元数据。spawn 时刻快照、出口显式传入即可钉住。
|
||||
/// 主会话路径不经过本类型(save_conversation 传 None),读法与历史版本一致。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct SaveMetaOverride {
|
||||
pub(crate) provider_id: Option<String>,
|
||||
pub(crate) created_at: Option<String>,
|
||||
}
|
||||
|
||||
/// 保存对话到数据库(按 conv_id 写库,不受 active_conversation_id 切换影响)
|
||||
///
|
||||
/// 写 ai_messages(每条消息一行,F-260619-03 拆分存储)+ updated_at + 累加 token 用量 +
|
||||
@@ -190,13 +202,40 @@ pub(crate) async fn save_conversation(
|
||||
model: Option<&str>,
|
||||
touch_updated_at: bool,
|
||||
) {
|
||||
// 外层 timeout(SAVE_CONVERSATION_TIMEOUT_SECS):超时只 warn 不阻断。
|
||||
// save 是非关键路径(失败已 warn,内存真相源不变),防 DB/fs 卡死拖垮 agentic loop / IPC。
|
||||
// timeout 仅作用于 DB 写入段(此处包住整个 inner,因 session 锁/序列化也属 save 周期,
|
||||
// 超时即放弃本次 save,下次 loop 轮次会重新落库,幂等覆盖无副作用)。
|
||||
save_with_meta(session_arc, db, conv_id, usage, model, touch_updated_at, None).await;
|
||||
}
|
||||
|
||||
/// 子代理出口落库入口(P3-5):显式携带父会话 save 元数据快照的 save 变体。
|
||||
/// 与 save_conversation 共用同一落库实现,仅首插的 provider_id/created_at 来源不同。
|
||||
pub(crate) async fn save_conversation_meta(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
db: &Arc<Database>,
|
||||
conv_id: &str,
|
||||
usage: Option<&df_ai::provider::TokenUsage>,
|
||||
model: Option<&str>,
|
||||
touch_updated_at: bool,
|
||||
meta: SaveMetaOverride,
|
||||
) {
|
||||
save_with_meta(session_arc, db, conv_id, usage, model, touch_updated_at, Some(meta)).await;
|
||||
}
|
||||
|
||||
/// save 统一实现(meta=None 即主会话既有行为,Some 即显式元数据覆盖)。
|
||||
/// 外层 timeout(SAVE_CONVERSATION_TIMEOUT_SECS):超时只 warn 不阻断。
|
||||
/// save 是非关键路径(失败已 warn,内存真相源不变),防 DB/fs 卡死拖垮 agentic loop / IPC。
|
||||
/// timeout 仅作用于 DB 写入段(此处包住整个 inner,因 session 锁/序列化也属 save 周期,
|
||||
/// 超时即放弃本次 save,下次 loop 轮次会重新落库,幂等覆盖无副作用)。
|
||||
async fn save_with_meta(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
db: &Arc<Database>,
|
||||
conv_id: &str,
|
||||
usage: Option<&df_ai::provider::TokenUsage>,
|
||||
model: Option<&str>,
|
||||
touch_updated_at: bool,
|
||||
meta: Option<SaveMetaOverride>,
|
||||
) {
|
||||
if let Err(_elapsed) = tokio::time::timeout(
|
||||
Duration::from_secs(SAVE_CONVERSATION_TIMEOUT_SECS),
|
||||
save_conversation_serialized(session_arc, db, conv_id, usage, model, touch_updated_at),
|
||||
save_conversation_serialized(session_arc, db, conv_id, usage, model, touch_updated_at, meta),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -216,16 +255,17 @@ async fn save_conversation_serialized(
|
||||
usage: Option<&df_ai::provider::TokenUsage>,
|
||||
model: Option<&str>,
|
||||
touch_updated_at: bool,
|
||||
meta: Option<SaveMetaOverride>,
|
||||
) {
|
||||
let save_lock = {
|
||||
let mut session = session_arc.lock().await;
|
||||
session.save_lock(conv_id)
|
||||
};
|
||||
let _save_guard = save_lock.lock().await;
|
||||
save_conversation_inner(session_arc, db, conv_id, usage, model, touch_updated_at).await;
|
||||
save_conversation_inner(session_arc, db, conv_id, usage, model, touch_updated_at, meta).await;
|
||||
}
|
||||
|
||||
/// save_conversation 内部实现(无超时,由外层 save_conversation 包 timeout 调用)。
|
||||
/// save_conversation 内部实现(无超时,由外层 save_with_meta 包 timeout 调用)。
|
||||
async fn save_conversation_inner(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
db: &Arc<Database>,
|
||||
@@ -233,6 +273,7 @@ async fn save_conversation_inner(
|
||||
usage: Option<&df_ai::provider::TokenUsage>,
|
||||
model: Option<&str>,
|
||||
touch_updated_at: bool,
|
||||
meta: Option<SaveMetaOverride>,
|
||||
) {
|
||||
// 取 messages + 懒创建首次落库所需的 provider_id/created_at
|
||||
// 工具结果(content)超 8KB50KB→8KB,治 GLM 1214)时截断头尾各 ~3KB + 中段
|
||||
@@ -253,7 +294,7 @@ async fn save_conversation_inner(
|
||||
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。
|
||||
// BE1无变化捷径提前到克隆之前——同一 lock 段内先判「消息数未增且非 dirty」,
|
||||
// 无变化则不 clone(每轮无变化 save 省一次全量 clone + 后续 truncate/records 构建)。
|
||||
let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs, conv_origin) = {
|
||||
let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs, conv_origin, conv_boundary) = {
|
||||
let mut session = session_arc.lock().await;
|
||||
// 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治):
|
||||
// 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。
|
||||
@@ -268,8 +309,15 @@ async fn save_conversation_inner(
|
||||
let __wait = __lock_start.elapsed();
|
||||
// 先取 session 顶层 owned 字段(immutable borrow 即刻结束),再取 conv mutable borrow,
|
||||
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。
|
||||
let provider_id = session.active_provider_id.clone();
|
||||
let created_at = session.active_conv_created_at.clone();
|
||||
// P3-5:显式快照优先(SubAgent 出口落库用),否则读 active_*(主会话历史读法不变)。
|
||||
let provider_id = match &meta {
|
||||
Some(m) => m.provider_id.clone(),
|
||||
None => session.active_provider_id.clone(),
|
||||
};
|
||||
let created_at = match &meta {
|
||||
Some(m) => m.created_at.clone(),
|
||||
None => session.active_conv_created_at.clone(),
|
||||
};
|
||||
// 会话来源标记(V44 子轨迹落库):子代理 conv 首插时随 record 落 origin 列,主会话 None
|
||||
let conv_origin = session.conv_origins.get(conv_id).cloned();
|
||||
let conv = session.conv(conv_id);
|
||||
@@ -284,6 +332,8 @@ async fn save_conversation_inner(
|
||||
conv.messages.all_messages_clone()
|
||||
};
|
||||
let pinned_goals = conv.pinned_goals.clone();
|
||||
// 无人值守任务边界随行落库(V45 boundary_json):每轮 save 同步内存真相源快照
|
||||
let conv_boundary = conv.boundary.clone();
|
||||
let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长)
|
||||
(
|
||||
provider_id,
|
||||
@@ -294,6 +344,7 @@ async fn save_conversation_inner(
|
||||
needs_full_rewrite,
|
||||
cloned,
|
||||
conv_origin,
|
||||
conv_boundary,
|
||||
)
|
||||
};
|
||||
let __lock_total = __lock_start.elapsed();
|
||||
@@ -403,6 +454,10 @@ async fn save_conversation_inner(
|
||||
rec.pinned_goals = Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string()));
|
||||
// 挂起审批快照持久化:每轮 save 同步当前 pending_approvals 快照
|
||||
rec.pending_approvals = Some(pending_approvals_json.clone());
|
||||
// 无人值守任务边界随行持久化:None(未声明)保持 NULL,不写 "null" 脏串
|
||||
rec.boundary_json = conv_boundary.as_ref()
|
||||
.map(|b| serde_json::to_string(b).unwrap_or_default())
|
||||
.filter(|s| !s.is_empty());
|
||||
// update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at
|
||||
if let Err(e) = conv_repo.update_full(&rec).await {
|
||||
tracing::warn!("更新对话元数据失败 {conv_id}: {e}");
|
||||
@@ -473,6 +528,9 @@ async fn save_conversation_inner(
|
||||
created_at: conv_created.clone(),
|
||||
updated_at: now,
|
||||
origin: conv_origin,
|
||||
boundary_json: conv_boundary.as_ref()
|
||||
.map(|b| serde_json::to_string(b).unwrap_or_default())
|
||||
.filter(|s| !s.is_empty()),
|
||||
};
|
||||
if let Err(e) = conv_repo.insert(rec).await {
|
||||
tracing::warn!("落库对话失败 {conv_id}: {e}");
|
||||
@@ -492,10 +550,14 @@ async fn save_conversation_inner(
|
||||
// 失败保基线不变:下次 save 会重新尝试写未落库的消息(增量路径重试同 slice,INSERT OR IGNORE
|
||||
// 幂等不冲突;脏路径全量重写收敛)。基线推进必须在锁内与读时一致(本进程内单线程 save 串行)。
|
||||
// 注意:写库期间若有其他操作 push/置 dirty,基线推进可能与最新内存错位——但 advance 用
|
||||
// max 防回退,且 dirty 标志由那次操作自己设(下次 save 仍走全量重写收敛),最终一致。
|
||||
// max 防回退;dirty 只在全量重写时清(增量/无变化路径写库期间新置的 dirty 如压缩 mark,
|
||||
// 与本次 append 写库无关,误清会致改动滞留内存永不落库)。
|
||||
if msgs_written_ok {
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.advance_persisted_count(total_len);
|
||||
session
|
||||
.conv(conv_id)
|
||||
.messages
|
||||
.advance_persisted_count(total_len, needs_full_rewrite);
|
||||
}
|
||||
|
||||
// 自动 checkpoint(每 20 轮或总 token > 150k 时创建)
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
pub mod agentic;
|
||||
pub mod augmentation;
|
||||
pub mod audit;
|
||||
// 无人值守任务边界(TaskBoundary):结构 + 纯判定函数 + 单测(设计 §二/§三)
|
||||
pub mod boundary;
|
||||
pub mod code_intel;
|
||||
pub mod command_stream;
|
||||
pub mod commands;
|
||||
@@ -236,6 +238,9 @@ pub enum AiChatEvent {
|
||||
/// - `status`:"completed" | "truncated" | "failed"
|
||||
/// - `rounds`/`tokens`:实际执行轮数与累计 token(启动失败为 0)
|
||||
/// - `rejected_tools`:被拒工具清单("工具名×次数",如 "write_file×3"),None=本次零拒绝
|
||||
/// - `stop_kind`:停止类别(snake_case 字符串,为前端备料):"cancelled"(用户停止级联)
|
||||
/// | "wall_clock" | "max_rounds" | "token_budget" | "empty_convergence" | "error"
|
||||
/// | "converged"(正常收敛);None=未分类(防御,当前所有路径均有类别)
|
||||
AiAgentFinished {
|
||||
agent_id: String,
|
||||
conversation_id: Option<String>,
|
||||
@@ -246,6 +251,9 @@ pub enum AiChatEvent {
|
||||
/// S2 报告协议:子循环运行期被拒工具汇总,让父会话与用户看见子代理被拒了什么。
|
||||
/// 可选字段:前端 S1 完成卡不消费该字段也不破坏(事件多字段向后兼容),消费留二期。
|
||||
rejected_tools: Option<Vec<String>>,
|
||||
/// 停止类别(为前端备料):"cancelled" | "wall_clock" | "max_rounds" | "token_budget"
|
||||
/// | "empty_convergence" | "error" | "converged"。前端不消费该字段也不破坏。
|
||||
stop_kind: Option<String>,
|
||||
},
|
||||
/// 流式调用自动重试提示流前失败重试中)
|
||||
///
|
||||
@@ -425,6 +433,34 @@ pub enum AiChatEvent {
|
||||
resolved_by: String,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// 无人值守安全停靠(设计 §四,挂起家族终局):五路非正常终局(审批超时取消/挂起后
|
||||
/// 取消/熔断/预算耗尽/Fatal)统一出口。语义:现场已保存、generating 已复位(非死等)、
|
||||
/// reason 即待办清单(停止原因+已拒绝操作提示),前端操作卡据此提供恢复入口
|
||||
/// (既有 ai_chat_force_send/重发语义)。
|
||||
///
|
||||
/// 前端契约(钉死):事件名 `AiTaskParked`,字段
|
||||
/// `{ conversation_id: string, reason: string, stop_kind?: string }`。
|
||||
/// stop_kind 复用 AiAgentFinished 词表扩展: "approval_timeout" | "cancelled"
|
||||
/// | "circuit_breaker" | "stall_breaker" | "max_rounds" | "fatal"。
|
||||
AiTaskParked {
|
||||
conversation_id: Option<String>,
|
||||
reason: String,
|
||||
stop_kind: Option<String>,
|
||||
},
|
||||
/// 无人值守进度可观测(设计 §4.3):主 loop 每 PROGRESS_EVERY_N_ROUNDS 轮 emit 一次,
|
||||
/// 长任务静默期(子代理长跑/大工具执行/重入退避)前端可判断「在干活还是死了」。
|
||||
/// 事件即逝不落库,终局(AiCompleted/AiTaskParked 等)不补发。
|
||||
///
|
||||
/// 前端契约(钉死):事件名 `AiTaskProgress`,字段
|
||||
/// `{ conversation_id: string, round: number, max_rounds: number, tokens_used: number }`。
|
||||
/// round 1 起算(对齐 AiAgentRound 口径);max_rounds=0 表示不限轮次,
|
||||
/// tokens_used 为本 loop 累计 token。
|
||||
AiTaskProgress {
|
||||
conversation_id: Option<String>,
|
||||
round: u32,
|
||||
max_rounds: u32,
|
||||
tokens_used: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// 模型信息(AiModelList 事件的载荷,对齐 apps/df-miniapp 模型选择器 ModelInfo 字段)。
|
||||
@@ -662,19 +698,6 @@ pub struct AiSession {
|
||||
}
|
||||
|
||||
impl AiSession {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active_provider_id: None,
|
||||
active_conversation_id: None,
|
||||
active_conv_created_at: None,
|
||||
pending_approvals: HashMap::new(),
|
||||
per_conv: HashMap::new(),
|
||||
deleted_convs: HashSet::new(),
|
||||
conv_origins: HashMap::new(),
|
||||
save_locks: HashMap::new(),
|
||||
namespace_store: df_ai::namespace_store::NamespaceStore::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 推导指定会话读侧状态视图(只读,不写任何字段)。
|
||||
///
|
||||
@@ -738,6 +761,25 @@ impl AiSession {
|
||||
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 新建顶层会话容器(单例字段全默认:无活跃对话/无挂起审批/per_conv 空)。
|
||||
///
|
||||
/// B 批4 后本结构仅剩容器字段(单会话态全部下沉 per_conv),构造极简;
|
||||
/// 历史单例字段(messages/stop_flag/...)的初值语义由 [`PerConvState::new`] 承接
|
||||
/// (调用方 session.conv(cid) 惰性创建时使用)。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active_provider_id: None,
|
||||
active_conversation_id: None,
|
||||
active_conv_created_at: None,
|
||||
pending_approvals: HashMap::new(),
|
||||
per_conv: HashMap::new(),
|
||||
deleted_convs: HashSet::new(),
|
||||
conv_origins: HashMap::new(),
|
||||
save_locks: HashMap::new(),
|
||||
namespace_store: df_ai::namespace_store::NamespaceStore::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1031,6 +1073,30 @@ pub struct PerConvState {
|
||||
/// 仍为 view-only(不落库改历史,仅影响发送视图);键用内容哈希而非位置(裁剪/压缩后
|
||||
/// 位置漂移,内容不变)。随会话销毁不落库。
|
||||
pub tool_view_decisions: std::collections::HashMap<u64, bool>,
|
||||
/// 无人值守任务级授权边界(设计 §二):发起时一次声明,存续期内边界内零审批。
|
||||
/// None=未声明(现状逐次审批零倒退)。内存真相源在 per_conv,落库随行
|
||||
/// (ai_conversations.boundary_json,save 写/switch 读还原),随会话同亡。
|
||||
/// 子代理 per_conv 独立新建恒 None,不受父边界影响(拍板点 6 fail-closed)。
|
||||
pub boundary: Option<boundary::TaskBoundary>,
|
||||
/// 只读工具结果缓存的「写后失效」下界(消息索引,纯内存态,不落库)。
|
||||
///
|
||||
/// 治「只读结果缓存无写失效」缺陷:find_cached_readonly_result 通过反扫历史 messages
|
||||
/// 命中旧 tool_result 回填缓存,但历史里 write_file/run_command 等写工具一旦执行过,
|
||||
/// 其之前的只读结果(read_file/grep/list_directory)已可能过期——写后 LLM 重读会拿到
|
||||
/// 改前内容,缓存把脏数据喂回 LLM 致其基于旧状态决策。
|
||||
///
|
||||
/// 语义:非只读工具(`is_readonly(name)==false`)执行成功回填后,本字段被推到当时
|
||||
/// `messages.len()`(即该写结果 push 之后的位置)。缓存命中扫描仅认 `idx >= floor`
|
||||
/// 的历史调用——写在前的只读结果全部失效(过界即脏),写在后的(写后新读)有效。
|
||||
///
|
||||
/// 脏失效范围必然覆盖「写工具影响的任何路径」:floor 是全量下界,不区分写的是哪个
|
||||
/// 文件(精确按路径失效需解析工具语义,过度工程;宁可多失效少喂脏——失效只是
|
||||
/// 多一次真执行,正确性无损)。
|
||||
///
|
||||
/// 索引漂移防御:floor 在锁内与 messages 同步读写;pop_last_assistant_round /
|
||||
/// clear 会缩短 messages,此时 floor 若 > len 用 `min` 钳制(等于全失效后重新积累,
|
||||
/// 保守方向安全)。floor 单调不减(取 max),避免写后多轮回退。
|
||||
pub readonly_cache_floor: usize,
|
||||
}
|
||||
|
||||
/// 单个目标记录(含状态追踪)。
|
||||
@@ -1041,6 +1107,73 @@ pub struct GoalEntry {
|
||||
pub status: GoalStatus,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_task_parked {
|
||||
use super::*;
|
||||
|
||||
/// 停靠事件 serde 契约(前端钉死):事件名 type=AiTaskParked,字段
|
||||
/// {conversation_id: string, reason: string, stop_kind?: string}。
|
||||
/// 前端下批消费,本测试锁死后端不悄悄改协议。
|
||||
#[test]
|
||||
fn ai_task_parked_event_serde_contract() {
|
||||
let ev = AiChatEvent::AiTaskParked {
|
||||
conversation_id: Some("conv-1".to_string()),
|
||||
reason: "审批超时(15分钟)自动取消,任务已安全停靠".to_string(),
|
||||
stop_kind: Some("approval_timeout".to_string()),
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(v["type"], "AiTaskParked", "事件名(前端契约)");
|
||||
assert_eq!(v["conversation_id"], "conv-1", "conversation_id 序列化为 string");
|
||||
assert_eq!(v["reason"], "审批超时(15分钟)自动取消,任务已安全停靠");
|
||||
assert_eq!(v["stop_kind"], "approval_timeout");
|
||||
// stop_kind 可选语义:None → null(前端 stop_kind? 可选消费)
|
||||
let ev2 = AiChatEvent::AiTaskParked {
|
||||
conversation_id: Some("c".to_string()),
|
||||
reason: "r".to_string(),
|
||||
stop_kind: None,
|
||||
};
|
||||
let v2 = serde_json::to_value(&ev2).unwrap();
|
||||
assert!(v2["stop_kind"].is_null(), "stop_kind 缺省序列化为 null(可选字段)");
|
||||
}
|
||||
|
||||
/// stop_kind 词表锁定(六路非正常终局):前端操作卡/图标按此分派,新增值须同步前端契约。
|
||||
#[test]
|
||||
fn ai_task_parked_stop_kind_vocabulary() {
|
||||
const KINDS: [&str; 7] = [
|
||||
"approval_timeout", "cancelled", "circuit_breaker",
|
||||
"stall_breaker", "max_rounds", "fatal", "midstream",
|
||||
];
|
||||
for k in KINDS {
|
||||
let ev = AiChatEvent::AiTaskParked {
|
||||
conversation_id: Some("c".to_string()),
|
||||
reason: "r".to_string(),
|
||||
stop_kind: Some(k.to_string()),
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(v["stop_kind"], k);
|
||||
}
|
||||
}
|
||||
|
||||
/// 进度事件 serde 契约(前端钉死):事件名 type=AiTaskProgress,字段
|
||||
/// {conversation_id: string, round: number, max_rounds: number, tokens_used: number}。
|
||||
/// max_rounds=0 语义=不限轮次(前端进度条按无上限处理),数值类型本测试锁死。
|
||||
#[test]
|
||||
fn ai_task_progress_event_serde_contract() {
|
||||
let ev = AiChatEvent::AiTaskProgress {
|
||||
conversation_id: Some("conv-1".to_string()),
|
||||
round: 6,
|
||||
max_rounds: 20,
|
||||
tokens_used: 12345,
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(v["type"], "AiTaskProgress", "事件名(前端契约)");
|
||||
assert_eq!(v["conversation_id"], "conv-1", "conversation_id 序列化为 string");
|
||||
assert_eq!(v["round"], 6, "round 序列化为 number(1 起算)");
|
||||
assert_eq!(v["max_rounds"], 20, "max_rounds 序列化为 number(0=不限)");
|
||||
assert_eq!(v["tokens_used"], 12345, "tokens_used 序列化为 number(loop 累计)");
|
||||
}
|
||||
}
|
||||
|
||||
/// 目标状态。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -1096,6 +1229,8 @@ impl PerConvState {
|
||||
workflow_dag_summary: None,
|
||||
frozen_tool_names: None,
|
||||
tool_view_decisions: std::collections::HashMap::new(),
|
||||
boundary: None,
|
||||
readonly_cache_floor: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1253,3 +1388,31 @@ pub(crate) async fn execute_run_workflow_for_tool(
|
||||
"note": "工作流已触发(后台异步执行,完成后自动推进任务状态,失败按退回态回滚)。前端 workflow-event 跟踪进度。"
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 无人值守安全停靠出口(设计 §四):五路非正常终局的统一事件+通知单点
|
||||
// ============================================================
|
||||
|
||||
/// 发停靠事件 AiTaskParked + NeedAction 系统通知(托盘场景补位,复用 notify 基建)。
|
||||
///
|
||||
/// 调用方职责(本函数不做,保持单一只读语义):调用前完成「保存现场(落库)+
|
||||
/// 不保持 generating(conv_state 复位)」两步——各终局路径现状已有
|
||||
/// (teardown_round/stream_relay Fatal/ai_chat_stop),审批超时路径由本批补齐。
|
||||
/// reason 即待办清单:把停止原因与恢复指引写成人话,前端操作卡直接展示。
|
||||
pub(crate) fn emit_task_parked(app: &tauri::AppHandle, conv_id: &str, reason: &str, stop_kind: &str) {
|
||||
let ev = AiChatEvent::AiTaskParked {
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
reason: reason.to_string(),
|
||||
stop_kind: Some(stop_kind.to_string()),
|
||||
};
|
||||
event_bus::emit_ai_chat_event(app, ev);
|
||||
// 托盘场景补位:停靠=需用户回来决策(继续/归档),与审批挂起同用 NeedAction 分组
|
||||
// (主窗聚焦跳过 + 5s 节流,重复终局不会刷屏)。
|
||||
notify::notify_session_event(
|
||||
app,
|
||||
notify::NotifyKind::NeedAction,
|
||||
conv_id,
|
||||
"任务已停靠",
|
||||
reason,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user