Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b68aa63e8 | ||
|
|
c27fd75d20 | ||
|
|
b68dc363d4 | ||
|
|
1920bcbd38 | ||
|
|
c002f0b352 | ||
|
|
438f9692f2 | ||
|
|
c34ad062e7 | ||
|
|
484080ac12 | ||
|
|
143b859727 | ||
|
|
d7e2845881 | ||
|
|
20ec571dbb | ||
|
|
c4ba920cf5 | ||
|
|
468950616b | ||
|
|
bd9031d35d | ||
|
|
365af554da | ||
|
|
0b68c5bed9 | ||
|
|
7594a5b7f3 | ||
|
|
c8f35a7211 | ||
|
|
e222d38e7c | ||
|
|
1eb2ca7dc5 | ||
|
|
3943d68430 | ||
|
|
faa61a9ba5 | ||
|
|
b1d7deece1 |
@@ -10,3 +10,12 @@
|
||||
[target.x86_64-unknown-linux-musl]
|
||||
linker = "rust-lld"
|
||||
rustflags = ["-C", "link-self-contained=y"]
|
||||
|
||||
# ── 主机编译加速(Windows MSVC) ────────────────────────────
|
||||
# rust-lld 比 MSVC link.exe 快 3-5x,且 Rust 自带零安装
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
linker = "rust-lld.exe"
|
||||
|
||||
# 并行编译单元,留部分核心给 Zed UI 和系统
|
||||
[build]
|
||||
jobs = 8
|
||||
|
||||
+134
-17
@@ -1,41 +1,71 @@
|
||||
{
|
||||
// 项目级 Zed 配置:优化 rust-analyzer 性能
|
||||
// ── 项目级 Zed 配置: rust-analyzer 性能极限优化 ──────────────
|
||||
//
|
||||
// 背景:本 workspace 含 src-tauri + 7 个 df-* crate,
|
||||
// flycheck 单次产出 600+ 行 artifact JSON,Zed 解析+渲染会冻结 UI。
|
||||
// 核心策略:关掉保存即 check、限定 check 范围、隔离 target 目录。
|
||||
// workspace: src-tauri + 7 个 df-* crate 的 Rust Monorepo
|
||||
// flcheck 单次 600+ 行 artifact JSON → Zed 解析+渲染冻结 UI
|
||||
// 核心策略: 关保存即 check · 限制检查范围 · 裁切 LSP 负载 · 排 IO 争抢
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
"lsp": {
|
||||
"rust-analyzer": {
|
||||
"initialization_options": {
|
||||
// ── cargo 编译参数 ───────────────────────────────────
|
||||
"cargo": {
|
||||
"features": [], // 不编 default features 之外的重头
|
||||
"allTargets": false, // 只为 host target 解析,跳过 musl/交叉
|
||||
"targetDir": null, // 留空用 cargo 默认,避免双写
|
||||
"features": [],
|
||||
"allTargets": false, // 只为 host target 解析
|
||||
"noDefaultFeatures": false,
|
||||
"targetDir": null,
|
||||
"buildScripts": {
|
||||
"enable": true,
|
||||
"invocationStrategy": "once", // 只跑一次,不反复
|
||||
"rerun": "on-save"
|
||||
}
|
||||
},
|
||||
"extraArgs": []
|
||||
},
|
||||
|
||||
// ── check / flycheck ────────────────────────────────
|
||||
"check": {
|
||||
"onSave": false, // ★ 关键!关闭保存即 check(Zed 卡死主因)
|
||||
"workspace": false, // 即便手动 check 也只查当前 crate
|
||||
"command": "check", // 用 cargo check(非 clippy),首次开销低
|
||||
"onSave": false, // ★ 核心!关保存即 check
|
||||
"workspace": false, // 手动 check 也只查当前 crate
|
||||
"command": "check", // 用 check,非 clippy
|
||||
"invocationStrategy": "once", // 只跑一轮,不反复
|
||||
"features": [],
|
||||
"allTargets": false
|
||||
"allTargets": false,
|
||||
"ignore": [ // 跳过不常用的目录
|
||||
"benches",
|
||||
"examples",
|
||||
"xtask",
|
||||
"tests"
|
||||
]
|
||||
},
|
||||
|
||||
// ── 缓存预热(关掉,省首次启动 CPU 爆发) ──────────────
|
||||
"cachePriming": {
|
||||
"enable": false
|
||||
},
|
||||
|
||||
// ── 诊断 ────────────────────────────────────────────
|
||||
"diagnostics": {
|
||||
"enable": true, // 保留 rust-analyzer 自身诊断
|
||||
"enable": true,
|
||||
"experimental": {
|
||||
"enable": false
|
||||
}
|
||||
},
|
||||
"disabled": [ // 关掉非关键诊断,降 CPU
|
||||
"unresolved-proc-macro",
|
||||
"inactive-code",
|
||||
"macro-error"
|
||||
]
|
||||
},
|
||||
|
||||
// ── proc-macro(必须开,否则 tauri 全报红) ────────────
|
||||
"procMacro": {
|
||||
"enable": true, // 必须开,否则 tauri::generate_context 等会报红
|
||||
"enable": true,
|
||||
"attributes": {
|
||||
"enable": true
|
||||
}
|
||||
},
|
||||
|
||||
// ── 符号检索 ────────────────────────────────────────
|
||||
"workspace": {
|
||||
"symbol": {
|
||||
"search": {
|
||||
@@ -44,21 +74,108 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── 补全(限流减少 LSP 计算量) ──────────────────────
|
||||
"completion": {
|
||||
"autoclose": true,
|
||||
"autoimport": {
|
||||
"enable": true
|
||||
},
|
||||
"callable": {
|
||||
"snippets": "fill_arguments"
|
||||
},
|
||||
"fullFunction": {
|
||||
"enable": false
|
||||
},
|
||||
"limit": 200, // 补全条目上限
|
||||
"postfix": {
|
||||
"enable": false // 关 postfix snippet 减少候选
|
||||
}
|
||||
},
|
||||
|
||||
// ── 内联提示(大文件里关掉多数减少 UI 重绘) ──────────
|
||||
"inlayHints": {
|
||||
"bindingModeHints": {
|
||||
"enable": false
|
||||
},
|
||||
"chainingHints": {
|
||||
"enable": false
|
||||
},
|
||||
"closingBraceHints": {
|
||||
"enable": false
|
||||
},
|
||||
"closureReturnTypeHints": {
|
||||
"enable": "never"
|
||||
},
|
||||
"constructorHints": "never",
|
||||
"discriminantHints": {
|
||||
"enable": "never"
|
||||
},
|
||||
"expressionAdjustmentHints": {
|
||||
"mode": "never"
|
||||
},
|
||||
"implicitDrops": "never",
|
||||
"lifetimeElisionHints": {
|
||||
"enable": "skip_trivial"
|
||||
},
|
||||
"maxLength": 100,
|
||||
"parameterHints": {
|
||||
"enable": true
|
||||
},
|
||||
"reborrowHints": {
|
||||
"enable": "never"
|
||||
},
|
||||
"renderColons": true,
|
||||
"typeHints": {
|
||||
"enable": true,
|
||||
"hideNamedConstructor": false
|
||||
}
|
||||
},
|
||||
|
||||
// ── 语义高亮(大文件关掉字符串部分减少重绘) ──────────
|
||||
"semanticHighlights": {
|
||||
"strings": {
|
||||
"enable": false
|
||||
}
|
||||
},
|
||||
|
||||
// ── 高亮关联(关掉减少 LSP 图构建) ──────────────────
|
||||
"highlightRelated": {
|
||||
"references": {
|
||||
"enable": false
|
||||
}
|
||||
},
|
||||
|
||||
// ── 测试解释器(关掉) ───────────────────────────────
|
||||
"interpret": {
|
||||
"tests": false
|
||||
},
|
||||
|
||||
// ── rust-analyzer 文件扫描排除 ─────────────────────
|
||||
"files": {
|
||||
"excludeDirs": [".cargo", "target", "target-musl", "node_modules", "dist", ".zed"]
|
||||
"excludeDirs": [
|
||||
".cargo",
|
||||
"target",
|
||||
"target-musl",
|
||||
"node_modules",
|
||||
"dist",
|
||||
".zed",
|
||||
".git"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Zed 文件监视排除(减少 File Watcher IO 争抢) ────────────
|
||||
// 和全局 ~/.config/zed/settings.json 的 file_scan_exclusions 叠加
|
||||
"file_scan_exclusions": [
|
||||
"**/target/**",
|
||||
"**/target-musl/**",
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/.cargo/**",
|
||||
"**/.git/**",
|
||||
"**/build/**"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 模型能力数据模型 — F-01 阶段1
|
||||
//! 模型能力数据模型。
|
||||
//!
|
||||
//! 单模型的完整描述(4 维度 + 路由控制)。纯数据结构,零 IO / 零 DB 依赖。
|
||||
//! df-storage 反序列化 DB 行时直接消费本模块类型;df-ai 探测器/路由器阶段 2-4 再用。
|
||||
@@ -45,9 +45,9 @@ pub enum Capability {
|
||||
///
|
||||
/// 序列化为 snake_case。原设计文档 §2.2 列 Free/Low/Medium/High,但 Free 变体形同虚设:
|
||||
/// 预设表(presets/models.json)0 条 free + 启发式从不赋 Free(只 Low/Medium/High),
|
||||
/// B-260618-05 删除 Free 死档变体。
|
||||
/// Free 死档变体已删除。
|
||||
///
|
||||
/// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 cost_tier,
|
||||
/// 路由已解耦:provider /v1/models API 不返回 cost_tier,
|
||||
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
|
||||
/// (原 §6.1 `cost_tier <= max_cost` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
@@ -63,7 +63,7 @@ pub enum CostTier {
|
||||
/// 设计文档 §2.2:Lite/Standard/Plus/Ultra。序列化为 snake_case。
|
||||
/// 派生 Ord:Lite < Standard < Plus < Ultra。
|
||||
///
|
||||
/// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 intelligence,
|
||||
/// 路由已解耦:provider /v1/models API 不返回 intelligence,
|
||||
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
|
||||
/// (原 §6.1 `intelligence >= min_intelligence` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
@@ -302,7 +302,7 @@ mod tests {
|
||||
assert_eq!(serde_json::from_str::<CostTier>(expected).unwrap(), variant);
|
||||
}
|
||||
// Ord:Low < Medium < High(枚举序,路由已解耦不再用于过滤,保留供未来判别源)。
|
||||
// Free 变体已删除(B-260618-05:预设/启发式从不赋 Free,死档)。
|
||||
// Free 变体已删除(预设/启发式从不赋 Free,死档)。
|
||||
assert!(CostTier::Low < CostTier::Medium);
|
||||
assert!(CostTier::Medium < CostTier::High);
|
||||
}
|
||||
|
||||
@@ -90,8 +90,8 @@ impl ChatMessage {
|
||||
|
||||
/// 是否处于 active 态(status 为 None 或 "active")。其余状态一律 false。
|
||||
///
|
||||
/// 正面白名单(F-15 §3.2):仅认 None / "active",新状态
|
||||
/// (如阶段2 引入的 "archived_segment" / "compressed")自动落入不 active 分支,
|
||||
/// 正面白名单:仅认 None / "active",新状态
|
||||
/// (如"archived_segment" / "compressed")自动落入不 active 分支,
|
||||
/// 无需每加一个状态就来这里改。当前取值 None/Some("active")/Some("truncated")
|
||||
/// 行为与旧反面排除完全等价(None=true / "active"=true / "truncated"=false)。
|
||||
pub fn is_active(&self) -> bool {
|
||||
@@ -155,10 +155,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn is_active_whitelist() {
|
||||
// F-15 §3.2 正面白名单:仅 None / "active" 为 true,其余一律 false。
|
||||
// 正面白名单:仅 None / "active" 为 true,其余一律 false。
|
||||
// 零行为变化:None / "active" / "truncated" 与旧反面排除完全等价;
|
||||
// "archived_segment" / "compressed"(阶段2 引入,当前代码未赋值)由白名单
|
||||
// matches! 只认 None/active 自动落入 false,面向未来验证。
|
||||
// "archived_segment" / "compressed" 由白名单 matches! 只认 None/active 自动落入 false。
|
||||
|
||||
// None(构造默认值,向前兼容老 JSON)
|
||||
let m = ChatMessage::user("hi");
|
||||
@@ -174,18 +173,18 @@ mod tests {
|
||||
m.status = Some(MessageStatus::Truncated);
|
||||
assert!(!m.is_active(), "truncated 应不 active");
|
||||
|
||||
// "archived_segment" — 阶段2 待引入,白名单自动隔离
|
||||
// "archived_segment" — 白名单自动隔离
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some(MessageStatus::ArchivedSegment);
|
||||
assert!(!m.is_active(), "archived_segment 应不 active(白名单隔离)");
|
||||
|
||||
// "compressed" — 阶段2 待引入,白名单自动隔离
|
||||
// "compressed" — 白名单自动隔离
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some(MessageStatus::Compressed);
|
||||
assert!(!m.is_active(), "compressed 应不 active(白名单隔离)");
|
||||
}
|
||||
|
||||
// ---------- F-260614-05 Phase 2a ContentPart ----------
|
||||
// ---------- ContentPart ----------
|
||||
|
||||
/// 老 JSON(无 parts 字段)反序列化时 parts 应为 None(向前兼容)
|
||||
#[test]
|
||||
@@ -333,7 +332,7 @@ mod tests {
|
||||
assert_eq!(deserialized.reasoning_content, Some("thinking process".to_string()));
|
||||
}
|
||||
|
||||
// ---------- F-260619-04 消息级溯源:id 字段 ----------
|
||||
// ---------- 消息级溯源:id 字段 ----------
|
||||
|
||||
/// 所有便捷构造函数默认生成非 None 的 id(ULID 风格)
|
||||
#[test]
|
||||
|
||||
@@ -43,7 +43,7 @@ pub struct CompletionRequest {
|
||||
|
||||
/// 多模态消息内容片。Text 片为字符串;Image 片可走 url 或 base64(二选一,base64 非空时 url 忽略)。
|
||||
///
|
||||
/// F-260614-05 Phase 2a 后端:ContentPart 作为 `ChatMessage.parts` 的元素类型。
|
||||
/// ContentPart 作为 `ChatMessage.parts` 的元素类型。
|
||||
/// 设计上 `content: String`(纯文本主载荷)保持不变,多模态片挂在 `parts`:
|
||||
/// 这样未接入多模态的调用方(audit/title/commands/knowledge_inject 等读 content 当字符串)
|
||||
/// 零回归,避免一次性改全仓。provider 转换层在 `has_image()` 为真时把 parts 透传给
|
||||
@@ -79,13 +79,13 @@ pub enum ContentPart {
|
||||
pub struct ChatMessage {
|
||||
/// 消息全局唯一 ID。用于消息级溯源(source_ref / audit message_id / idea source)。
|
||||
/// 构造时由 `new_message_id()` 生成;老 JSON 反序列化为 None(向前兼容)。
|
||||
/// 消息拆分存储(F-260619-03)后,此 ID 即 `ai_messages.id` 列主键。
|
||||
/// 消息拆分存储后,此 ID 即 `ai_messages.id` 列主键。
|
||||
/// 临时/派生消息(如 title 摘要)可显式赋 None(不溯源不落库)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
pub role: MessageRole,
|
||||
pub content: String,
|
||||
/// 多模态内容片(F-260614-05 Phase 2a)。
|
||||
/// 多模态内容片。
|
||||
///
|
||||
/// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。
|
||||
/// `Some(含 Image 片)` → 多模态消息,provider 在 `has_image()` 为真时把 parts
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! 声明式工具注册基础设施(tool_registry 拆分第一步)
|
||||
//!
|
||||
//! 背景:`tool_registry.rs` 4224 行,每加一个工具手写一段
|
||||
//! `registry.register(name, desc, object_schema(...), RiskLevel::X, { ... Box::new ... })`,
|
||||
//! 5 段重复样板 + 闭包捕获 + 缩进极易出错。本模块提供声明式宏 `declare_tool!`,
|
||||
//! 把工具定义收敛为「名字 / 描述 / schema / 风险 / handler 块」五要素一行式声明。
|
||||
//!
|
||||
//! 设计取舍(三选一权衡):
|
||||
//! - A. proc-macro `#[ai_tool]` 属性宏:需新建 proc-macro crate + syn/quote 重依赖,违反
|
||||
//! 「不引新依赖」。否决。
|
||||
//! - B. `inventory` crate 自动收集:新外部依赖 + 拉入 linkme/ctor 运行期注册语义,
|
||||
//! 与现有 `register_*(&mut registry)` 命令式收集并存需双重 source-of-truth。否决。
|
||||
//! - C. **`macro_rules!` 声明式宏(本方案)**:零新依赖(纯 std `macro_rules!`),
|
||||
//! 展开为等价的 `registry.register(...)` 调用——与现有 48 个 `register` 调用**完全并存**,
|
||||
//! 旧工具零改动,新工具可选声明式。handler 块就地书写,`$db`/`$registry` 等捕获变量
|
||||
//! 原样透传,语义 1:1 等价(schema/risk/handler 同源不变)。
|
||||
//!
|
||||
//! 试点:`list_projects` 已迁至声明式(见 commands/ai/tools/list_projects.rs),验证编译过 +
|
||||
//! 行为等价 + 48 工具基线不破。其余 47 个工具后续批次渐进迁移,不在此步。
|
||||
//!
|
||||
//! 宏展开示例(输入):
|
||||
//! ```ignore
|
||||
//! declare_tool!(registry, db: Arc<Database>, "list_projects",
|
||||
//! "列出所有项目...", RiskLevel::Low,
|
||||
//! schema: object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]),
|
||||
//! args => {
|
||||
//! let repo = ProjectRepo::new(&db);
|
||||
//! let items = repo.list_active().await?;
|
||||
//! // ...
|
||||
//! Ok(json!({ "items": items, ... }))
|
||||
//! });
|
||||
//! ```
|
||||
//! 展开后等价于现有手写的:
|
||||
//! ```ignore
|
||||
//! registry.register(
|
||||
//! "list_projects", "...",
|
||||
//! object_schema(vec![...]), RiskLevel::Low,
|
||||
//! { let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
//! let db = db.clone();
|
||||
//! Box::pin(async move { /* handler body */ })
|
||||
//! })},
|
||||
//! );
|
||||
//! ```
|
||||
|
||||
/// 声明式注册一个 AI 工具到 `$registry`。
|
||||
///
|
||||
/// 与手写 `registry.register(name, desc, schema, risk, handler)` 语义 1:1 等价,
|
||||
/// 仅消除闭包包装样板(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone(); Box::pin(async move { ... }) }) }`)。
|
||||
///
|
||||
/// 形参:
|
||||
/// - `$registry`: `&mut AiToolRegistry` 注册表句柄。
|
||||
/// - `$capture: $cap_ty`: handler 需捕获的外部变量(如 `db: Arc<Database>`)。宏自动 clone 进闭包,
|
||||
/// handler body 内以 `$capture` 名访问。无捕获工具用 `_` 占位(并保证 body 不引用它)。
|
||||
/// - `$name`: 工具名 `&str`。
|
||||
/// - `$desc`: 工具描述 `&str`(发给 LLM)。
|
||||
/// - `$risk`: `RiskLevel`(如 `RiskLevel::Low`)。
|
||||
/// - `$schema`: 参数 JSON Schema(`serde_json::Value`),常用 `object_schema(...)`。
|
||||
/// - `$handler`: 一个 **block 表达式**(花括号体),返回 `anyhow::Result<serde_json::Value>`。
|
||||
/// 体内在 `$args`(serde_json::Value)与 `$capture`(克隆后的捕获变量)上工作。
|
||||
/// 宏负责把它包进 `async move { ... }` 并 `Box::pin`——故调用方只写「同步语义的体」,
|
||||
/// 不写 `async move`/`Box::pin`/`Box::new` 三层样板。
|
||||
///
|
||||
/// 注意:此宏不替代 `register`,而是包装它——`register` 仍是 `AiToolRegistry` 的唯一注册入口,
|
||||
/// 宏仅是语法糖。现有 48 个手写 `register` 调用不动,新工具改用 `declare_tool!`。
|
||||
#[macro_export]
|
||||
macro_rules! declare_tool {
|
||||
(
|
||||
$registry:expr,
|
||||
$capture:ident : $cap_ty:ty,
|
||||
$name:expr,
|
||||
$desc:expr,
|
||||
$risk:expr,
|
||||
schema: $schema:expr,
|
||||
$args:ident => $handler:block
|
||||
) => {{
|
||||
let __cap: $cap_ty = $capture.clone();
|
||||
$registry.register(
|
||||
$name, $desc, $schema, $risk,
|
||||
{
|
||||
let $capture = __cap.clone();
|
||||
::std::boxed::Box::new(move |$args: ::serde_json::Value| {
|
||||
let $capture = $capture.clone();
|
||||
::std::boxed::Box::pin(async move {
|
||||
let $capture: $cap_ty = $capture;
|
||||
$handler
|
||||
})
|
||||
as ::std::pin::Pin<::std::boxed::Box<
|
||||
dyn ::std::future::Future<Output = ::anyhow::Result<::serde_json::Value>>
|
||||
+ ::std::marker::Send,
|
||||
>>
|
||||
})
|
||||
},
|
||||
);
|
||||
}};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||||
|
||||
/// 声明式宏注册的工具与手写 register 行为等价(name/schema/risk/执行结果一致)。
|
||||
/// 这是「现有工具零回归 + 语义等价」的最小验证(试点 list_projects 迁移的微观镜像)。
|
||||
#[tokio::test]
|
||||
async fn declare_tool_equivalent_to_register() {
|
||||
let mut reg = AiToolRegistry::new();
|
||||
|
||||
// 声明式注册(新基础设施)
|
||||
let counter: std::sync::Arc<std::sync::atomic::AtomicUsize> =
|
||||
std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
declare_tool!(
|
||||
reg,
|
||||
counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
"echo_decl",
|
||||
"声明式 echo 工具",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![("msg", "string", true)]),
|
||||
args => {
|
||||
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let msg = args["msg"].as_str().unwrap_or("");
|
||||
Ok(serde_json::json!({ "echo": msg }))
|
||||
}
|
||||
);
|
||||
|
||||
// 等价断言:定义层(name 存在 / risk 正确 / schema 透传)
|
||||
assert_eq!(reg.len(), 1, "声明式宏应注册 1 个工具");
|
||||
let tool = reg.get("echo_decl").expect("echo_decl 应已注册");
|
||||
assert_eq!(tool.risk_level, RiskLevel::Low);
|
||||
assert_eq!(tool.definition.function.name, "echo_decl");
|
||||
assert_eq!(tool.definition.function.parameters["properties"]["msg"]["type"], "string");
|
||||
|
||||
// 执行等价:handler 收到 args、捕获变量可访问、返回 handler body 的 Value
|
||||
let out = reg.execute("echo_decl", serde_json::json!({ "msg": "hi" })).await.unwrap();
|
||||
assert_eq!(out["echo"], "hi");
|
||||
assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 1, "捕获变量应在执行时递增");
|
||||
}
|
||||
|
||||
/// 无捕获工具(纯计算/常量返回)用占位 capture,handler 不引用它。
|
||||
#[tokio::test]
|
||||
async fn declare_tool_no_capture() {
|
||||
let mut reg = AiToolRegistry::new();
|
||||
let dummy: std::sync::Arc<()> = std::sync::Arc::new(()); // 无真实捕获,占位
|
||||
declare_tool!(
|
||||
reg,
|
||||
dummy: std::sync::Arc<()>,
|
||||
"const_tool",
|
||||
"无捕获常量工具",
|
||||
RiskLevel::Medium,
|
||||
schema: object_schema(vec![]),
|
||||
_args => { Ok(serde_json::json!({ "ok": true })) }
|
||||
);
|
||||
let out = reg.execute("const_tool", serde_json::json!({})).await.unwrap();
|
||||
assert_eq!(out["ok"], true);
|
||||
}
|
||||
}
|
||||
@@ -137,11 +137,11 @@ impl AnthropicCompatProvider {
|
||||
}
|
||||
MessageRole::User => {
|
||||
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
|
||||
// F-260614-05 Phase 2a: 多模态 user 消息 → content blocks 数组(text/image)。
|
||||
// 多模态 user 消息 → content blocks 数组(text/image)。
|
||||
// 含图时把 content + parts 拍平成 blocks:Text 片 → {type:text},
|
||||
// Image 片 → {type:image, source:{type:base64, media_type, data}}。
|
||||
// Anthropic 协议要求 image 必须内嵌 base64(不接受 URL 直传)。
|
||||
// 现状:前端(Phase2b)只产 base64 模式图片片,url 模式当前不可达。
|
||||
// 现状:前端只产 base64 模式图片片,url 模式当前不可达。
|
||||
// 未来若加 url 图片输入,必须在 commands 层补 url→base64 预拉
|
||||
//(provider 不发额外 HTTP),否则下方兜底会发空 data 致 Anthropic 400。
|
||||
// 纯文本消息(无图)保持原字符串简写,与现有端点零回归。
|
||||
@@ -149,36 +149,7 @@ impl AnthropicCompatProvider {
|
||||
let blocks: Vec<serde_json::Value> = m
|
||||
.flattened_parts()
|
||||
.into_iter()
|
||||
.map(|p| match p {
|
||||
crate::provider::ContentPart::Text { text } => serde_json::json!({
|
||||
"type": "text",
|
||||
"text": text,
|
||||
}),
|
||||
crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => {
|
||||
// p 已被 match 取得所有权,直接 move media_type/base64 避免大 base64 clone。
|
||||
let mt = media_type.unwrap_or_else(|| "image/png".into());
|
||||
let data = base64.unwrap_or_else(|| {
|
||||
// 完整性兜底:当前 url 模式不可达(前端 Phase2b 只产 base64 图片片)。
|
||||
// 若未来接入 url 图片输入而 commands 层未补 url→base64 预拉,
|
||||
// 此处会发空 data 致 Anthropic 400,warn 留痕但不阻塞(避免静默吞数据)。
|
||||
if url.is_some() {
|
||||
warn!(
|
||||
url = ?url,
|
||||
"Anthropic user 消息含 Image(url) 但 base64 缺失,将发空 data(commands 层未补 url→base64 预拉)"
|
||||
);
|
||||
}
|
||||
String::new()
|
||||
});
|
||||
serde_json::json!({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": mt,
|
||||
"data": data,
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.map(Self::content_part_to_block)
|
||||
.collect();
|
||||
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
|
||||
} else {
|
||||
@@ -193,7 +164,7 @@ impl AnthropicCompatProvider {
|
||||
}
|
||||
if let Some(calls) = &m.tool_calls {
|
||||
for tc in calls {
|
||||
// B-260618-25: arguments 非法 JSON(流式中断残留 / ToolCall::new 默认空串)
|
||||
// arguments 非法 JSON(流式中断残留 / ToolCall::new 默认空串)
|
||||
// → 空 object 兜底。Anthropic/GLM 要求 tool_use.input 必为 object,
|
||||
// null 直触发 1214「messages 参数非法」。
|
||||
let input: serde_json::Value = serde_json::from_str(&tc.function.arguments)
|
||||
@@ -215,13 +186,13 @@ impl AnthropicCompatProvider {
|
||||
}
|
||||
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
|
||||
|
||||
// B-260619-03: 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user
|
||||
// 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user
|
||||
// 触发 GLM 1214。场景:drainQueue 续发(前一轮以 tool_result 结尾 + 新 user)→ flush 把
|
||||
// tool_result 转成 user 后紧跟 push 新 user → 连续两 user。合并成一条 user 含
|
||||
// [tool_result..., text] blocks(Anthropic 允许一条 user 多 blocks),打破恶性循环。
|
||||
Self::merge_consecutive_users(&mut messages);
|
||||
|
||||
// B-260626-01: 保证首条为 user(Anthropic 协议硬性要求 messages[0].role == "user")。
|
||||
// 保证首条为 user(Anthropic 协议硬性要求 messages[0].role == "user")。
|
||||
// 上游绕过 ContextManager::sanitize_messages 的调用方(标题生成 / 知识注入 / 工作流 AI
|
||||
// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、
|
||||
// 续发或历史片段截取时,真正的首条 user 已被裁剪/压缩掉,直接发触发 precheck
|
||||
@@ -237,7 +208,7 @@ impl AnthropicCompatProvider {
|
||||
.map(|d| AnthropicToolDef {
|
||||
name: d.function.name,
|
||||
description: Some(d.function.description).filter(|s| !s.is_empty()),
|
||||
// B-260618-25: input_schema 非 object(未来误用)→ 兜底 {"type":"object"},
|
||||
// input_schema 非 object(未来误用)→ 兜底 {"type":"object"},
|
||||
// 防 Anthropic 拒非法 tool schema(当前全走 object_schema 恒 object,纯防御)。
|
||||
input_schema: if d.function.parameters.is_object() {
|
||||
d.function.parameters
|
||||
@@ -260,6 +231,43 @@ impl AnthropicCompatProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个 ContentPart → Anthropic content block(text/image)。
|
||||
/// - Text 片 → {type:text, text}
|
||||
/// - Image 片 → {type:image, source:{type:base64, media_type, data}}
|
||||
/// base64 内嵌;url 模式当前不可达,兜底发空 data + warn(保留原行为)。
|
||||
/// match 取得 p 所有权后直接 move media_type/base64,避免大 base64 clone。
|
||||
fn content_part_to_block(p: crate::provider::ContentPart) -> serde_json::Value {
|
||||
match p {
|
||||
crate::provider::ContentPart::Text { text } => serde_json::json!({
|
||||
"type": "text",
|
||||
"text": text,
|
||||
}),
|
||||
crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => {
|
||||
let mt = media_type.unwrap_or_else(|| "image/png".into());
|
||||
let data = base64.unwrap_or_else(|| {
|
||||
// 完整性兜底:当前 url 模式不可达(前端只产 base64 图片片)。
|
||||
// 若未来接入 url 图片输入而 commands 层未补 url→base64 预拉,
|
||||
// 此处会发空 data 致 Anthropic 400,warn 留痕但不阻塞(避免静默吞数据)。
|
||||
if url.is_some() {
|
||||
warn!(
|
||||
url = ?url,
|
||||
"Anthropic user 消息含 Image(url) 但 base64 缺失,将发空 data(commands 层未补 url→base64 预拉)"
|
||||
);
|
||||
}
|
||||
String::new()
|
||||
});
|
||||
serde_json::json!({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": mt,
|
||||
"data": data,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 将累积的 tool_result 块作为一条 user 消息 flush 进消息列表
|
||||
fn flush_tool_results(
|
||||
messages: &mut Vec<serde_json::Value>,
|
||||
@@ -272,7 +280,7 @@ impl AnthropicCompatProvider {
|
||||
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
|
||||
}
|
||||
|
||||
/// B-260619-03: 合并相邻 user 消息为一条(content 拼成 blocks 数组)。
|
||||
/// 合并相邻 user 消息为一条(content 拼成 blocks 数组)。
|
||||
/// Anthropic 协议要求 user/assistant 严格交替,连续 user 触发 1214。
|
||||
/// 触发场景:flush_tool_results 把 tool_result 转 user 后紧跟新 user(drainQueue 续发,
|
||||
/// 前一轮以 tool_result 结尾)。合并成一条 user 含 [tool_result..., text] blocks,合法。
|
||||
@@ -305,7 +313,7 @@ impl AnthropicCompatProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// B-260626-01: 保证 messages 首条为 user(Anthropic 协议硬性要求 messages[0].role=="user")。
|
||||
/// 保证 messages 首条为 user(Anthropic 协议硬性要求 messages[0].role=="user")。
|
||||
///
|
||||
/// 上游绕过 `ContextManager::sanitize_messages` 的调用方(标题生成 / 知识注入 / 工作流 AI
|
||||
/// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、续发
|
||||
@@ -342,7 +350,7 @@ impl AnthropicCompatProvider {
|
||||
}
|
||||
|
||||
/// 生成 messages 诊断摘要(每条 role + content 形态 + tool 标记),不含敏感数据。
|
||||
/// B-260618-27: 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。
|
||||
/// 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。
|
||||
fn summarize_messages(messages: &[serde_json::Value]) -> String {
|
||||
let lines: Vec<String> = messages
|
||||
.iter()
|
||||
@@ -385,7 +393,7 @@ impl AnthropicCompatProvider {
|
||||
format!("{} msgs: {}", lines.len(), lines.join(" | "))
|
||||
}
|
||||
|
||||
/// B-260618-27: 协议预检——扫 messages 发现确定非法形态,命中返回原因(仅诊断不修复)。
|
||||
/// 协议预检——扫 messages 发现确定非法形态,命中返回原因(仅诊断不修复)。
|
||||
/// 覆盖:首条非 user / 连续同 role / tool_use input 非 object / 空 content / orphan tool_result
|
||||
/// (tool_use_id 无前置 tool_use,常见于裁剪/过滤后 assistant 被删但 tool_result 留)。
|
||||
fn precheck_messages(messages: &[serde_json::Value]) -> Result<(), String> {
|
||||
@@ -415,25 +423,7 @@ impl AnthropicCompatProvider {
|
||||
return Err(format!("#{} user content 空数组", i));
|
||||
}
|
||||
for b in blocks {
|
||||
match b.get("type").and_then(|t| t.as_str()).unwrap_or("") {
|
||||
"tool_use" => {
|
||||
let id = b.get("id").and_then(|t| t.as_str()).unwrap_or("");
|
||||
tool_use_ids.push(id);
|
||||
if !b.get("input").map(|v| v.is_object()).unwrap_or(false) {
|
||||
return Err(format!("#{} tool_use input 非 object", i));
|
||||
}
|
||||
}
|
||||
"tool_result" => {
|
||||
let tid = b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or("");
|
||||
if !tid.is_empty() && !tool_use_ids.contains(&tid) {
|
||||
return Err(format!(
|
||||
"#{} orphan tool_result(tid={} 无前置 tool_use)",
|
||||
i, tid
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Self::check_block(b, i, &mut tool_use_ids)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -442,6 +432,36 @@ impl AnthropicCompatProvider {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验单个 content block(precheck_messages 内部用)。
|
||||
/// - tool_use: 收集 id,校验 input 为 object
|
||||
/// - tool_result: 校验 tool_use_id 有前置 tool_use(非 orphan)
|
||||
fn check_block<'a>(
|
||||
b: &'a serde_json::Value,
|
||||
idx: usize,
|
||||
tool_use_ids: &mut Vec<&'a str>,
|
||||
) -> Result<(), String> {
|
||||
match b.get("type").and_then(|t| t.as_str()).unwrap_or("") {
|
||||
"tool_use" => {
|
||||
let id = b.get("id").and_then(|t| t.as_str()).unwrap_or("");
|
||||
tool_use_ids.push(id);
|
||||
if !b.get("input").map(|v| v.is_object()).unwrap_or(false) {
|
||||
return Err(format!("#{} tool_use input 非 object", idx));
|
||||
}
|
||||
}
|
||||
"tool_result" => {
|
||||
let tid = b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or("");
|
||||
if !tid.is_empty() && !tool_use_ids.contains(&tid) {
|
||||
return Err(format!(
|
||||
"#{} orphan tool_result(tid={} 无前置 tool_use)",
|
||||
idx, tid
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 统一鉴权头:x-api-key + anthropic-version
|
||||
fn auth_headers(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
rb.header("x-api-key", &self.api_key)
|
||||
@@ -457,7 +477,7 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
req.stream = false;
|
||||
let body = self.convert_request(req);
|
||||
|
||||
// B-260618-27: 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
|
||||
// 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
|
||||
if let Err(reason) = Self::precheck_messages(&body.messages) {
|
||||
let summary = Self::summarize_messages(&body.messages);
|
||||
warn!(%reason, %summary, "Anthropic messages 协议预检失败");
|
||||
@@ -466,9 +486,8 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
|
||||
debug!(model = %body.model, "Anthropic 同步调用");
|
||||
|
||||
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定。
|
||||
// 同时补 FR-R4 遗漏: Anthropic 同步路径此前无单请求 timeout(建连后挂起会无限 hang),
|
||||
// 此处加 60s timeout,与 OpenAI 路径对齐。
|
||||
// 指数退避重试: 包裹 send + 状态码判定。
|
||||
// 同时补单请求 timeout(Anthropic 同步路径无 timeout 会 hang),此处加 60s,与 OpenAI 路径对齐。
|
||||
let label = format!("Anthropic[{}]", body.model);
|
||||
retry_with_backoff(&label, move |_| {
|
||||
let client = self.client.clone();
|
||||
@@ -488,7 +507,7 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
let resp = match rb.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// B-260618-26: 记 reqwest 错误源因链(is_*/source)。原仅 Display
|
||||
// 记 reqwest 错误源因链(is_*/source)。原仅 Display
|
||||
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
|
||||
tracing::error!(
|
||||
is_timeout = e.is_timeout(),
|
||||
@@ -580,7 +599,7 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
req.stream = true;
|
||||
let body = self.convert_request(req);
|
||||
|
||||
// B-260618-27: 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
|
||||
// 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
|
||||
if let Err(reason) = Self::precheck_messages(&body.messages) {
|
||||
let summary = Self::summarize_messages(&body.messages);
|
||||
warn!(%reason, %summary, "Anthropic messages 协议预检失败");
|
||||
@@ -589,9 +608,9 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
|
||||
debug!(model = %body.model, "Anthropic 流式调用");
|
||||
|
||||
// BUG-2026-07-07: send 阶段需 timeout 防 hang(实测 GLM 偶发建连后长时间不返回)。
|
||||
// send 阶段需 timeout 防 hang(实测 GLM 偶发建连后长时间不返回)。
|
||||
// 注意:不能用 reqwest 的 .timeout()——它是整个请求(含 body 读取)的总超时,
|
||||
// 流式长生成任务会被误砍(build_provider_client 注释已明确)。改用 tokio::time::timeout
|
||||
// 流式长生成任务会被误砍。改用 tokio::time::timeout
|
||||
// 包裹 send().await,只管建连+首响应头,不管后续 body 读取(后续由 stream_llm idle timeout 兜底)。
|
||||
// 60s 选型:正常 send(建连+收 200 headers)<5s,60s 足够宽容。
|
||||
let send_future = self
|
||||
@@ -638,9 +657,9 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text);
|
||||
}
|
||||
|
||||
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream(同 openai_compat)。
|
||||
// 原生 SSE 解析器替代 eventsource-stream(同 openai_compat)。
|
||||
let mut usage_accum: Option<TokenUsage> = None;
|
||||
// B-260618-28: MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。
|
||||
// MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。
|
||||
let messages_summary = Self::summarize_messages(&body.messages);
|
||||
|
||||
let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
|
||||
@@ -870,7 +889,7 @@ mod tests {
|
||||
assert!(acc.is_none());
|
||||
}
|
||||
|
||||
// ---------- F-260614-05 Phase 2a 多模态 convert_request ----------
|
||||
// ---------- 多模态 convert_request ----------
|
||||
|
||||
/// 含图 user 消息 → content blocks(text + image source.base64)
|
||||
#[test]
|
||||
@@ -931,7 +950,7 @@ mod tests {
|
||||
assert_eq!(user_msg.get("content").and_then(|c| c.as_str()), Some("hello"));
|
||||
}
|
||||
|
||||
// ---------- B-260626-01: ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
|
||||
// ---------- ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
|
||||
|
||||
/// 辅助:构造 assistant(tool_use) 消息
|
||||
fn msg_assistant_with_tool_use(text: &str, tool_id: &str, tool_name: &str) -> ChatMessage {
|
||||
@@ -941,7 +960,7 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// B-260626-01: 精确复现线上 bug——多轮 [asst(tool_use), tool_result] 链,首条 assistant。
|
||||
/// 精确复现线上场景——多轮 [asst(tool_use), tool_result] 链,首条 assistant。
|
||||
/// 补一条 user 占位后:首条 user、tool_use/tool_result 配对完整保留、precheck 通过。
|
||||
/// (原"砍"策略会把每对三元组砍掉,多轮砍到空,丢失全部工具调用历史——"补"策略零丢失。)
|
||||
#[test]
|
||||
@@ -981,7 +1000,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// B-260626-01: 首条 assistant 无 tool_use → 补占位,首条 user,原上下文保留。
|
||||
/// 首条 assistant 无 tool_use → 补占位,首条 user,原上下文保留。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_plain_assistant() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
@@ -1007,7 +1026,7 @@ mod tests {
|
||||
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
|
||||
}
|
||||
|
||||
/// B-260626-01: 正常序列(user 开头)不补占位——零回归验证。
|
||||
/// 正常序列(user 开头)不补占位——零回归验证。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_normal_sequence_unchanged() {
|
||||
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
|
||||
@@ -1033,7 +1052,7 @@ mod tests {
|
||||
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
|
||||
}
|
||||
|
||||
/// B-260626-01: 线上 3 轮工具调用场景(6 条 [asst(tu),tool_result]×3,首条 assistant)。
|
||||
/// 线上 3 轮工具调用场景(6 条 [asst(tu),tool_result]×3,首条 assistant)。
|
||||
/// 补一个 user 占位后全部保留,验证多轮链不丢数据、precheck 通过(原"砍"策略此场景砍到空)。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_three_round_chain() {
|
||||
@@ -1070,7 +1089,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// B-260626-01: 空 messages(异常会话经 sanitize 清空)→ convert 补 1 条 user 占位,
|
||||
/// 空 messages(异常会话经 sanitize 清空)→ convert 补 1 条 user 占位,
|
||||
/// 避免发空 messages 触发 precheck "messages 为空"(降级让会话能继续)。
|
||||
#[test]
|
||||
fn anthropic_ensure_leading_user_empty_messages_gets_placeholder() {
|
||||
|
||||
@@ -20,7 +20,7 @@ fn cfg(max_tokens: u32) -> ContextConfig {
|
||||
|
||||
#[test]
|
||||
fn estimate_message_counts_parts_tokens() {
|
||||
// F-260614-05 多模态回归:含图消息的大段 base64 必须计入 token 预算,
|
||||
// 多模态回归:含图消息的大段 base64 必须计入 token 预算,
|
||||
// 否则 history_tokens 严重低估 → build_for_request 不裁剪 → provider 超限。
|
||||
let est = TokenEstimator::default();
|
||||
|
||||
@@ -263,7 +263,7 @@ fn system_over_budget_trims_to_protect_zone() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── F-15 阶段1 辅助方法单测 ──
|
||||
// ── 辅助方法单测 ──
|
||||
|
||||
#[test]
|
||||
fn compress_old_messages_marks_compressed_and_returns_refs() {
|
||||
@@ -430,7 +430,7 @@ fn build_eviction_units_keeps_triplet_atomic_public() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── F-260619-04 P1 消息级溯源:last_assistant/last_user message_id ──
|
||||
// ── 消息级溯源:last_assistant/last_user message_id ──
|
||||
|
||||
#[test]
|
||||
fn last_assistant_message_id_returns_latest() {
|
||||
@@ -717,3 +717,223 @@ fn topic_marker_take_is_idempotent_and_clears() {
|
||||
// 第二次 take 必 None
|
||||
assert!(mgr.take_topic_marker().is_none(), "marker 一次性消费, 二次 take 必空");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// msg-split-phase1:增量写路径基线(persisted_msg_count)+ 全量重写标志(needs_full_rewrite)
|
||||
// ============================================================
|
||||
//
|
||||
// 这些测试验证 save_conversation 增量优化的核心不变量:
|
||||
// - 纯 push(append)不置 needs_full_rewrite,persisted_msg_count 由 save 推进(此处只测标志语义)
|
||||
// - 任何修改既有消息 / 缩短 / 中段插入 / 清空的入口都置 needs_full_rewrite=true
|
||||
// (漏置位 = 静默 DB 不同步,是本优化最危险的回归点)
|
||||
// - restore_from_messages(DB 加载)后基线对齐到 len + 清 needs_full_rewrite
|
||||
//
|
||||
// persisted_msg_count 的推进/读取由 save_conversation(src-tauri) 调 advance_persisted_count 完成,
|
||||
// 此处仅测 ContextManager 侧的字段初值 + advance 语义(max 防回退 + 清标志)。
|
||||
|
||||
#[test]
|
||||
fn phase1_new_manager_zero_baseline_no_rewrite_needed() {
|
||||
// 新建 ContextManager:基线 0,无需全量重写(append-only 起点)
|
||||
let mgr = ContextManager::new(cfg(100_000));
|
||||
assert_eq!(mgr.persisted_msg_count(), 0);
|
||||
assert!(!mgr.needs_full_rewrite(), "新 manager 不应需全量重写");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase1_push_only_keeps_clean_no_dirty_flag() {
|
||||
// 纯 append(push 多条)不置 needs_full_rewrite —— 这是常态(agentic loop 每轮追加),
|
||||
// 必须走增量路径只插新消息,不能误触发全量重写。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问题1"));
|
||||
assert!(!mgr.needs_full_rewrite(), "push 不应置 dirty");
|
||||
mgr.push(ChatMessage::assistant("回答1"));
|
||||
mgr.push(ChatMessage::user("问题2"));
|
||||
assert!(!mgr.needs_full_rewrite(), "连续 push 不应置 dirty");
|
||||
assert_eq!(mgr.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase1_advance_persisted_count_sets_baseline_and_clears_dirty() {
|
||||
// advance_persisted_count(new_count):推进基线 + 清 needs_full_rewrite(DB 已与内存一致)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("x"));
|
||||
mgr.push(ChatMessage::user("y"));
|
||||
// 模拟 save 成功后推进
|
||||
mgr.advance_persisted_count(2);
|
||||
assert_eq!(mgr.persisted_msg_count(), 2);
|
||||
assert!(!mgr.needs_full_rewrite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase1_advance_persisted_count_max_no_regression() {
|
||||
// 防回退:advance 传更小值不回退基线(防误传 / 并发致基线丢失 → 下次增量漏写)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.advance_persisted_count(5);
|
||||
mgr.advance_persisted_count(3); // 误传更小
|
||||
assert_eq!(mgr.persisted_msg_count(), 5, "基线不可回退");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase1_compress_old_messages_sets_dirty() {
|
||||
// compress 改既有消息 status(active→compressed) → 必须全量重写收敛
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("旧1"));
|
||||
mgr.push(ChatMessage::user("旧2"));
|
||||
mgr.push(ChatMessage::user("保护区"));
|
||||
assert!(!mgr.needs_full_rewrite());
|
||||
let compressed = mgr.compress_old_messages(2);
|
||||
assert_eq!(compressed.len(), 2, "应压缩 2 条");
|
||||
assert!(mgr.needs_full_rewrite(), "compress 后必须全量重写");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase1_compress_no_op_does_not_set_dirty() {
|
||||
// 边界:compress_end 范围内无可压缩(全已 compressed)→ 状态未变,不应置 dirty
|
||||
// (避免无意义全量重写,compress 重试场景常见)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("a"));
|
||||
let _ = mgr.compress_old_messages(1);
|
||||
assert!(mgr.needs_full_rewrite(), "首次压缩应置 dirty");
|
||||
mgr.advance_persisted_count(1); // 模拟 save 后清 dirty
|
||||
// 二次压缩同范围:全已 compressed,无新标 → 不应再置 dirty
|
||||
let compressed = mgr.compress_old_messages(1);
|
||||
assert!(compressed.is_empty());
|
||||
// 注:此处 dirty 在二次 compress 时未变(未置位),但 advance 已清,故仍 false
|
||||
assert!(!mgr.needs_full_rewrite(), "无新压缩不应置 dirty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase1_replace_tool_result_content_sets_dirty() {
|
||||
// 审批 replace_tool_result 改既有 tool 消息 content → 必须全量重写
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调用工具",
|
||||
vec![ToolCall {
|
||||
id: "call_1".into(),
|
||||
call_type: "function".into(),
|
||||
function: crate::provider::ToolCallFunction {
|
||||
name: "read_file".into(),
|
||||
arguments: "{}".into(),
|
||||
},
|
||||
}],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("call_1", "占位结果"));
|
||||
assert!(!mgr.needs_full_rewrite());
|
||||
let replaced = mgr.replace_tool_result_content("call_1", "真实结果");
|
||||
assert!(replaced, "应找到并替换");
|
||||
assert!(mgr.needs_full_rewrite(), "replace content 后必须全量重写");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase1_pop_last_assistant_round_sets_dirty() {
|
||||
// 重新生成 pop 末尾 assistant 轮 → len 缩短 → 必须全量重写(删 DB 尾行)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问"));
|
||||
mgr.push(ChatMessage::assistant("答"));
|
||||
assert!(!mgr.needs_full_rewrite());
|
||||
let popped = mgr.pop_last_assistant_round();
|
||||
assert!(popped);
|
||||
assert!(mgr.needs_full_rewrite(), "pop 后必须全量重写");
|
||||
assert_eq!(mgr.len(), 1, "应只剩 user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase1_pop_no_op_does_not_set_dirty() {
|
||||
// 边界:无可弹 assistant(末尾是 user)→ 不变,不应置 dirty
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("只有 user"));
|
||||
let popped = mgr.pop_last_assistant_round();
|
||||
assert!(!popped);
|
||||
assert!(!mgr.needs_full_rewrite(), "pop no-op 不应置 dirty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase1_truncate_after_user_message_sets_dirty() {
|
||||
// 编辑重生成 truncate 后续消息 status → 必须全量重写
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("原始问题"));
|
||||
mgr.push(ChatMessage::assistant("答1"));
|
||||
mgr.push(ChatMessage::assistant("答2"));
|
||||
assert!(!mgr.needs_full_rewrite());
|
||||
let count = mgr.truncate_after_user_message("原始问题").expect("应找到");
|
||||
assert_eq!(count, 2);
|
||||
assert!(mgr.needs_full_rewrite(), "truncate 后必须全量重写");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase1_replace_last_active_user_content_sets_dirty() {
|
||||
// 编辑 user 消息 content → 必须全量重写
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("旧问题"));
|
||||
assert!(!mgr.needs_full_rewrite());
|
||||
mgr.replace_last_active_user_content("新问题").expect("应替换");
|
||||
assert!(mgr.needs_full_rewrite(), "replace user content 后必须全量重写");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase1_insert_at_sets_dirty() {
|
||||
// 中段插入(压缩摘要/话题标记)改变 seq → 必须全量重写
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("a"));
|
||||
mgr.push(ChatMessage::user("b"));
|
||||
assert!(!mgr.needs_full_rewrite());
|
||||
mgr.insert_at(0, ChatMessage::system("[摘要]"));
|
||||
assert!(mgr.needs_full_rewrite(), "insert_at 后必须全量重写( seq 错位)");
|
||||
assert_eq!(mgr.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase1_clear_resets_baseline_and_sets_dirty() {
|
||||
// clear:基线重置 0 + 置 dirty(下次 save 全量重写空列表清 DB)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("a"));
|
||||
mgr.advance_persisted_count(1);
|
||||
assert_eq!(mgr.persisted_msg_count(), 1);
|
||||
mgr.clear();
|
||||
assert_eq!(mgr.persisted_msg_count(), 0, "clear 应重置基线到 0");
|
||||
assert!(mgr.needs_full_rewrite(), "clear 应置 dirty");
|
||||
assert_eq!(mgr.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase1_restore_from_messages_aligns_baseline_clears_dirty() {
|
||||
// DB 加载(restore_from_messages)后:基线对齐到 len + 清 dirty
|
||||
// (DB 已是该真相源,append 基线应对齐当前长度,不需全量重写)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("旧内存")); // 制造一个 dirty + 非零状态
|
||||
mgr.mark_needs_full_rewrite();
|
||||
// 模拟从 DB 加载 3 条消息
|
||||
let loaded = vec![
|
||||
ChatMessage::user("db1"),
|
||||
ChatMessage::assistant("db2"),
|
||||
ChatMessage::user("db3"),
|
||||
];
|
||||
mgr.restore_from_messages(loaded);
|
||||
assert_eq!(mgr.persisted_msg_count(), 3, "基线应对齐到 DB 加载的消息数");
|
||||
assert!(!mgr.needs_full_rewrite(), "DB 加载后不应需全量重写");
|
||||
assert_eq!(mgr.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase1_mark_needs_full_rewrite_idempotent() {
|
||||
// 幂等:重复 mark 不报警不改变语义(供 messages_mut 调用方多次调用兜底)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.mark_needs_full_rewrite();
|
||||
mgr.mark_needs_full_rewrite();
|
||||
mgr.mark_needs_full_rewrite();
|
||||
assert!(mgr.needs_full_rewrite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase1_append_after_dirty_stays_dirty_until_save() {
|
||||
// dirty 后再 push(append):dirty 保持(下次 save 仍走全量重写收敛,不会因 push 重置)
|
||||
// 验证 push 不清 dirty(否则 compress 后追加新消息会漏收敛旧行)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("a"));
|
||||
mgr.mark_needs_full_rewrite(); // 模拟 compress
|
||||
mgr.push(ChatMessage::user("b")); // 追加
|
||||
assert!(mgr.needs_full_rewrite(), "dirty 后 push 不应清 dirty");
|
||||
// 模拟 save(全量重写)成功后推进 + 清
|
||||
mgr.advance_persisted_count(2);
|
||||
assert!(!mgr.needs_full_rewrite());
|
||||
}
|
||||
|
||||
@@ -54,6 +54,30 @@ pub struct ContextManager {
|
||||
/// 都非 None 且不同(双高置信),置位本字段,格式 "old|new"。agentic loop 顶部读并
|
||||
/// 消费(insert 系统标记后清空)。纯内存态,不落库。保守:任一 topic 为 None 不置位(宁可漏报)。
|
||||
pending_topic_marker: Option<String>,
|
||||
/// msg-split-phase1:已落库(ai_messages 表)的消息条数。
|
||||
///
|
||||
/// 增量写路径用:save_conversation 据此判定 `[persisted_msg_count..len)` 是"自上次 save
|
||||
/// 后新增的消息"(纯 append),走 `insert_batch`(INSERT OR IGNORE)只插新行,跳过昂贵的
|
||||
/// `replace_conversation`(DELETE 整对话 + 全量重插)。
|
||||
///
|
||||
/// 仅 append-only 路径才有效——一旦 [`needs_full_rewrite`] 被置位(DB 与内存可能脱钩:
|
||||
/// compress 改 status / replace_tool_result_content 改 content / pop_last_assistant_round
|
||||
/// 缩短 / insert_at 中段插入改 seq / clear 清空),save 必须走全量重写收敛,本字段随之重置。
|
||||
///
|
||||
/// restore_from_messages(DB 加载)后设为 len(DB 已是该真相源,append 基线对齐到当前长度)。
|
||||
/// 纯内存态,不落库(下次进程重启从 DB reload 时由 restore_from_messages 重置)。
|
||||
persisted_msg_count: usize,
|
||||
/// msg-split-phase1:全量重写需求标志。
|
||||
///
|
||||
/// `true` = 自上次 save 后发生过修改既有消息 / 缩短 / 中段插入 / 清空,内存与 DB 可能脱钩,
|
||||
/// 下次 save 必须走 `replace_conversation` 全量重写收敛(DELETE + INSERT 全部行)。
|
||||
/// save 全量重写成功后清零;append-only save(insert_batch)成功后保持 false。
|
||||
///
|
||||
/// 任何修改既有消息(非纯 push append)的入口置位:clear / pop_last_assistant_round /
|
||||
/// truncate_after_user_message / replace_tool_result_content / replace_last_active_user_content /
|
||||
/// insert_at / compress_old_messages / messages_mut(可变借用兜底,调用方可能改 status/content)。
|
||||
/// 误置位零代价(只是下次 save 多走一次全量重写,正确性不变);漏置位才会丢更新——故保守置位。
|
||||
needs_full_rewrite: bool,
|
||||
}
|
||||
|
||||
impl ContextManager {
|
||||
@@ -65,6 +89,8 @@ impl ContextManager {
|
||||
estimator: TokenEstimator::default(),
|
||||
is_compressing: false,
|
||||
pending_topic_marker: None,
|
||||
persisted_msg_count: 0,
|
||||
needs_full_rewrite: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +148,11 @@ impl ContextManager {
|
||||
self.history_tokens = 0;
|
||||
self.is_compressing = false;
|
||||
self.pending_topic_marker = None;
|
||||
// msg-split-phase1:全清后 DB 必须也清(调用方 chat.rs:1149 走 delete_range),
|
||||
// 全量重写基线重置到 0。needs_full_rewrite=true 保险(若 save 先于 delete_range 触发,
|
||||
// 全量重写空列表也会清 DB)。
|
||||
self.persisted_msg_count = 0;
|
||||
self.needs_full_rewrite = true;
|
||||
}
|
||||
|
||||
/// 消息数量
|
||||
@@ -165,7 +196,7 @@ impl ContextManager {
|
||||
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
|
||||
if self.history_tokens <= available {
|
||||
let sanitized = Self::sanitize_messages(self.all_messages_clone());
|
||||
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
return (
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
false,
|
||||
@@ -191,7 +222,7 @@ impl ContextManager {
|
||||
"history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限",
|
||||
self.history_tokens, available, PROTECT_COUNT
|
||||
);
|
||||
// B-260626-01: 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
|
||||
// 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
|
||||
// 触发"首条 assistant 非法"/orphan/连续 role。原裸返 all_messages_clone 不过滤
|
||||
// truncated/中毒三元组/首条非法——是主 loop 唯一的 sanitize 漏洞(大体量 tool_result
|
||||
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
|
||||
@@ -214,7 +245,7 @@ impl ContextManager {
|
||||
trim_end, removed
|
||||
);
|
||||
let sanitized = Self::sanitize_messages(msgs);
|
||||
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
(
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
true,
|
||||
@@ -246,10 +277,16 @@ impl ContextManager {
|
||||
|
||||
/// 从 Vec 恢复(兼容从 DB 加载)
|
||||
pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
|
||||
// clear() 会置 needs_full_rewrite=true + persisted_msg_count=0;但本入口是"DB 刚加载",
|
||||
// 加载后的 messages 与 DB 完全一致(它们就是从 DB 来的),append 基线应对齐到当前 len,
|
||||
// 且不需要全量重写(DB 已是真相源)。故 clear 后立即覆盖这两个字段。
|
||||
let len = messages.len();
|
||||
self.clear();
|
||||
for msg in messages {
|
||||
self.push(msg);
|
||||
}
|
||||
self.persisted_msg_count = len;
|
||||
self.needs_full_rewrite = false;
|
||||
}
|
||||
|
||||
/// 就地替换某条 tool_result 的内容(兼容审批 replace_tool_result)
|
||||
@@ -271,6 +308,8 @@ impl ContextManager {
|
||||
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
|
||||
self.messages[i].token_count = new_tokens;
|
||||
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
|
||||
// msg-split-phase1:改既有消息 content,DB 需全量重写收敛(append-only 路径不覆盖旧行)。
|
||||
self.needs_full_rewrite = true;
|
||||
true
|
||||
}
|
||||
|
||||
@@ -298,6 +337,11 @@ impl ContextManager {
|
||||
popped_any = true;
|
||||
}
|
||||
}
|
||||
// msg-split-phase1:从末尾弹出 → len 缩短,append-only 基线(persisted_msg_count)
|
||||
// 会大于新 len。下次 save 必须全量重写(删 DB 中已不存在的尾行)。
|
||||
if popped_any {
|
||||
self.needs_full_rewrite = true;
|
||||
}
|
||||
popped_any
|
||||
}
|
||||
|
||||
@@ -325,6 +369,10 @@ impl ContextManager {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
// msg-split-phase1:改既有消息 status(truncated),DB 需全量重写收敛。
|
||||
if count > 0 {
|
||||
self.needs_full_rewrite = true;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
@@ -342,6 +390,8 @@ impl ContextManager {
|
||||
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
|
||||
self.messages[i].token_count = new_tokens;
|
||||
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
|
||||
// msg-split-phase1:改既有 user 消息 content,DB 需全量重写收敛。
|
||||
self.needs_full_rewrite = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -350,7 +400,7 @@ impl ContextManager {
|
||||
self.messages.iter().map(|t| &t.message)
|
||||
}
|
||||
|
||||
/// F-260619-04 P1 消息级溯源:取末条指定 role 消息的 id(ULID)。
|
||||
/// 消息级溯源:取末条指定 role 消息的 id(ULID)。
|
||||
///
|
||||
/// 从尾部反向扫描(末条消息命中即停,避免全量 O(n) 正扫累积),返回最近一条
|
||||
/// `role` 匹配且 `id` 非空消息的 id。无匹配或老消息无 id → None(向前兼容:
|
||||
@@ -417,17 +467,49 @@ impl ContextManager {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// 可变消息切片(供阶段2 标记 status="compressed"/"archived_segment" + 调整 token)
|
||||
/// 可变消息切片(供标记 status="compressed"/"archived_segment" + 调整 token)
|
||||
///
|
||||
/// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走
|
||||
/// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。
|
||||
///
|
||||
/// msg-split-phase1:**若调用方借此句柄改了既有消息(status/content),必须紧接着调
|
||||
/// [`mark_needs_full_rewrite`]**,否则下次 save 走 append-only 增量路径会漏更新旧行,
|
||||
/// DB 与内存脱钩。仅读(过滤后 clone)不需调。本方法无法自行置位(借用冲突:返回 &mut
|
||||
/// 切片时不能再持有 &mut self 标志)。
|
||||
pub fn messages_mut(&mut self) -> &mut [TrackedMessage] {
|
||||
&mut self.messages
|
||||
}
|
||||
|
||||
/// msg-split-phase1:显式标记"下次 save 须全量重写"。
|
||||
///
|
||||
/// 供 [`messages_mut`] 的调用方在改完既有消息后调用(本 struct 无法在返回 &mut 切片时
|
||||
/// 自行置位)。也可供任何绕过本 impl 直接改 messages 的路径兜底。幂等(重复置 true 无害)。
|
||||
pub fn mark_needs_full_rewrite(&mut self) {
|
||||
self.needs_full_rewrite = true;
|
||||
}
|
||||
|
||||
/// msg-split-phase1:读取已落库消息条数(append-only 增量写路径的基线)。供 save_conversation
|
||||
/// 判定 `[persisted_msg_count..len)` 是否为"自上次 save 后新增"。
|
||||
pub fn persisted_msg_count(&self) -> usize {
|
||||
self.persisted_msg_count
|
||||
}
|
||||
|
||||
/// msg-split-phase1:推进已落库消息条数(insert_batch/replace_conversation 成功后调用)。
|
||||
/// 同时清 needs_full_rewrite(DB 已与内存一致)。供 save_conversation 在写库成功后调用。
|
||||
pub fn advance_persisted_count(&mut self, new_count: usize) {
|
||||
// 防回退:若调用方误传更小值(理论上不应发生),取 max 保不丢基线(下次 save 仍能收敛)。
|
||||
self.persisted_msg_count = self.persisted_msg_count.max(new_count);
|
||||
self.needs_full_rewrite = false;
|
||||
}
|
||||
|
||||
/// msg-split-phase1:是否需要全量重写(改过既有消息 / 缩短 / 中段插入 / 清空)。
|
||||
pub fn needs_full_rewrite(&self) -> bool {
|
||||
self.needs_full_rewrite
|
||||
}
|
||||
|
||||
/// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。
|
||||
///
|
||||
/// 供阶段2 在压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义,
|
||||
/// 供压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义,
|
||||
/// 调用方负责算合法 index,如 `compress_end` 已由 `compress_old_messages` 校验)。
|
||||
pub fn insert_at(&mut self, index: usize, message: ChatMessage) {
|
||||
let tokens = self.estimator.estimate_message(&message);
|
||||
@@ -441,6 +523,9 @@ impl ContextManager {
|
||||
group,
|
||||
topic: None,
|
||||
});
|
||||
// msg-split-phase1:中段插入会改变 index 及之后所有消息的 seq(append-only 基线
|
||||
// persisted_msg_count 按"末尾追加"语义计算 seq,中段插入后 seq 错位),DB 必须全量重写。
|
||||
self.needs_full_rewrite = true;
|
||||
}
|
||||
|
||||
/// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。
|
||||
@@ -449,7 +534,7 @@ impl ContextManager {
|
||||
/// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元
|
||||
/// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元
|
||||
///
|
||||
/// 公开供阶段2 会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
|
||||
/// 公开会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
|
||||
pub fn build_eviction_units(&self, protect_start: usize) -> Vec<EvictionUnit> {
|
||||
let mut units = Vec::new();
|
||||
let mut i = 0usize;
|
||||
@@ -488,7 +573,7 @@ impl ContextManager {
|
||||
/// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。
|
||||
pub fn has_compressible_messages(&self, protect_start: usize) -> bool {
|
||||
let end = protect_start.min(self.messages.len());
|
||||
// BUG-260624-05:排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
|
||||
// 排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
|
||||
// 若计入,压缩摘要 insert_at(0) 落在可压缩区 [0..protect_start) 且 is_active(status=None),
|
||||
// 致每轮 has_compressible 恒 true → 无限循环压缩(用户报"压缩后每轮提示已压缩并停止")。
|
||||
// compress_old_messages 不改:被调用时仍标旧 system 摘要 compressed(被新摘要替代,防堆积)。
|
||||
@@ -498,7 +583,7 @@ impl ContextManager {
|
||||
}
|
||||
|
||||
/// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`,
|
||||
/// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供阶段2 喂 LLM 摘要)。
|
||||
/// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供喂 LLM 摘要)。
|
||||
///
|
||||
/// **幂等**:已 compressed(或任何 !active)的消息跳过,不会被二次压缩;`history_tokens`
|
||||
/// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。
|
||||
@@ -517,6 +602,12 @@ impl ContextManager {
|
||||
self.history_tokens = self.history_tokens.saturating_sub(t.token_count);
|
||||
}
|
||||
}
|
||||
// msg-split-phase1:改了既有消息 status(active → compressed),DB 需全量重写收敛
|
||||
// (append-only 增量路径不覆盖旧行)。仅在确实标了新 compressed 时置位(无可压缩时
|
||||
// newly_compressed 为空,状态未变,无需全量重写)。
|
||||
if !newly_compressed.is_empty() {
|
||||
self.needs_full_rewrite = true;
|
||||
}
|
||||
newly_compressed
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
sanitized
|
||||
};
|
||||
|
||||
// step 3.5(阶段2 占位配对完整性):反向 orphan 检测 —— tool_result 无对应 tool_call 头 → 丢。
|
||||
// step 3.5(占位配对完整性):反向 orphan 检测 —— tool_result 无对应 tool_call 头 → 丢。
|
||||
//
|
||||
// 根因(解 400 orphan):审批挂起占位 tool_result(内容 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
|
||||
// 经 step3(正向 orphan:头无 result→丢头 + 其 result)或 build_eviction_units(预算裁剪从三元组
|
||||
@@ -227,7 +227,7 @@ pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
filtered
|
||||
}
|
||||
|
||||
/// 发送视图出口断言(阶段2 占位配对完整性):确保所有 tool_result(含审批占位)都有
|
||||
/// 发送视图出口断言(占位配对完整性):确保所有 tool_result(含审批占位)都有
|
||||
/// 对应 tool_call 头,失败降级 TOOL_MISSING_PREFIX 自愈。
|
||||
///
|
||||
/// **职责**:在消息即将发给 LLM 前(`build_for_request` 出口)最后一道防线:若仍有
|
||||
@@ -470,7 +470,7 @@ mod tests {
|
||||
assert_eq!(msgs.len(), 3, "正常三元组不应被 sanitize 剔除");
|
||||
}
|
||||
|
||||
// ── 阶段2 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
|
||||
// ── 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_reverse_orphan_tool_result() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 上下文管理纯函数与数据类型 — 从 context.rs 抽离的无 `self` 依赖部分
|
||||
//! 上下文管理纯函数与数据类型 — 从 context.rs 抽离的无 `self` 依赖部分
|
||||
//!
|
||||
//! 职责:
|
||||
//! - Token 粗估器(字符级近似,无 tokenizer 依赖)
|
||||
@@ -47,7 +47,7 @@ impl Default for TokenEstimator {
|
||||
impl TokenEstimator {
|
||||
/// 估算单条消息的 token 数(保守估计)
|
||||
///
|
||||
/// F-260614-05 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
|
||||
/// 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
|
||||
/// 此前只算 `content`,含图消息的大段 base64(可达 25 万 tokens)被完全忽略,致
|
||||
/// `history_tokens` 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。
|
||||
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。
|
||||
@@ -58,7 +58,7 @@ impl TokenEstimator {
|
||||
match p {
|
||||
crate::provider::ContentPart::Text { text } => char_count += text.chars().count(),
|
||||
crate::provider::ContentPart::Image { base64, url, .. } => {
|
||||
// CR-260618-11#2:0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
|
||||
// 0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
|
||||
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
|
||||
// base64 优先(多模态主载荷),url 次之;url 模式无字节,仅按 URL 长度估
|
||||
if let Some(b) = base64 {
|
||||
@@ -145,7 +145,7 @@ pub enum MessageGroup {
|
||||
|
||||
/// 带有 token 缓存和分组信息的消息条目
|
||||
///
|
||||
/// 字段 `pub`:供阶段2 IPC 经 `ContextManager::messages_mut()` 拿到可变切片后,
|
||||
/// 字段 `pub`:供 IPC 经 `ContextManager::messages_mut()` 拿到可变切片后,
|
||||
/// 直接改 `message.status` / 读 `token_count` 做 token 重算(Mutex 单线程访问,
|
||||
/// 同 crate 内安全)。结构体本身也 `pub`(返回类型对外可见)。
|
||||
pub struct TrackedMessage {
|
||||
@@ -408,7 +408,7 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
||||
*field = serde_json::Value::String(out.join("\n"));
|
||||
truncated = true;
|
||||
} else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX {
|
||||
// BUG-260628-01:单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。
|
||||
// 单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。
|
||||
// 按字符数截断保留头尾,保证压缩至少生效。
|
||||
let head: String = s.chars().take(TOOL_RESULT_JSON_STR_FIELD_MAX / 2).collect();
|
||||
let tail: String = s.chars().skip(s.chars().count().saturating_sub(TOOL_RESULT_JSON_STR_FIELD_MAX / 2)).collect();
|
||||
@@ -439,7 +439,7 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
||||
let total = lines.len();
|
||||
let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
|
||||
if total <= kept_boundary {
|
||||
// BUG-260628-01:行数少但内容超大的情况(单行 50KB),行级截断无效。
|
||||
// 行数少但内容超大的情况(单行 50KB),行级截断无效。
|
||||
// 按字符数截断保证压缩至少生效。
|
||||
let char_count = content.chars().count();
|
||||
if char_count > TOOL_RESULT_CHAR_LIMIT {
|
||||
@@ -524,7 +524,7 @@ fn is_error_line(line: &str) -> bool {
|
||||
|
||||
/// 淘汰单元:连续消息范围 [..end) + token 总和
|
||||
///
|
||||
/// `pub` 供 `build_eviction_units` 的返回类型对外可见(阶段2/3 调用方读 `end` / `token_sum`)。
|
||||
/// `pub` 供 `build_eviction_units` 的返回类型对外可见(调用方读 `end` / `token_sum`)。
|
||||
pub struct EvictionUnit {
|
||||
pub end: usize,
|
||||
pub token_sum: u32,
|
||||
@@ -541,7 +541,7 @@ pub const PROTECT_COUNT: usize = 6;
|
||||
/// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。
|
||||
pub const TOOL_MISSING_PREFIX: &str = "tool_missing_";
|
||||
|
||||
/// 阶段2(path_auth 审批链重构):占位配对完整性(解 400 orphan)常量开关。
|
||||
/// 占位配对完整性(解 400 orphan)常量开关。
|
||||
///
|
||||
/// 根因:审批挂起占位 tool_result(内容为 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
|
||||
/// 与其 tool_call 头经 sanitize/compress 裁剪后丢配对头 → orphan tool_result(无头)→
|
||||
@@ -553,6 +553,9 @@ pub const TOOL_MISSING_PREFIX: &str = "tool_missing_";
|
||||
/// 兜底:sanitize view-only 不改持久化,失败只影响单请求,flag 关→行为完全等价改动前。
|
||||
pub const PLACEHOLDER_INTEGRITY_ENABLED: bool = true;
|
||||
|
||||
/// namespace 引用路径前缀(转发至 namespace_store,供 context.rs 统一路径引用)
|
||||
pub const NAMESPACE_REF_PREFIX: &str = crate::namespace_store::NAMESPACE_REF_PREFIX;
|
||||
|
||||
/// 占位 tool_result 内容中嵌入的唯一标记前缀(供 sanitize 识别"审批挂起占位,不可裁")。
|
||||
///
|
||||
/// 写入处(audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)格式:`{占位文本}{__PENDING__}{tc_id}`,
|
||||
@@ -604,6 +607,169 @@ pub fn extract_pending_tc_id(content: &str) -> Option<&str> {
|
||||
.filter(|id| !id.is_empty())
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// Wave 共享契约类型(由 上下文管理演进-执行计划 Agent 3 定义)
|
||||
// ============================================================
|
||||
|
||||
/// 压缩摘要双格式(T3 输出, T5 消费)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompressedSummary {
|
||||
/// JSON 卡片序列化(主题/决策/待办/token节省)
|
||||
pub json_card: String,
|
||||
/// 自然语言摘要(向前兼容关键词兜底降级)
|
||||
pub nl_summary: String,
|
||||
}
|
||||
|
||||
/// 工作流 DAG 上下文块(T4 输出 → 注入 system prompt)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowContextBlock {
|
||||
pub workflow_id: String,
|
||||
pub workflow_name: String,
|
||||
pub total_nodes: usize,
|
||||
pub completed_nodes: usize,
|
||||
pub current_node: Option<String>,
|
||||
pub next_nodes: Vec<String>,
|
||||
}
|
||||
|
||||
impl WorkflowContextBlock {
|
||||
/// 格式化为 system prompt 可读文本
|
||||
pub fn to_system_text(&self) -> String {
|
||||
let mut s = format!("工作流: {} (ID: {})", self.workflow_name, self.workflow_id);
|
||||
s.push_str(&format!("\n进度: {}/{}", self.completed_nodes, self.total_nodes));
|
||||
if let Some(ref cur) = self.current_node {
|
||||
s.push_str(&format!("\n当前步骤: {}", cur));
|
||||
}
|
||||
if !self.next_nodes.is_empty() {
|
||||
s.push_str(&format!("\n后续: {}", self.next_nodes.join(" → ")));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// 目标状态(T5 WorkingContext)
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum GoalStatus {
|
||||
Active,
|
||||
Completed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// 步骤状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum StepStatus {
|
||||
Pending,
|
||||
InProgress,
|
||||
Done,
|
||||
Blocked,
|
||||
}
|
||||
|
||||
/// 目标条目
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GoalItem {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub status: GoalStatus,
|
||||
}
|
||||
|
||||
/// 决策条目
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DecisionItem {
|
||||
pub question: String,
|
||||
pub decision: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// 步骤信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StepInfo {
|
||||
pub step_name: String,
|
||||
pub status: StepStatus,
|
||||
}
|
||||
|
||||
/// WorkingContext 版本号(脏标记追踪)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkingContextVersion {
|
||||
pub goals_changed_at: u32,
|
||||
pub decisions_changed_at: u32,
|
||||
pub unresolved_changed_at: u32,
|
||||
pub step_changed_at: u32,
|
||||
pub current_turn: u32,
|
||||
}
|
||||
|
||||
impl WorkingContextVersion {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
goals_changed_at: 0,
|
||||
decisions_changed_at: 0,
|
||||
unresolved_changed_at: 0,
|
||||
step_changed_at: 0,
|
||||
current_turn: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 压缩后重置:将所有字段标记为本轮变更
|
||||
pub fn reset_all(&mut self, _summary: &str, current_turn: u32) {
|
||||
self.goals_changed_at = current_turn;
|
||||
self.decisions_changed_at = current_turn;
|
||||
self.unresolved_changed_at = current_turn;
|
||||
self.current_turn = current_turn;
|
||||
}
|
||||
}
|
||||
|
||||
/// WorkingContext 常驻上下文(T5 实现)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkingContext {
|
||||
pub goals: Vec<GoalItem>,
|
||||
pub decisions: Vec<DecisionItem>,
|
||||
pub unresolved: Vec<String>,
|
||||
pub current_step: Option<StepInfo>,
|
||||
pub version: WorkingContextVersion,
|
||||
}
|
||||
|
||||
impl WorkingContext {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
goals: Vec::new(),
|
||||
decisions: Vec::new(),
|
||||
unresolved: Vec::new(),
|
||||
current_step: None,
|
||||
version: WorkingContextVersion::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建本轮需要注入的 L1 内容(L1a 必带 + L1b 条件注入)
|
||||
pub fn build_injection(&self, recent_turns: u32) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
// L1a: 当前步骤(每轮必带)
|
||||
if let Some(ref step) = self.current_step {
|
||||
parts.push(format!("当前步骤: {} [{:?}]", step.step_name, step.status));
|
||||
}
|
||||
|
||||
// L1b: 过去 N 轮内有变更才注入
|
||||
let threshold = self.version.current_turn.saturating_sub(recent_turns);
|
||||
let has_active = self.goals.iter().any(|g| g.status == GoalStatus::Active);
|
||||
if has_active && self.version.goals_changed_at >= threshold {
|
||||
let active: Vec<&str> = self.goals.iter()
|
||||
.filter(|g| g.status == GoalStatus::Active)
|
||||
.map(|g| g.text.as_str())
|
||||
.collect();
|
||||
parts.push(format!("目标({}): {}", active.len(), active.join("; ")));
|
||||
}
|
||||
if self.version.decisions_changed_at >= threshold && !self.decisions.is_empty() {
|
||||
let recent: Vec<&str> = self.decisions.iter().map(|d| d.decision.as_str()).collect();
|
||||
parts.push(format!("近期决策: {}", recent.join("; ")));
|
||||
}
|
||||
if self.version.unresolved_changed_at >= threshold && !self.unresolved.is_empty() {
|
||||
parts.push(format!("待解: {}", self.unresolved.join("; ")));
|
||||
}
|
||||
|
||||
parts.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -952,7 +1118,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_single_huge_line_no_newline_compressed() {
|
||||
// BUG-260628-01:单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
|
||||
// 单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
|
||||
let content = "x".repeat(50_000);
|
||||
let result = extract_key_info(&content, "read_file");
|
||||
assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len());
|
||||
@@ -964,7 +1130,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_json_huge_string_field_truncated() {
|
||||
// BUG-260628-01:JSON 对象中大字符串字段(单行少行)逃逸压缩。
|
||||
// JSON 对象中大字符串字段(单行少行)逃逸压缩。
|
||||
// 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。
|
||||
let large = "z".repeat(10_000);
|
||||
let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large);
|
||||
@@ -1077,7 +1243,7 @@ mod tests {
|
||||
assert!(toks.contains(&"now".to_string()));
|
||||
}
|
||||
|
||||
// ── 阶段2 占位配对完整性:is_pending_placeholder / extract_pending_tc_id ──
|
||||
// ── 占位配对完整性:is_pending_placeholder / extract_pending_tc_id ──
|
||||
|
||||
#[test]
|
||||
fn is_pending_placeholder_new_with_marker() {
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
//! df-ai: AI 编排 — LLM Provider、Agent 协调、上下文管理、流式处理、工具注册
|
||||
|
||||
pub mod ai_tools;
|
||||
// 声明式工具注册宏 `declare_tool!`(tool_registry 拆分第一步基础设施)。
|
||||
// macro_rules + #[macro_export]:零新依赖,展开为等价 `register` 调用,与现有 48 工具并存。
|
||||
// 试点:list_projects 已迁至声明式(commands/ai/tools/list_projects.rs),验证可行+零回归。
|
||||
#[macro_use]
|
||||
pub mod ai_tools_decl;
|
||||
pub mod anthropic_compat;
|
||||
pub mod anthropic_helpers;
|
||||
pub mod context;
|
||||
pub mod context_helpers;
|
||||
pub mod memory_adapter;
|
||||
// 多 Agent 协作调度中心(Phase 1 规则驱动, Phase 2 LLM 扩展)。
|
||||
pub mod coordinator;
|
||||
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
|
||||
@@ -37,11 +43,12 @@ pub mod persona;
|
||||
pub mod plan_executor;
|
||||
pub mod provider;
|
||||
pub mod router;
|
||||
// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
|
||||
// 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
|
||||
// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类)
|
||||
// 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。
|
||||
// 避免重写退避/分类逻辑。改 pub mod 后对外仅暴露纯函数 + 常量。
|
||||
pub mod retry;
|
||||
pub mod sse_parser;
|
||||
pub mod namespace_store;
|
||||
|
||||
use provider::LlmProvider;
|
||||
use reqwest::Client;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! MemoryAdapter trait — 上下文记忆层抽象
|
||||
//!
|
||||
//! 将 ContextManager 的核心接口提取为 trait,支持多种记忆后端:
|
||||
//! - InMemoryAdapter(当前 ContextManager 的包装,默认实现)
|
||||
//! - VectorMemoryAdapter(未来:接入向量检索,支持语义记忆)
|
||||
//!
|
||||
//! 使用方式:`Box<dyn MemoryAdapter>` 替代 `ContextManager`。
|
||||
|
||||
use crate::context::ContextManager;
|
||||
use crate::context_helpers::{ContextConfig, TrackedMessage};
|
||||
use crate::provider::ChatMessage;
|
||||
|
||||
/// 上下文记忆层抽象接口
|
||||
///
|
||||
/// 覆盖 agentic loop / IPC 最常用的 15 个方法。
|
||||
/// 保留 `ContextManager` 作为默认实现(`InMemoryAdapter`),行为零变化。
|
||||
pub trait MemoryAdapter: Send + Sync {
|
||||
// ── 写入 ──
|
||||
fn push(&mut self, msg: ChatMessage);
|
||||
fn clear(&mut self);
|
||||
fn insert_at(&mut self, index: usize, message: ChatMessage);
|
||||
fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool;
|
||||
fn pop_last_assistant_round(&mut self) -> bool;
|
||||
fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()>;
|
||||
fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()>;
|
||||
|
||||
// ── 读取 ──
|
||||
fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool);
|
||||
fn all_messages_clone(&self) -> Vec<ChatMessage>;
|
||||
fn len(&self) -> usize;
|
||||
fn is_empty(&self) -> bool;
|
||||
fn history_tokens(&self) -> u32;
|
||||
fn budget_limit(&self) -> u32;
|
||||
fn config(&self) -> &ContextConfig;
|
||||
|
||||
// ── 压缩 ──
|
||||
fn compress_old_messages(&mut self, end: usize) -> Vec<ChatMessage>;
|
||||
fn has_compressible_messages(&self, protect_start: usize) -> bool;
|
||||
fn is_compressing(&self) -> bool;
|
||||
fn set_compressing(&mut self, v: bool);
|
||||
|
||||
// ── 生命周期 ──
|
||||
fn restore_from_messages(&mut self, messages: Vec<ChatMessage>);
|
||||
fn take_topic_marker(&mut self) -> Option<String>;
|
||||
fn messages_mut(&mut self) -> &mut [TrackedMessage];
|
||||
fn iter(&self) -> Box<dyn Iterator<Item = &ChatMessage> + '_>;
|
||||
}
|
||||
|
||||
/// ContextManager 作为默认 MemoryAdapter 实现
|
||||
impl MemoryAdapter for ContextManager {
|
||||
fn push(&mut self, msg: ChatMessage) { self.push(msg); }
|
||||
fn clear(&mut self) { self.clear(); }
|
||||
fn insert_at(&mut self, index: usize, message: ChatMessage) { self.insert_at(index, message); }
|
||||
fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
|
||||
self.replace_tool_result_content(tool_call_id, new_content)
|
||||
}
|
||||
fn pop_last_assistant_round(&mut self) -> bool { self.pop_last_assistant_round() }
|
||||
fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()> {
|
||||
self.truncate_after_user_message(target_content)
|
||||
}
|
||||
fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
|
||||
self.replace_last_active_user_content(new_content)
|
||||
}
|
||||
|
||||
fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
|
||||
self.build_for_request(sys_tokens)
|
||||
}
|
||||
fn all_messages_clone(&self) -> Vec<ChatMessage> { self.all_messages_clone() }
|
||||
fn len(&self) -> usize { self.len() }
|
||||
fn is_empty(&self) -> bool { self.is_empty() }
|
||||
fn history_tokens(&self) -> u32 { self.history_tokens() }
|
||||
fn budget_limit(&self) -> u32 { self.budget_limit() }
|
||||
fn config(&self) -> &ContextConfig { self.config() }
|
||||
|
||||
fn compress_old_messages(&mut self, end: usize) -> Vec<ChatMessage> { self.compress_old_messages(end) }
|
||||
fn has_compressible_messages(&self, protect_start: usize) -> bool { self.has_compressible_messages(protect_start) }
|
||||
fn is_compressing(&self) -> bool { self.is_compressing() }
|
||||
fn set_compressing(&mut self, v: bool) { self.set_compressing(v); }
|
||||
|
||||
fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) { self.restore_from_messages(messages); }
|
||||
fn take_topic_marker(&mut self) -> Option<String> { self.take_topic_marker() }
|
||||
fn messages_mut(&mut self) -> &mut [TrackedMessage] { self.messages_mut() }
|
||||
fn iter(&self) -> Box<dyn Iterator<Item = &ChatMessage> + '_> {
|
||||
Box::new(self.iter())
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 厂商模型列表拉取 — F-01 阶段3
|
||||
//! 厂商模型列表拉取。
|
||||
//!
|
||||
//! 按 `provider_type` 分派拉取厂商模型列表,过滤非 chat 模型,返回模型名 Vec。
|
||||
//! `fetch_and_probe` 在拉取基础上对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 模型探测器 — F-01 阶段2
|
||||
//! 模型探测器。
|
||||
//!
|
||||
//! 给定模型名,产出完整 `ModelConfig`(4 维度 + 路由控制 + 探测来源标注)。
|
||||
//!
|
||||
@@ -100,7 +100,7 @@ mod tests {
|
||||
assert_eq!(m.model_id, "glm-4");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
assert_eq!(m.modalities, vec![Modality::Text]);
|
||||
// B-260618-04:预设表不再写死 cost_tier/intelligence,由 serde default 兜底中性值
|
||||
// 预设表不写死 cost_tier/intelligence,由 serde default 兜底中性值
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
@@ -177,7 +177,7 @@ mod tests {
|
||||
fn heuristic_flash_keeps_neutral_tier() {
|
||||
let m = probe("unknown-flash");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
|
||||
// B-260618-04:cost/intel 一律中性,不靠名字猜
|
||||
// cost/intel 一律中性,不靠名字猜
|
||||
assert_eq!(m.intelligence, IntelligenceTier::Standard);
|
||||
assert_eq!(m.cost_tier, CostTier::Medium);
|
||||
}
|
||||
@@ -261,7 +261,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn probe_preset_beats_heuristic() {
|
||||
// "glm-4-flash" 精确命中预设表;B-260618-04 后预设/启发式档位都中性,
|
||||
// "glm-4-flash" 精确命中预设表;预设/启发式档位都中性,
|
||||
// 此处仅校验 source 标注为 PresetTable
|
||||
let m = probe("glm-4-flash");
|
||||
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 模型探测器 — 纯逻辑子模块(F-01 阶段2)
|
||||
//! 模型探测器 — 纯逻辑子模块。
|
||||
//!
|
||||
//! 从 `model_probe.rs` 抽离的纯函数实现(预设表加载 / 启发式推断 / 词素判定)。
|
||||
//! 无 IO、无外部状态,crate 内经 `model_probe::probe` 间接复用 —
|
||||
@@ -36,7 +36,7 @@ pub(super) fn presets() -> &'static [ModelConfig] {
|
||||
/// 模型名启发式推断。仅推断功能性维度(modalities/capabilities),
|
||||
/// cost_tier / intelligence 一律返中性默认(Medium / Standard)。
|
||||
///
|
||||
/// 取舍:B-260618-04 — 模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据,
|
||||
/// 取舍:模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据,
|
||||
/// 厂商定价/智力与命名无关,瞎填会污染路由器过滤(intelligence >= min / cost_tier <= max)。
|
||||
/// 改中性默认,真实档位由用户手填或更高阶探测源(如厂商 API/定价表)提供。
|
||||
///
|
||||
@@ -49,7 +49,7 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
|
||||
let name = model_id.to_lowercase();
|
||||
let mut modalities: Vec<Modality> = Vec::new();
|
||||
let mut capabilities: Vec<Capability> = Vec::new();
|
||||
// 中性默认:不靠模型名猜档位(B-260618-04)
|
||||
// 中性默认:不靠模型名猜档位
|
||||
let cost_tier = CostTier::Medium;
|
||||
let intelligence = IntelligenceTier::Standard;
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
//! 工具命名空间存储 — 大工具结果不进主消息队列
|
||||
//!
|
||||
//! 当工具执行结果超过阈值时,存入 `NamespaceStore`,主队列只保留轻量引用路径。
|
||||
//! 运行时为内存 HashMap(LRU 淘汰),持久化时由调用方展开引用写 DB。
|
||||
//!
|
||||
//! 存储格式: "namespace://tool_name/args_hash"
|
||||
//! 例: namespace://read_file/a1b2c3d4
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
/// 引用路径前缀
|
||||
pub const NAMESPACE_REF_PREFIX: &str = "namespace://";
|
||||
|
||||
/// namespace 条目被 LRU 淘汰(或跨会话残留引用)后,展开点 read_only()=None 时的提示文案。
|
||||
///
|
||||
/// 旧实现 None 分支保留 "namespace://" 字面 URI,LLM 收到无意义串且无告警。
|
||||
/// 现统一替换为此文案:既告知用户结果已淘汰,又提示可重新调用工具取回。
|
||||
pub const EVICTED_PLACEHOLDER: &str = "[该工具结果已超出内存上限被淘汰,如需请重新调用对应工具]";
|
||||
|
||||
/// namespace 字节阈值(> 2048 bytes 触发)
|
||||
pub const NAMESPACE_BYTE_THRESHOLD: usize = 2048;
|
||||
|
||||
/// namespace 行数阈值(> 50 行 触发)
|
||||
pub const NAMESPACE_LINE_THRESHOLD: usize = 50;
|
||||
|
||||
/// 始终进 namespace 的工具名(其输出几乎总是大结果)
|
||||
pub const ALWAYS_LARGE_TOOLS: &[&str] = &["read_file", "list_directory", "grep", "diff_files"];
|
||||
|
||||
/// 判定工具结果是否应进入 namespace
|
||||
pub fn should_use_namespace(content: &str, tool_name: &str) -> bool {
|
||||
if ALWAYS_LARGE_TOOLS.contains(&tool_name) {
|
||||
return true;
|
||||
}
|
||||
content.len() > NAMESPACE_BYTE_THRESHOLD
|
||||
|| content.lines().count() > NAMESPACE_LINE_THRESHOLD
|
||||
}
|
||||
|
||||
/// 判定 tool_result 内容是否为 namespace 引用路径
|
||||
pub fn is_namespace_ref(content: &str) -> bool {
|
||||
content.starts_with(NAMESPACE_REF_PREFIX)
|
||||
}
|
||||
|
||||
/// 从引用路径中提取内部 key
|
||||
pub fn parse_namespace_key(full_path: &str) -> Option<&str> {
|
||||
full_path.strip_prefix(NAMESPACE_REF_PREFIX)
|
||||
.and_then(|s| s.split('/').nth(1))
|
||||
}
|
||||
|
||||
/// namespace 条目
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NamespaceEntry {
|
||||
pub key: String,
|
||||
pub tool_name: String,
|
||||
pub content: String,
|
||||
pub created_at: Instant,
|
||||
pub access_count: u64,
|
||||
}
|
||||
|
||||
/// namespace 存储(内存 HashMap + LRU 淘汰)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NamespaceStore {
|
||||
entries: HashMap<String, NamespaceEntry>,
|
||||
max_bytes: usize,
|
||||
current_bytes: usize,
|
||||
}
|
||||
|
||||
impl NamespaceStore {
|
||||
/// 创建 namespace 存储
|
||||
pub fn new(max_bytes: usize) -> Self {
|
||||
Self {
|
||||
entries: HashMap::new(),
|
||||
max_bytes,
|
||||
current_bytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认大小:10MB
|
||||
pub fn default() -> Self {
|
||||
Self::new(10 * 1024 * 1024)
|
||||
}
|
||||
|
||||
/// 存入 namespace。返回引用路径字符串。
|
||||
pub fn store(&mut self, tool_name: &str, content: &str) -> String {
|
||||
let key = simple_hash(content);
|
||||
let path = format!("{}{}/{}", NAMESPACE_REF_PREFIX, tool_name, key);
|
||||
|
||||
// 已存在 → 刷新 access_count,不重复占用空间
|
||||
if self.entries.contains_key(&key) {
|
||||
if let Some(entry) = self.entries.get_mut(&key) {
|
||||
entry.access_count += 1;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// 检查容量,超限淘汰
|
||||
let new_bytes = content.len();
|
||||
self.evict_if_needed(new_bytes);
|
||||
|
||||
let entry = NamespaceEntry {
|
||||
key: key.clone(),
|
||||
tool_name: tool_name.to_string(),
|
||||
content: content.to_string(),
|
||||
created_at: Instant::now(),
|
||||
access_count: 1,
|
||||
};
|
||||
|
||||
self.current_bytes += new_bytes;
|
||||
self.entries.insert(key, entry);
|
||||
path
|
||||
}
|
||||
|
||||
/// 读取完整内容
|
||||
pub fn read(&mut self, full_path: &str) -> Option<&str> {
|
||||
let key = parse_namespace_key(full_path)?;
|
||||
let entry = self.entries.get_mut(key)?;
|
||||
entry.access_count += 1;
|
||||
Some(entry.content.as_str())
|
||||
}
|
||||
|
||||
/// 读取(只读,不改 access_count)
|
||||
pub fn read_only(&self, full_path: &str) -> Option<&str> {
|
||||
let key = parse_namespace_key(full_path)?;
|
||||
self.entries.get(key).map(|e| e.content.as_str())
|
||||
}
|
||||
|
||||
/// 检查 key 是否存在
|
||||
pub fn contains(&self, full_path: &str) -> bool {
|
||||
parse_namespace_key(full_path)
|
||||
.and_then(|k| self.entries.get(k))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// 条目数
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// LRU 淘汰:删除最旧条目直到腾出所需空间
|
||||
fn evict_if_needed(&mut self, needed: usize) {
|
||||
while self.current_bytes + needed > self.max_bytes && !self.entries.is_empty() {
|
||||
// 找最旧的条目
|
||||
let oldest_key = self.entries.iter()
|
||||
.min_by_key(|(_, e)| e.created_at)
|
||||
.map(|(k, _)| k.clone());
|
||||
|
||||
if let Some(key) = oldest_key {
|
||||
if let Some(entry) = self.entries.remove(&key) {
|
||||
self.current_bytes = self.current_bytes.saturating_sub(entry.content.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
self.current_bytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// 内容 → 引用 key 段(确定性,不含 `/` 等会破坏 `parse_namespace_key` 的字符)。
|
||||
///
|
||||
/// 历史 bug:旧实现用 `"{len}_{前16字符}"`,前 16 字符原样进 key;内容前缀含 `/`(目录列表、
|
||||
/// 带路径的 grep 结果、文件头是路径)时,`namespace://tool/{key}` 被 `parse_namespace_key`
|
||||
/// 的 `split('/').nth(1)` 从首个 `/` 截断 → read/read_only 拿回 None → 原文彻底丢失。
|
||||
/// 现改用 SipHash u64 → 16 位 hex:确定性 + 极低碰撞 + 不含特殊字符。
|
||||
fn simple_hash(content: &str) -> String {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = DefaultHasher::new();
|
||||
content.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_use_namespace_small_content() {
|
||||
assert!(!should_use_namespace("short", "custom_tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_namespace_large_bytes() {
|
||||
let large = "x".repeat(3000);
|
||||
assert!(should_use_namespace(&large, "custom_tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_namespace_always_large_tool() {
|
||||
assert!(should_use_namespace("short", "read_file"));
|
||||
assert!(should_use_namespace("short", "list_directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_namespace_many_lines() {
|
||||
let many = (0..60).map(|i| format!("line {}", i)).collect::<Vec<_>>().join("\n");
|
||||
assert!(should_use_namespace(&many, "custom_tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_namespace_ref_detects_prefix() {
|
||||
assert!(is_namespace_ref("namespace://read_file/abc"));
|
||||
assert!(!is_namespace_ref("file content here"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_namespace_key_extracts_key() {
|
||||
assert_eq!(parse_namespace_key("namespace://read_file/abc_123"), Some("abc_123"));
|
||||
assert_eq!(parse_namespace_key("namespace://grep/xyz"), Some("xyz"));
|
||||
assert_eq!(parse_namespace_key("no_prefix"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_and_read() {
|
||||
let mut ns = NamespaceStore::new(100_000);
|
||||
let path = ns.store("read_file", "hello world content");
|
||||
assert!(is_namespace_ref(&path));
|
||||
assert!(path.contains("read_file"));
|
||||
assert_eq!(ns.read(&path), Some("hello world content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_duplicate_key_does_not_double_count() {
|
||||
let mut ns = NamespaceStore::new(100_000);
|
||||
let content = "test content for duplicate check";
|
||||
let p1 = ns.store("tool_a", content);
|
||||
let p2 = ns.store("tool_b", content);
|
||||
assert_eq!(ns.len(), 1, "相同内容应去重");
|
||||
// read(&mut self) 返回 Option<&str> 借用 ns,两次调用须各自转 owned 避免双重可变借用
|
||||
let r1 = ns.read(&p1).map(str::to_owned);
|
||||
let r2 = ns.read(&p2).map(str::to_owned);
|
||||
assert_eq!(r1, r2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_content_with_slash_in_prefix_reads_back() {
|
||||
// 回归:旧 simple_hash 把内容前 16 字符原样进 key,前缀含 '/' 时(目录列表/带路径 grep
|
||||
// 结果/文件头是路径)namespace://tool/{key} 被 parse_namespace_key 的 split('/') 截断
|
||||
// → read None,原文丢失。hex hash 修复后 store→read 正确取回。
|
||||
let mut ns = NamespaceStore::new(100_000);
|
||||
let content = "foo/bar/baz/qux\nline2\nline3";
|
||||
let path = ns.store("list_directory", content);
|
||||
assert!(is_namespace_ref(&path));
|
||||
assert_eq!(ns.read(&path).map(str::to_owned), Some(content.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_oldest_removed_when_over_limit() {
|
||||
let mut ns = NamespaceStore::new(100); // 极小容量
|
||||
let small = "a"; // 1 byte
|
||||
ns.store("t1", small);
|
||||
assert_eq!(ns.len(), 1);
|
||||
|
||||
// 填充到超限
|
||||
let big = "x".repeat(200);
|
||||
ns.store("t2", &big);
|
||||
// t1 可能被淘汰
|
||||
if ns.current_bytes > 100 {
|
||||
assert!(ns.len() < 2, "超限时应已淘汰旧条目");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_resets_all() {
|
||||
let mut ns = NamespaceStore::new(100_000);
|
||||
ns.store("t1", "content");
|
||||
assert!(!ns.is_empty());
|
||||
ns.clear();
|
||||
assert!(ns.is_empty());
|
||||
assert_eq!(ns.current_bytes, 0);
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ impl OpenAICompatProvider {
|
||||
crate::provider::MessageRole::Assistant => "assistant",
|
||||
crate::provider::MessageRole::Tool => "tool",
|
||||
};
|
||||
// F-260614-05 Phase 2a: 多模态 content(须在 move m.tool_calls 之前算,借用 m)。
|
||||
// 多模态 content(须在 move m.tool_calls 之前算,借用 m)。
|
||||
// 含图消息走 content 数组(text/image_url);纯文本走字符串简写
|
||||
// (保持与现有纯文本端点零回归)。image_url 支持 data URI(base64)与 http(s) URL。
|
||||
let content = if m.has_image() {
|
||||
@@ -176,7 +176,7 @@ impl OpenAICompatProvider {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// B-260626-01: 保证首条 user/system(OpenAI 协议要求首条非 assistant/tool)。
|
||||
// 保证首条 user/system(OpenAI 协议要求首条非 assistant/tool)。
|
||||
// 对齐 AnthropicCompatProvider::ensure_leading_user:上游绕过 sanitize 的调用方
|
||||
// (标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
|
||||
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
|
||||
@@ -231,7 +231,7 @@ impl OpenAICompatProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// B-260626-01: 保证 messages 首条为 user/system(OpenAI 协议要求首条非 assistant/tool)。
|
||||
/// 保证 messages 首条为 user/system(OpenAI 协议要求首条非 assistant/tool)。
|
||||
///
|
||||
/// 对齐 `AnthropicCompatProvider::ensure_leading_user`。上游绕过 `ContextManager::sanitize_messages`
|
||||
/// 的调用方(标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
|
||||
@@ -307,8 +307,8 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
|
||||
debug!(model = %openai_req.model, "OpenAI 同步调用");
|
||||
|
||||
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定。
|
||||
// 单请求 60s timeout 保持不变(FR-R4),重试是额外层: 3 次 × 60s 最坏 180s,
|
||||
// 指数退避重试: 包裹 send + 状态码判定。
|
||||
// 单请求 60s timeout 保持不变,重试是额外层: 3 次 × 60s 最坏 180s,
|
||||
// 由 retry_with_backoff 内部 30s 总预算主动止损。
|
||||
let label = format!("OpenAI[{}]", openai_req.model);
|
||||
retry_with_backoff(&label, move |_| {
|
||||
@@ -386,7 +386,7 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
|
||||
debug!(model = %openai_req.model, "OpenAI 流式调用");
|
||||
|
||||
// BUG-2026-07-07: send 阶段需 timeout 防 hang(同 Anthropic 路径)。
|
||||
// send 阶段需 timeout 防 hang(同 Anthropic 路径)。
|
||||
// 不能用 reqwest .timeout()(会砍流式 body),改用 tokio::time::timeout 包裹 send。
|
||||
let send_future = self
|
||||
.client
|
||||
@@ -414,7 +414,7 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body);
|
||||
}
|
||||
|
||||
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream 库。
|
||||
// 原生 SSE 解析器替代 eventsource-stream 库。
|
||||
// eventsource-stream 在 Windows 上对 Deepseek 等响应报 "error decoding response body"
|
||||
// (严格 UTF-8 + SSE 协议校验,跨 chunk 字符/不完整事件均报错且不可恢复)。
|
||||
// 原生解析器:bytes 累积 + from_utf8_lossy 宽松处理 + \n\n 分隔,容错不中断流。
|
||||
@@ -616,7 +616,7 @@ mod tests {
|
||||
assert!(!c.finished);
|
||||
}
|
||||
|
||||
// ---------- F-260614-05 Phase 2a 多模态 convert_request ----------
|
||||
// ---------- 多模态 convert_request ----------
|
||||
|
||||
/// 含图消息 → content 数组(text + image_url data URI);纯文本 → 字符串简写
|
||||
#[test]
|
||||
@@ -672,9 +672,9 @@ mod tests {
|
||||
assert_eq!(msg.content, serde_json::Value::String("hello".into()));
|
||||
}
|
||||
|
||||
// ---------- B-260626-01: ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic)----------
|
||||
// ---------- ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic)----------
|
||||
|
||||
/// B-260626-01: 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的
|
||||
/// 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的
|
||||
/// 调用方(title/knowledge_inject/工作流节点)可能传入首条 assistant 序列,补占位保留上下文。
|
||||
#[test]
|
||||
fn openai_ensure_leading_user_first_assistant_gets_placeholder() {
|
||||
@@ -699,7 +699,7 @@ mod tests {
|
||||
assert_eq!(out.messages[2].role.as_str(), "user");
|
||||
}
|
||||
|
||||
/// B-260626-01: 正常序列(user 开头)不补占位——零回归。
|
||||
/// 正常序列(user 开头)不补占位——零回归。
|
||||
#[test]
|
||||
fn openai_ensure_leading_user_normal_unchanged() {
|
||||
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
|
||||
|
||||
@@ -20,7 +20,7 @@ use std::time::Duration;
|
||||
use rand::Rng;
|
||||
use tracing::warn;
|
||||
|
||||
/// 最多尝试次数(含初次)。B-260616-07: 3 次 = 初次 + 2 次重试。
|
||||
/// 最多尝试次数(含初次)。3 次 = 初次 + 2 次重试。
|
||||
///
|
||||
/// 配置化 TODO: 未来接入 per-provider config(`AiProviderRecord.config` JSON)或全局开关
|
||||
/// (`useSetting('df-ai-max-retries')`)时改为读取配置。当前低频后台调用,常量足够。
|
||||
@@ -71,8 +71,8 @@ pub fn is_status_retryable(status: u16) -> bool {
|
||||
/// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。
|
||||
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
|
||||
///
|
||||
/// CR-30-1: 暴露 pub 供 src-tauri/agentic.rs 流前重试复用(对齐决策 F-260616-07 a1
|
||||
/// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
|
||||
/// 暴露 pub 供 src-tauri/agentic.rs 流前重试复用
|
||||
/// ("复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
|
||||
pub fn backoff_delay(attempt: u32) -> Duration {
|
||||
let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000;
|
||||
// ±50% jitter,相对 base 时长的浮动比例
|
||||
|
||||
+14
-17
@@ -1,26 +1,23 @@
|
||||
//! 模型路由器 — F-01 阶段4
|
||||
//! 模型路由器。
|
||||
//!
|
||||
//! 纯函数核心,零 IO / 零状态。给定 TaskRequirements + 候选池,返回最优 ModelConfig。
|
||||
//! 不接调用点(那是阶段5:agentic.rs / title.rs / knowledge_inject.rs / project.rs /
|
||||
//! 不接调用点(那是调用方:agentic.rs / title.rs / knowledge_inject.rs / project.rs /
|
||||
//! df-ideas / df-nodes ai_node.rs)。
|
||||
//!
|
||||
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §6.1。
|
||||
//!
|
||||
//! ModelRouter 为单元结构,select 是无状态关联函数(对齐任务规格,非设计文档的 `&self` 方法)。
|
||||
//! ModelRouter 为单元结构,select 是无状态关联函数。
|
||||
|
||||
// 阶段5: 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
|
||||
// 直接 import 维度枚举构造 TaskRequirements(对齐任务规格 import 风格),re-export 避免调用点
|
||||
// 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
|
||||
// 直接 import 维度枚举构造 TaskRequirements,re-export 避免调用点
|
||||
// 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。
|
||||
// 注:CostTier/IntelligenceTier 路由已解耦(2026-06-18 B-260618-03)——provider /v1/models API
|
||||
// 注:CostTier/IntelligenceTier 路由已解耦——provider /v1/models API
|
||||
// 不返回这两维度,数据无客观依据不可信,不参与硬路由;re-export 保留供未来真实判别源。
|
||||
pub use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig};
|
||||
|
||||
/// 任务对模型的需求(3 维度)。
|
||||
///
|
||||
/// 由调用点构造(阶段5),描述本次调用需要什么模态/能力/上下文,
|
||||
/// 由调用点构造,描述本次调用需要什么模态/能力/上下文,
|
||||
/// 交 ModelRouter::select 在候选池中选最优模型。
|
||||
///
|
||||
/// 路由已解耦(2026-06-18 B-260618-03):原 `min_intelligence`/`max_cost` 两字段删除。
|
||||
/// 路由已解耦:原 `min_intelligence`/`max_cost` 两字段删除。
|
||||
/// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 +
|
||||
/// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier)
|
||||
/// 保留供未来出现真实判别源时再接回。
|
||||
@@ -49,7 +46,7 @@ impl ModelRouter {
|
||||
/// 4. 窗口够大 — context_window >= estimated_context
|
||||
/// 5. max_by_key 选最优:纯 weight 主导(权重高者胜)
|
||||
///
|
||||
/// 路由已解耦(2026-06-18 B-260618-03):原「智力达标」/「成本可控」两步删除,
|
||||
/// 路由已解耦:原「智力达标」/「成本可控」两步删除,
|
||||
/// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序纯 weight 主导。
|
||||
/// cost_tier/intelligence 数据无客观依据(provider /v1/models 不返回,靠预设表+模型名
|
||||
/// 启发式猜),不参与硬路由。枚举保留供未来真实判别源再接回。
|
||||
@@ -63,7 +60,7 @@ impl ModelRouter {
|
||||
}
|
||||
}
|
||||
|
||||
/// 阶段5 调用点 helper — 路由选模型并直接返回 model_id(纯函数)。
|
||||
/// 调用点 helper — 路由选模型并直接返回 model_id(纯函数)。
|
||||
///
|
||||
/// 给定 TaskRequirements + 候选池,返回最优模型的 `model_id`。
|
||||
/// 调用点用法:`provider.model_configs`(Vec<ModelConfig>)→ `select_model_id(&req, &pool)`
|
||||
@@ -106,7 +103,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 阶段5 select_model_id helper ──
|
||||
// ── select_model_id helper ──
|
||||
|
||||
#[test]
|
||||
fn select_model_id_empty_pool_returns_none() {
|
||||
@@ -248,7 +245,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 步骤 4(原智力/成本过滤已解耦 B-260618-03):窗口够大 ──
|
||||
// ── 步骤 4(原智力/成本过滤已解耦):窗口够大 ──
|
||||
|
||||
#[test]
|
||||
fn context_window_insufficient() {
|
||||
@@ -316,13 +313,13 @@ mod tests {
|
||||
#[test]
|
||||
fn all_dimensions_match_picks_best() {
|
||||
// 3+ 候选各维度参差,验证过滤链全过 + max_by_key 纯 weight 选最优。
|
||||
// (B-260618-03:智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
|
||||
// (智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
|
||||
//
|
||||
// 候选:
|
||||
// a: weight 60 → 通过全部过滤,key=60
|
||||
// b: weight 80 → 通过,key=80 — weight 最高档(与 c 并列)
|
||||
// c: weight 80 → 通过,key=80 — 同 weight 80,max_by_key 并列返回最后
|
||||
// d: weight 90 → 通过(B-260618-03 后 intelligence 不参与过滤),key=90 — weight 最高,胜
|
||||
// d: weight 90 → 通过(intelligence 不参与过滤),key=90 — weight 最高,胜
|
||||
// e: enabled=false → 步骤 1 滤掉
|
||||
//
|
||||
// 预期:d 胜(weight 90 最高,不再被 intelligence 滤掉)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 原生 SSE 流式解析器 — 替代 eventsource-stream 库
|
||||
//!
|
||||
//! BUG-2026-07-17 根治: eventsource-stream 0.2 在 Windows 上对 Deepseek 等 provider
|
||||
//! eventsource-stream 0.2 在 Windows 上对 Deepseek 等 provider
|
||||
//! 的 SSE 响应解析时报 "Transport error: error decoding response body" 错误。
|
||||
//!
|
||||
//! 根因分析:
|
||||
@@ -11,9 +11,13 @@
|
||||
//! - 非 ASCII 字符的多字节序列跨 chunk 边界
|
||||
//!
|
||||
//! 本解析器实现:
|
||||
//! - 宽松的 UTF-8 处理(用 bytes 累积,String::from_utf8_lossy 转换,不报错)
|
||||
//! - SSE 协议简单解析(以 \n\n 分隔事件,data: 前缀提取)
|
||||
//! - 容错:解析失败时跳过该事件继续,不中断流
|
||||
//! - 宽松的 UTF-8 处理(用 bytes 累积,优先 `String::from_utf8` 严格解码保留多字节完整,
|
||||
//! 失败再降级 `from_utf8_lossy` 不报错)
|
||||
//! - SSE 协议简单解析(分隔符兼容 `\n\n` / `\r\n\r\n` / `\r\r` 三种行尾归一,
|
||||
//! data: 前缀提取)
|
||||
//! - 多字节续接:事件体只在定位到完整分隔符后才解码,跨 chunk 的多字节字符在
|
||||
//! buffer 中天然拼接复原(分隔符为 ASCII,不落在多字节序列中间)
|
||||
//! - 容错:解析失败时跳过该事件继续,不中断流;buffer 设 `BUF_MAX` 上限防 OOM
|
||||
//! - 返回 Vec<String>(每个元素是一个事件 data 字段拼接内容)
|
||||
|
||||
use futures::Stream;
|
||||
@@ -23,6 +27,10 @@ use std::task::{Context, Poll};
|
||||
/// SSE 事件流的 data 字段内容
|
||||
pub type SseEvent = String;
|
||||
|
||||
/// 缓冲区字节上限(防御性):正常流会被 parse_events 持续消费,
|
||||
/// 仅畸形上游(持续不发分隔符)时触发裁剪,避免无界增长 OOM。
|
||||
const BUF_MAX: usize = 1 << 20; // 1 MiB
|
||||
|
||||
/// 原生 SSE 解析器流:包装 bytes_stream,产出 Vec<SseEvent>(一次 poll 可能产出多个事件)
|
||||
pub struct SseStream<S> {
|
||||
inner: S,
|
||||
@@ -40,19 +48,42 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 buffer 解析完整的 SSE 事件(以 \n\n 分隔),返回事件列表
|
||||
/// 从 buffer 解析完整的 SSE 事件,返回事件列表。
|
||||
///
|
||||
/// 事件分隔符兼容三种行尾归一:
|
||||
/// - `\n\n` (LF LF,规范形态)
|
||||
/// - `\r\n\r\n` (CRLF CRLF,部分中转站/反代发送)
|
||||
/// - `\r\r` (CR CR,极少见但同源)
|
||||
///
|
||||
/// 取最早出现的分隔符;事件体内部的 `\r\n` 在解码后归一为 `\n`,
|
||||
/// 确保 `lines()` / `strip_prefix` 等行级解析正常工作。
|
||||
///
|
||||
/// 多字节续接:事件体只在定位到完整分隔符后才解码,跨 chunk 的多字节
|
||||
/// 字符在 buffer 中天然拼接复原(分隔符为 ASCII,不会落在多字节序列中间)。
|
||||
/// 解码用 `String::from_utf8`(失败再降级 lossy),避免把合法多字节误判为残缺。
|
||||
fn parse_events(&mut self) -> Vec<SseEvent> {
|
||||
let mut events = Vec::new();
|
||||
loop {
|
||||
let sep_pos = self.buffer.windows(2).position(|w| w == b"\n\n");
|
||||
if sep_pos.is_none() {
|
||||
break;
|
||||
// 同时识别三种分隔符,取最早出现的位置与该分隔符的字节长度
|
||||
let sep = Self::find_event_boundary(&self.buffer);
|
||||
let (sep_pos, sep_len) = match sep {
|
||||
Some(v) => v,
|
||||
None => break,
|
||||
};
|
||||
// 取出事件体 + 分隔符整体(buffer 前 sep_pos+sep_len 字节)
|
||||
let event_bytes: Vec<u8> = self.buffer.drain(..sep_pos + sep_len).collect();
|
||||
// 去掉末尾的分隔符,得到事件体
|
||||
let body_end = event_bytes.len().saturating_sub(sep_len);
|
||||
let body_bytes = &event_bytes[..body_end];
|
||||
// 解码:优先严格 UTF-8(保留多字节完整),失败再降级 lossy(不崩)
|
||||
let mut event_text = match std::str::from_utf8(body_bytes) {
|
||||
Ok(s) => s.to_owned(),
|
||||
Err(_) => String::from_utf8_lossy(body_bytes).into_owned(),
|
||||
};
|
||||
// 事件体内 CRLF / 裸 CR 归一为 LF,保证后续行级解析一致
|
||||
if event_text.contains('\r') {
|
||||
event_text = event_text.replace("\r\n", "\n").replace('\r', "\n");
|
||||
}
|
||||
let sep_pos = sep_pos.unwrap();
|
||||
let event_bytes: Vec<u8> = self.buffer.drain(..sep_pos + 2).collect();
|
||||
// 去掉末尾的 \n\n
|
||||
let body_end = event_bytes.len().saturating_sub(2);
|
||||
let event_text = String::from_utf8_lossy(&event_bytes[..body_end]);
|
||||
let data = Self::extract_data_fields(&event_text);
|
||||
if !data.is_empty() {
|
||||
events.push(data);
|
||||
@@ -61,6 +92,29 @@ where
|
||||
events
|
||||
}
|
||||
|
||||
/// 在 buffer 中查找最早的事件分隔符,返回 `(起始位置, 分隔符字节长度)`。
|
||||
/// 扫描 `\r\n\r\n` / `\n\n` / `\r\r` 三种形态,取最小起始位置(取最早分隔符)。
|
||||
fn find_event_boundary(buf: &[u8]) -> Option<(usize, usize)> {
|
||||
// candidates: (pattern, length)
|
||||
const PATTERNS: &[(&[u8], usize)] = &[
|
||||
(b"\r\n\r\n", 4),
|
||||
(b"\n\n", 2),
|
||||
(b"\r\r", 2),
|
||||
];
|
||||
let mut best: Option<(usize, usize)> = None;
|
||||
for &(pat, len) in PATTERNS {
|
||||
// windows 匹配;找到首次出现位置
|
||||
if let Some(pos) = buf.windows(pat.len()).position(|w| w == pat) {
|
||||
match best {
|
||||
None => best = Some((pos, len)),
|
||||
Some((bpos, _)) if pos < bpos => best = Some((pos, len)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// 从 SSE 事件文本中提取所有 data: 行的内容,拼接为单个字符串(多个 data 行用 \n 连接)
|
||||
fn extract_data_fields(event_text: &str) -> String {
|
||||
let mut data_parts: Vec<&str> = Vec::new();
|
||||
@@ -94,6 +148,12 @@ where
|
||||
match self.inner.poll_next_unpin(cx) {
|
||||
Poll::Ready(Some(Ok(chunk))) => {
|
||||
self.buffer.extend_from_slice(&chunk);
|
||||
// 防御:缓冲区不应无限增长。正常情况下 parse_events 会持续消费,
|
||||
// 仅当上游持续不发分隔符(畸形流)时才触发,此处裁掉头部旧数据避免 OOM。
|
||||
if self.buffer.len() > BUF_MAX {
|
||||
let drop_n = self.buffer.len() - BUF_MAX;
|
||||
self.buffer.drain(..drop_n);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Some(Err(e))) => {
|
||||
@@ -102,8 +162,17 @@ where
|
||||
Poll::Ready(None) => {
|
||||
// 流结束,处理 buffer 中的剩余数据(可能没有 \n\n 结束的最后一段)
|
||||
if !self.buffer.is_empty() {
|
||||
let remaining = String::from_utf8_lossy(&self.buffer).to_string();
|
||||
// 解码:优先严格 UTF-8(EOF 无下一 chunk 可拼接,
|
||||
// 残缺多字节尾部只能尽力而为,降级 lossy 不崩)
|
||||
let mut remaining = match String::from_utf8(self.buffer.clone()) {
|
||||
Ok(s) => s,
|
||||
Err(_) => String::from_utf8_lossy(&self.buffer).into_owned(),
|
||||
};
|
||||
self.buffer.clear();
|
||||
// 同主解析路径:CRLF / 裸 CR 归一为 LF
|
||||
if remaining.contains('\r') {
|
||||
remaining = remaining.replace("\r\n", "\n").replace('\r', "\n");
|
||||
}
|
||||
let data = Self::extract_data_fields(&remaining);
|
||||
if !data.is_empty() {
|
||||
return Poll::Ready(Some(Ok(vec![data])));
|
||||
|
||||
@@ -51,7 +51,7 @@ impl EnvSnapshot {
|
||||
return snap;
|
||||
}
|
||||
// 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。
|
||||
// BUG-2026-07-18 根治: probe_version 内 std::process::Command::output() 无 timeout,
|
||||
// probe_version 内 std::process::Command::output() 无 timeout,
|
||||
// Windows 上 python/node 若是 Microsoft Store App Execution Alias(用户未装但开了
|
||||
// "应用执行别名"),`python --version` 触发 Store 重定向、process 不退出 → output()
|
||||
// 永久阻塞 → spawn_blocking 线程永不返回 → detect().await 永久挂 → run_agentic_loop
|
||||
|
||||
@@ -33,8 +33,8 @@ pub enum ShellType {
|
||||
impl Default for ShellType {
|
||||
fn default() -> Self {
|
||||
// L1 环境感知:Windows 默认 PowerShell 系(非 Cmd)。PowerShell 对引号/$变量/Unicode 处理
|
||||
// 远优于 cmd,从根上避 kms 类引号转义地狱(seq26-52 撞墙 20+ 次)。AI 写文件执行见 env_profile。
|
||||
// BUG-260623-04:优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
|
||||
// 远优于 cmd,从根上避 kms 类引号转义地狱。AI 写文件执行见 env_profile。
|
||||
// 优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
|
||||
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
|
||||
// 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。
|
||||
// 注:Default trait 为同步签名,这里只能读取已探测的缓存结果(若未探测则返回 false,退回 PowerShell)。
|
||||
@@ -146,7 +146,7 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
||||
// 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。
|
||||
cmd.kill_on_drop(true);
|
||||
|
||||
// B-260619-01: Windows 下创建子进程默认弹控制台窗口(cmd/powershell 黑窗闪现)。
|
||||
// Windows 下创建子进程默认弹控制台窗口(cmd/powershell 黑窗闪现)。
|
||||
// CREATE_NO_WINDOW(0x0800_0000) 标志抑制窗口创建,后台静默执行。
|
||||
// tokio::process::Command 在 Windows 自带 creation_flags 方法(无需 std CommandExt trait)。
|
||||
#[cfg(windows)]
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! 评估来源由 [`EvaluatedBy`] 三态标记:`Llm`(LLM 深度评估)/ `Heuristic`(主动选启发式,
|
||||
//! 无 provider)/ `HeuristicFallback`(LLM 调用失败降级)。前端可据此显示评估深度标签。
|
||||
//!
|
||||
//! LLM prompt 构造与 JSON 解析在 F-260614-03 接入:[`AdversarialEngine::evaluate_with_llm`]
|
||||
//! LLM prompt 构造与 JSON 解析在后续接入:[`AdversarialEngine::evaluate_with_llm`]
|
||||
//! 构造三角色辩论 prompt(正方/反方/分析师),调一次 `complete()` 要求返回对齐结构的 JSON,
|
||||
//! 解析失败/字段缺失/枚举非法 → `bail` 触发降级([`AdversarialEngine::evaluate`] 已兜底)。
|
||||
//!
|
||||
@@ -42,7 +42,7 @@ pub struct AdversarialEngine {
|
||||
/// 可选 LLM provider。Some → 优先 LLM 评估(失败降级启发式);None → 纯启发式。
|
||||
/// 构造注入(与 IdeaPromoter::new(policy) 同一模式),批量评估复用同一 provider。
|
||||
provider: Option<Arc<dyn LlmProvider>>,
|
||||
/// F-01 阶段5: 候选模型池。非空时 evaluate_with_llm 经 select_model_id 路由选模型;
|
||||
/// 候选模型池。非空时 evaluate_with_llm 经 select_model_id 路由选模型;
|
||||
/// 空(None provider 或未注入池)→ model 留空由 provider impl 回填自身 default_model
|
||||
/// (与接入前行为一致,平稳过渡)。
|
||||
model_pool: Vec<ModelConfig>,
|
||||
@@ -54,7 +54,7 @@ impl AdversarialEngine {
|
||||
Self { provider: Some(provider), model_pool: Vec::new() }
|
||||
}
|
||||
|
||||
/// F-01 阶段5: 注入 provider + 候选模型池构造。池非空时 evaluate_with_llm 走路由。
|
||||
/// 注入 provider + 候选模型池构造。池非空时 evaluate_with_llm 走路由。
|
||||
pub fn with_pool(provider: Arc<dyn LlmProvider>, model_pool: Vec<ModelConfig>) -> Self {
|
||||
Self { provider: Some(provider), model_pool }
|
||||
}
|
||||
@@ -101,7 +101,7 @@ impl AdversarialEngine {
|
||||
/// analyst.final_assessment 自洽性等),待产品决策,当前逻辑原样保留不调整。
|
||||
async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> {
|
||||
let prompt = build_adversarial_prompt(idea);
|
||||
// F-01 阶段5: 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。
|
||||
// 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。
|
||||
// 池非空 → select_model_id 选最优 model_id;池空/无匹配 → 留空由 provider impl
|
||||
// 回填自身 default_model(与接入前行为一致,平稳过渡)。
|
||||
let eval_req = df_ai::router::TaskRequirements {
|
||||
@@ -421,7 +421,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// LLM 路径测试(F-260614-03)
|
||||
// LLM 路径测试
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// mock LlmProvider:按构造时给定的响应文本回放,仅供 adversarial 单测。
|
||||
|
||||
@@ -84,7 +84,7 @@ pub enum Recommendation {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LLM 对抗评估 — prompt 构造 / JSON 解析(F-260614-03)
|
||||
// LLM 对抗评估 — prompt 构造 / JSON 解析
|
||||
// ============================================================
|
||||
|
||||
/// LLM 角色 / 输出契约的系统级约束。
|
||||
|
||||
@@ -45,7 +45,7 @@ impl Node for AiNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("AiNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
// FR-S1 注入链:provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
|
||||
// provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
|
||||
let p = resolve_and_parse(&self.db, &ctx.config, &ctx.inputs).await?;
|
||||
let provider: Box<dyn LlmProvider> = provider_from_params(&p);
|
||||
|
||||
@@ -117,12 +117,12 @@ impl Node for AiNode {
|
||||
"properties": {
|
||||
"prompt": { "type": "string", "description": "用户提示词(若无则取上游 prompt 输出)" },
|
||||
"system_prompt": { "type": "string", "description": "系统提示词(可选)" },
|
||||
"provider_id": { "type": "string", "description": "AI Provider ID(FR-S1:密钥经 df_storage::secret 解析,不进 config;留空走默认 provider)" },
|
||||
"provider_id": { "type": "string", "description": "AI Provider ID(密钥经 df_storage::secret 解析,不进 config;留空走默认 provider)" },
|
||||
"model": { "type": "string", "description": "模型名(可选,留空用 record.default_model)" },
|
||||
"temperature": { "type": "number", "description": "温度 0.0~2.0(可选)" },
|
||||
"max_tokens": { "type": "integer", "description": "最大生成 token(可选,anthropic 协议无值时默认 4096)" },
|
||||
"base_url": { "type": "string", "description": "(已废弃过渡)明文 API 地址,改用 provider_id" },
|
||||
"api_key": { "type": "string", "description": "(已废弃过渡)明文 API 密钥,改用 provider_id;FR-S1 下经 secret 解析" }
|
||||
"api_key": { "type": "string", "description": "(已废弃过渡)明文 API 密钥,改用 provider_id;密钥经 secret 解析" }
|
||||
},
|
||||
// SW-260618-15: prompt/provider_id 均"留空走兜底"(prompt 取上游、provider_id 走默认 provider),与 required 矛盾。改 required=[] 对齐 execute 运行时,防前端按 schema 误拒合法配置。
|
||||
"required": []
|
||||
@@ -231,7 +231,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// resolve_provider 双路径测试(FR-S1 注入链核心)
|
||||
// resolve_provider 双路径测试(注入链核心)
|
||||
// ============================================================
|
||||
|
||||
/// 内存 DB 插 provider(可选 is_default),返回 (db, provider_id)。
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::sync::Arc;
|
||||
|
||||
use df_ai::df_ai_core::model::{Modality, ModelConfig};
|
||||
use df_ai::provider::LlmProvider;
|
||||
// F-01 阶段5: AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由
|
||||
// AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由
|
||||
// (默认 Standard + needs_tool_use=true)。池空/无匹配兜底 record.default_model。
|
||||
use df_ai::router::{select_model_id, TaskRequirements};
|
||||
use df_storage::crud::AiProviderRepo;
|
||||
@@ -22,7 +22,7 @@ use df_workflow::node::{NodeOutput};
|
||||
/// AI 节点解析后的参数(execute 与参数解析解耦,便于单测覆盖取值/默认/校验逻辑)
|
||||
///
|
||||
/// provider 配置(base_url/api_key/protocol/default_model)经 `resolve_provider` 从 DB
|
||||
/// ai_providers 表查 record + 经 df_storage::secret 解析密钥得到,**不进 config(FR-S1)**。
|
||||
/// ai_providers 表查 record + 经 df_storage::secret 解析密钥得到,**不进 config**。
|
||||
/// api_key 仅存于本结构体内存(AiNode 进程内存),不落 NodeContext.config / NodeOutput.data。
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AiNodeParams {
|
||||
@@ -37,7 +37,7 @@ pub(crate) struct AiNodeParams {
|
||||
|
||||
/// 经 `resolve_provider` 从 ai_providers 表 + df_storage::secret 解析后的 provider 构造要素。
|
||||
///
|
||||
/// api_key 字段:明文,FR-S1 下全程不出 AiNode 进程内存(不进 config/output/schema)。
|
||||
/// api_key 字段:明文,全程不出 AiNode 进程内存(不进 config/output/schema)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ResolvedProvider {
|
||||
/// 协议类型:openai_compat(默认)/ anthropic(GLM 订阅 / Claude 官方)— 从 record.provider_type 映射
|
||||
@@ -47,12 +47,12 @@ pub(crate) struct ResolvedProvider {
|
||||
pub api_key: String,
|
||||
/// model 为空时的占位(record.default_model 或 "gpt-4o-mini"),避免 provider 构造 panic
|
||||
pub default_model: String,
|
||||
/// F-01 阶段5: 候选模型池(来自 record.model_configs)。parse_params 路由用:
|
||||
/// 候选模型池(来自 record.model_configs)。parse_params 路由用:
|
||||
/// config.model 留空时经 select_model_id 选最优;池空兜底 default_model。
|
||||
pub model_pool: Vec<ModelConfig>,
|
||||
}
|
||||
|
||||
/// 经 ai_providers 表 + df_storage::secret 解析 provider 构造要素(FR-S1 注入链核心)。
|
||||
/// 经 ai_providers 表 + df_storage::secret 解析 provider 构造要素(注入链核心)。
|
||||
///
|
||||
/// 三路径(优先级从高到低):
|
||||
/// 1. **provider_id 优先**:config["provider_id"] 存在 → `AiProviderRepo::get_by_id` 查 record →
|
||||
@@ -114,12 +114,11 @@ pub(crate) async fn resolve_provider(
|
||||
let plain_key = config.get("api_key").and_then(|v| v.as_str());
|
||||
if let (Some(base_url_str), Some(api_key_str)) = (plain_base, plain_key) {
|
||||
tracing::warn!(
|
||||
"AiNode 明文 api_key/base_url 经 config 注入已废弃, 改用 provider_id (FR-S1). \
|
||||
"AiNode 明文 api_key/base_url 经 config 注入已废弃, 改用 provider_id. \
|
||||
老路径将在后续版本移除"
|
||||
);
|
||||
let base_url = base_url_str.to_string();
|
||||
let api_key = api_key_str.to_string();
|
||||
ensure_resolved_key("(明文注入)", &api_key)
|
||||
let api_key = ensure_resolved_key("(明文注入)", api_key_str)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let protocol = config
|
||||
.get("protocol")
|
||||
@@ -162,7 +161,7 @@ pub(crate) fn resolve_from_record(
|
||||
config_model: &str,
|
||||
) -> anyhow::Result<ResolvedProvider> {
|
||||
let api_key = resolve_provider_secret(record);
|
||||
ensure_resolved_key(&record.name, &api_key).map_err(anyhow::Error::msg)?;
|
||||
let api_key = ensure_resolved_key(&record.name, &api_key).map_err(anyhow::Error::msg)?;
|
||||
let default_model = if !config_model.is_empty() {
|
||||
config_model.to_string()
|
||||
} else if !record.default_model.is_empty() {
|
||||
@@ -201,7 +200,7 @@ pub(crate) fn parse_params(
|
||||
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: prompt(config 或上游输入均无)"))?;
|
||||
|
||||
// ── 可选参数 ──
|
||||
// model 解析优先级(F-01 阶段5):config.model 显式指定 > 路由选优(provider.model_pool 非空时)
|
||||
// model 解析优先级:config.model 显式指定 > 路由选优(provider.model_pool 非空时)
|
||||
// > 空(CompletionRequest.model 留空由 provider impl 回填 default_model,行为不变)。
|
||||
// 注:provider.model_pool 在 provider move 进 AiNodeParams 前先借引用路由,选中的 model_id
|
||||
// 填入 CompletionRequest.model;provider.default_model 仍是 build_provider 兜底用。
|
||||
@@ -213,7 +212,7 @@ pub(crate) fn parse_params(
|
||||
let model = if !config_model.is_empty() {
|
||||
config_model
|
||||
} else {
|
||||
// F-01 阶段5: AiNode 默认路由 — needs_tool_use=true(工作流无人值守 AI 步骤
|
||||
// AiNode 默认路由 — needs_tool_use=true(工作流无人值守 AI 步骤
|
||||
// 常含工具调用,如检索/生成;无需工具的节点应在 config 显式指定 model)。
|
||||
// select_model_id None(池空/无匹配)→ 空串(由 provider impl 回填 default_model)。
|
||||
let node_req = TaskRequirements {
|
||||
@@ -289,10 +288,10 @@ pub(crate) fn truncate_for_summary(s: &str) -> String {
|
||||
format!("{truncated}…")
|
||||
}
|
||||
|
||||
/// 阶段3 自审闸门决策(纯函数,便于单测覆盖各 verdict/gate 组合)。
|
||||
/// 自审闸门决策(纯函数,便于单测覆盖各 verdict/gate 组合)。
|
||||
///
|
||||
/// 仅当 `gate==true` 且 `verdict=="fail"` 时阻断。verdict="unknown"(LLM 输出不可靠)
|
||||
/// 与 "pass" 均不阻断 —— unknown 保持人定权(阶段2 保守语义不变)。
|
||||
/// 与 "pass" 均不阻断 —— unknown 保持人定权(保守语义不变)。
|
||||
pub(crate) fn gate_should_block(gate: bool, verdict: &str) -> bool {
|
||||
gate && verdict == "fail"
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ impl Node for AiSelfReviewNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("AiSelfReviewNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
// FR-S1 注入链:provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
|
||||
// provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
|
||||
let p = resolve_and_parse(&self.db, &ctx.config, &ctx.inputs).await?;
|
||||
|
||||
// ── 读任务(需求 + 产出) ──
|
||||
@@ -195,7 +195,7 @@ impl Node for AiSelfReviewNode {
|
||||
"model": response.model,
|
||||
}));
|
||||
|
||||
// ── 阶段3: 自审闸门(F-260616-07 决策 a 步骤③) ──
|
||||
// ── 自审闸门 ──
|
||||
// config["gate"]==true 时,AiSelfReviewNode 从「自审辅助」升级为「DAG 节点闸门」:
|
||||
// verdict="fail" → 返回 Err → executor first_err 中止后续层(下游 human_review 不跑)
|
||||
// → 工作流 failed → ②-4 回调退回 in_review(对齐工作流失败语义)
|
||||
@@ -214,7 +214,7 @@ impl Node for AiSelfReviewNode {
|
||||
// C) executor 闸门检查钩子(节点 execute 后 executor 读 output.verdict):改 DagExecutor
|
||||
// 核心循环,牵动所有节点,风险/范围不符「最简不破坏」。
|
||||
// 选 A:语义最贴近「自审结果作为闸门」(自审节点自行决定放行/阻断),且 gate 可按节点
|
||||
// config 开关(默认 false = 阶段2 行为不变,模板/前端零强制改动,向后兼容)。
|
||||
// config 开关(默认 false = 辅助模式行为不变,模板/前端零强制改动,向后兼容)。
|
||||
let gate_enabled = ctx
|
||||
.config
|
||||
.get("gate")
|
||||
@@ -241,10 +241,10 @@ impl Node for AiSelfReviewNode {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": { "type": "string", "description": "自审目标任务 ID(必填)" },
|
||||
"provider_id": { "type": "string", "description": "AI Provider ID(FR-S1:密钥经 secret 解析不进 config;留空走默认 provider)" },
|
||||
"provider_id": { "type": "string", "description": "AI Provider ID(密钥经 secret 解析不进 config;留空走默认 provider)" },
|
||||
"model": { "type": "string", "description": "模型名(可选,留空用 record.default_model)" },
|
||||
"max_tokens": { "type": "integer" },
|
||||
"gate": { "type": "boolean", "description": "阶段3 闸门开关:false(默认)=自审辅助,verdict 仅透传展示;true=自审结果作 DAG 闸门,verdict=fail 返回 Err 阻断下游(工作流 failed → ②-4 退回),verdict=unknown/pass 放行" }
|
||||
"gate": { "type": "boolean", "description": "闸门开关:false(默认)=自审辅助,verdict 仅透传展示;true=自审结果作 DAG 闸门,verdict=fail 返回 Err 阻断下游(工作流 failed → ②-4 退回),verdict=unknown/pass 放行" }
|
||||
},
|
||||
"required": ["task_id", "provider_id"]
|
||||
}),
|
||||
@@ -411,18 +411,18 @@ mod tests {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 阶段3 自审闸门(gate_should_block)单测
|
||||
// 自审闸门(gate_should_block)单测
|
||||
// ============================================================
|
||||
//
|
||||
// gate 决策矩阵:
|
||||
// gate=false(默认,阶段2 行为) → 任何 verdict 都放行(辅助模式)
|
||||
// gate=false(默认,辅助模式行为) → 任何 verdict 都放行(辅助模式)
|
||||
// gate=true + verdict=pass → 放行
|
||||
// gate=true + verdict=unknown → 放行(LLM 不可靠时不阻断,人定权)
|
||||
// gate=true + verdict=fail → 阻断(返回 Err,工作流 failed)
|
||||
|
||||
#[test]
|
||||
fn gate_disabled_never_blocks_any_verdict() {
|
||||
// gate 默认 false(阶段2 兼容):无论 verdict 如何都不阻断,自审仅辅助展示
|
||||
// gate 默认 false:无论 verdict 如何都不阻断,自审仅辅助展示
|
||||
assert!(!gate_should_block(false, "fail"), "gate 关闭时 fail 也不阻断");
|
||||
assert!(!gate_should_block(false, "pass"), "gate 关闭时 pass 放行");
|
||||
assert!(!gate_should_block(false, "unknown"), "gate 关闭时 unknown 放行");
|
||||
|
||||
@@ -40,7 +40,7 @@ impl Node for HumanNode {
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(3600);
|
||||
|
||||
// F-260615-01: 解析 select_type(缺省 Single,向后兼容)。非 "multiple" 一律按 Single 处理。
|
||||
// 解析 select_type(缺省 Single,向后兼容)。非 "multiple" 一律按 Single 处理。
|
||||
let select_type = match config.get("select_type").and_then(|v| v.as_str()) {
|
||||
Some("multiple") => SelectType::Multiple,
|
||||
_ => SelectType::Single,
|
||||
@@ -74,7 +74,7 @@ impl Node for HumanNode {
|
||||
Ok(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id, node_id, decision, decisions, comment,
|
||||
}) if execution_id == ctx.execution_id && node_id == ctx.node_id => {
|
||||
// F-260615-01: 归一化决策集合(优先用 decisions 数组,空则回退兼容 decision 单值)
|
||||
// 归一化决策集合(优先用 decisions 数组,空则回退兼容 decision 单值)
|
||||
// select_type=Single → 决策数必须 =1
|
||||
// select_type=Multiple → 决策数必须 ≥1
|
||||
// options 空 → 允许自由文本(仅受数量约束);
|
||||
@@ -91,24 +91,26 @@ impl Node for HumanNode {
|
||||
let each_valid = picked.iter().all(|d| !d.is_empty())
|
||||
&& (options.is_empty() || picked.iter().all(|d| options.contains(d)));
|
||||
if count_ok && each_valid {
|
||||
// F-260616-06 阶段2: 拒绝语义化。
|
||||
// 拒绝语义化。
|
||||
// 审批拒绝此前与同意一样返 Ok —— 语义反转(审批被拒却报"成功"),
|
||||
// 下游无法据 failed 触发退回/重做。
|
||||
// 现:decision 命中拒绝关键字(见 REJECT_KEYWORDS)→ 返 Err
|
||||
// "人工审批被拒绝(用户选择: <decision>)",executor Err 分支 set_failed
|
||||
// → 工作流 failed 状态 → 阶段2 推进链可据 failed 触发退回。
|
||||
// → 工作流 failed 状态 → 推进链可据 failed 触发退回。
|
||||
// 行为变更:审批拒绝从 Ok → Err,标注(同步通知主代理)。
|
||||
if contains_reject(&picked) {
|
||||
let primary = picked.first().cloned().unwrap_or_default();
|
||||
let comment_str = comment.unwrap_or_default();
|
||||
// 意见后缀:空 comment 不拼接,非空才追加(避免空括号)
|
||||
let suffix = if comment_str.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(";意见: {}", comment_str)
|
||||
};
|
||||
return Err(anyhow::anyhow!(
|
||||
"人工审批被拒绝(用户选择: {}){}",
|
||||
primary,
|
||||
if comment_str.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(";意见: {}", comment_str)
|
||||
}
|
||||
suffix,
|
||||
));
|
||||
}
|
||||
// 输出统一含 decisions 数组;保留 decision 取首项(向后兼容下游消费者)
|
||||
@@ -225,7 +227,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// 发一条审批响应到事件总线(模拟前端 approve_human_approval IPC 走完后的链路)。
|
||||
/// F-260615-01: 单选调用方仅填 decision;多选调用方填 decisions。
|
||||
/// 单选调用方仅填 decision;多选调用方填 decisions。
|
||||
async fn send_response(
|
||||
event_bus: &EventBus,
|
||||
execution_id: &str,
|
||||
@@ -244,7 +246,7 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
/// F-260615-01: 多选响应发送助手(填 decisions 数组,decision 留空)
|
||||
/// 多选响应发送助手(填 decisions 数组,decision 留空)
|
||||
async fn send_response_multi(
|
||||
event_bus: &EventBus,
|
||||
execution_id: &str,
|
||||
@@ -568,9 +570,9 @@ mod tests {
|
||||
assert_eq!(sm.get(&"h".to_string()), NodeStatus::Cancelled);
|
||||
}
|
||||
|
||||
// ===== F-260615-01: 多选审批覆盖 =====
|
||||
// ===== 多选审批覆盖 =====
|
||||
|
||||
/// F-260615-01: select_type=multiple + 多 decisions(均∈options) → 返回 decisions 数组
|
||||
/// select_type=multiple + 多 decisions(均∈options) → 返回 decisions 数组
|
||||
#[tokio::test]
|
||||
async fn multiple_select_returns_decisions_array() {
|
||||
let bus = EventBus::new();
|
||||
@@ -596,7 +598,7 @@ mod tests {
|
||||
assert_eq!(out.data["comment"], json!("多选"));
|
||||
}
|
||||
|
||||
/// F-260615-01: select_type=single 缺省 + decisions 多个 → 校验失败(count!=1)忽略后超时
|
||||
/// select_type=single 缺省 + decisions 多个 → 校验失败(count!=1)忽略后超时
|
||||
#[tokio::test]
|
||||
async fn single_select_rejects_multiple_decisions_then_timeout() {
|
||||
let bus = EventBus::new();
|
||||
@@ -618,7 +620,7 @@ mod tests {
|
||||
assert!(err.contains("超时"), "single 下多 decisions 应被忽略后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// F-260615-01: select_type=multiple 但 decisions 含 ∉ options 的项 → 非法忽略后超时
|
||||
/// select_type=multiple 但 decisions 含 ∉ options 的项 → 非法忽略后超时
|
||||
#[tokio::test]
|
||||
async fn multiple_select_invalid_option_ignored_then_timeout() {
|
||||
let bus = EventBus::new();
|
||||
@@ -644,7 +646,7 @@ mod tests {
|
||||
assert!(err.contains("超时"), "含非法 option 应被忽略后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// F-260615-01: 兼容旧调用方 —— 不填 select_type(缺省 single) + 只填 decision 单值,应正常通过
|
||||
/// 兼容旧调用方 —— 不填 select_type(缺省 single) + 只填 decision 单值,应正常通过
|
||||
/// (即所有未改造的现有 Request 均按 single 解析,零改动)
|
||||
#[tokio::test]
|
||||
async fn default_single_with_legacy_decision_single_value() {
|
||||
@@ -664,10 +666,10 @@ mod tests {
|
||||
assert_eq!(out.data["decisions"], json!(["同意"]), "兼容回退后 decisions 应含单值");
|
||||
}
|
||||
|
||||
// ===== F-260616-06 阶段2: 审批拒绝语义化(行为变更: 拒绝从 Ok → Err) =====
|
||||
// ===== 审批拒绝语义化(行为变更: 拒绝从 Ok → Err) =====
|
||||
|
||||
/// F-260616-06: 默认 options `["同意","拒绝"]` 下选「拒绝」→ Err(不再 Ok)。
|
||||
/// 阶段2 推进链依赖工作流 failed 触发退回,故拒绝必须让节点返 Err → executor set_failed。
|
||||
/// 默认 options `["同意","拒绝"]` 下选「拒绝」→ Err(不再 Ok)。
|
||||
/// 推进链依赖工作流 failed 触发退回,故拒绝必须让节点返 Err → executor set_failed。
|
||||
#[tokio::test]
|
||||
async fn reject_decision_returns_error() {
|
||||
let bus = EventBus::new();
|
||||
@@ -697,7 +699,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// F-260616-06: 同一 options 下选「同意」→ Ok(通过路径不回归)。
|
||||
/// 同一 options 下选「同意」→ Ok(通过路径不回归)。
|
||||
#[tokio::test]
|
||||
async fn approve_decision_still_ok() {
|
||||
let bus = EventBus::new();
|
||||
@@ -718,7 +720,7 @@ mod tests {
|
||||
assert_eq!(out.data["decision"], json!("同意"));
|
||||
}
|
||||
|
||||
/// F-260616-06: 英文 reject 关键字同样识别为拒绝 → Err(归一化大小写/空白)。
|
||||
/// 英文 reject 关键字同样识别为拒绝 → Err(归一化大小写/空白)。
|
||||
/// 多关键字覆盖走 reject_keyword_detection_normalized 纯单元测试,此处仅验证端到端一条。
|
||||
#[tokio::test]
|
||||
async fn english_reject_keyword_returns_error() {
|
||||
@@ -744,7 +746,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// F-260616-06: 多选场景,picked 含一项拒绝 → 整单拒绝 → Err
|
||||
/// 多选场景,picked 含一项拒绝 → 整单拒绝 → Err
|
||||
/// (选了「驳回」即驳回,即便同时选了「同意」)。
|
||||
#[tokio::test]
|
||||
async fn multiple_select_with_one_reject_returns_error() {
|
||||
@@ -776,7 +778,7 @@ mod tests {
|
||||
assert!(err.contains("拒绝"), "多选含拒绝项应返 Err, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// F-260616-06: options 空的自由文本场景 —— 明确拒绝词("拒绝")仍返 Err,
|
||||
/// options 空的自由文本场景 —— 明确拒绝词("拒绝")仍返 Err,
|
||||
/// 其余自由文本(非拒绝词)仍按通过处理(向后兼容,不阻断自由反馈)。
|
||||
#[tokio::test]
|
||||
async fn empty_options_free_text_reject_keyword_still_errors() {
|
||||
@@ -793,7 +795,7 @@ mod tests {
|
||||
assert!(err.contains("拒绝"), "自由文本明确为拒绝词仍应 Err, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// F-260616-06: options 空的自由文本场景 —— 非拒绝词自由文本仍返 Ok(不误伤自由反馈)。
|
||||
/// options 空的自由文本场景 —— 非拒绝词自由文本仍返 Ok(不误伤自由反馈)。
|
||||
/// (empty_options_allows_free_text 已覆盖 "改成先做B方案" → Ok,此处补一条非拒绝中文短句。)
|
||||
#[tokio::test]
|
||||
async fn empty_options_non_reject_free_text_still_ok() {
|
||||
@@ -810,7 +812,7 @@ mod tests {
|
||||
assert_eq!(out.data["decision"], json!("再讨论一下"));
|
||||
}
|
||||
|
||||
/// F-260616-06 单元: 关键字判定函数归一化(去空白+小写)与边界。
|
||||
/// 单元: 关键字判定函数归一化(去空白+小写)与边界。
|
||||
#[test]
|
||||
fn reject_keyword_detection_normalized() {
|
||||
assert!(is_reject_decision("拒绝"));
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
//! 从 human_node.rs 抽离的纯函数/常量(execute 与关键字判定解耦,便于单测覆盖)。
|
||||
//! HumanNode 的 struct + impl 仍保留在 human_node.rs(impl 块约束)。
|
||||
//!
|
||||
//! B-260615-05 / CR-260618-15: 拒绝语义化保留(executor set_failed → 推进链退回)。
|
||||
//! 拒绝语义化保留(executor set_failed → 推进链退回)。
|
||||
|
||||
/// F-260616-06 阶段2: 拒绝语义化关键字。
|
||||
/// 拒绝语义化关键字。
|
||||
/// decision 归一化(去空白 + 小写)后命中此集合 → 审批拒绝 → 节点返 Err(触发工作流 failed)。
|
||||
///
|
||||
/// 识别范围(避免误伤):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 任务推进节点 — advance_task 推进链触发器(F-260616-02)
|
||||
//! 任务推进节点 — advance_task 推进链触发器。
|
||||
//!
|
||||
//! 实现推进链的唯一 status 写入路径(D-260616-03 落 df-nodes Node):
|
||||
//! 1. 读当前 TaskRecord(取 from status)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! 任务推进状态机 — 7 态合法转换定义(F-260616-01)
|
||||
//! 任务推进状态机 — 7 态合法转换定义。
|
||||
//!
|
||||
//! 独立模块,非挂在 df-types::TaskStatus enum 上(对齐 D-260616-03「推进链业务逻辑落
|
||||
//! df-nodes」)。本模块只做「给定 from/to 是否合法」的纯函数判定,不触碰存储层
|
||||
//! (原子写 SQL 在 task_advance_node.rs 完成,见 F-260616-02)。
|
||||
//! (原子写 SQL 在 task_advance_node.rs 完成)。
|
||||
//!
|
||||
//! 7 态(与 df-types::TaskStatus / 前端对齐,D-260616-01):
|
||||
//! todo / in_progress / in_review / testing / done / blocked / cancelled
|
||||
@@ -124,7 +124,7 @@ pub fn is_regression(from: &str, to: &str) -> bool {
|
||||
matches!((from, to), (IN_REVIEW, IN_PROGRESS) | (TESTING, IN_REVIEW))
|
||||
}
|
||||
|
||||
/// 工作流联动任务「失败退一步」的目标态映射(F-260616-06 ②-4)。
|
||||
/// 工作流联动任务「失败退一步」的目标态映射。
|
||||
///
|
||||
/// 工作流失败时,任务不应停留在失败前向目标态,需回退到上一闸门重做。映射表:
|
||||
/// - testing → in_review(测试失败退回重审)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 任务推进链 DAG 模板(F-260616-06 阶段2 / D-260616-03)
|
||||
//! 任务推进链 DAG 模板。
|
||||
//!
|
||||
//! 推进链前向转换的工作流拓扑描述(声明式,纯数据)。DagDef 只描述节点与边,
|
||||
//! 执行逻辑靠 DagExecutor 驱动各 Node trait 的 execute —— 模板本身不跑逻辑。
|
||||
@@ -51,7 +51,7 @@ fn in_progress_template() -> DagDef {
|
||||
|
||||
/// in_review → testing:AiNode 自审 → HumanNode 核对。
|
||||
///
|
||||
/// 拓扑:ai → human 串行。阶段3(本批)起 ai_self_review 启用 gate:true:
|
||||
/// 拓扑:ai → human 串行。ai_self_review 启用 gate:true:
|
||||
/// - verdict=fail → AiSelfReviewNode 返回 Err → 工作流 failed(不经 human_review)
|
||||
/// → ②-4 失败回调退回 in_review(review_rounds+=1)。
|
||||
/// - verdict=unknown/pass → 放行 human_review,人定最终是否推进。
|
||||
@@ -59,9 +59,9 @@ fn in_progress_template() -> DagDef {
|
||||
/// 通过则完成回调(②-3)推进 status 到 testing。
|
||||
fn testing_template() -> DagDef {
|
||||
let mut dag = DagDef::new();
|
||||
// 决策 a 步骤③:ai_self_review 节点类型对齐 state.rs 注册的独立自审节点
|
||||
// 决策:ai_self_review 节点类型对齐 state.rs 注册的独立自审节点
|
||||
// (四维度 prompt + JSON 解析兜底 + 写回 output_json 加 review 子字段)。
|
||||
// 阶段3:gate=true 启用自审闸门(verdict=fail 阻断下游,工作流 failed)。
|
||||
// gate=true 启用自审闸门(verdict=fail 阻断下游,工作流 failed)。
|
||||
dag.add_node(
|
||||
"ai_self_review",
|
||||
"ai_self_review",
|
||||
@@ -135,13 +135,13 @@ mod tests {
|
||||
// 节点类型(决策 a 步骤③:ai_self_review 独立节点类型)
|
||||
let ai = dag.nodes.get("ai_self_review").expect("ai_self_review 存在");
|
||||
assert_eq!(ai.node_type, "ai_self_review");
|
||||
// 阶段3:gate=true 启用自审闸门(verdict=fail 阻断下游)
|
||||
// gate=true 启用自审闸门(verdict=fail 阻断下游)
|
||||
let gate = ai
|
||||
.config
|
||||
.get("gate")
|
||||
.and_then(|v| v.as_bool())
|
||||
.expect("ai_self_review config 应含 gate");
|
||||
assert!(gate, "testing 模板 ai_self_review 应启用 gate:true(阶段3 闸门)");
|
||||
assert!(gate, "testing 模板 ai_self_review 应启用 gate:true(闸门)");
|
||||
let human = dag.nodes.get("human_review").expect("human_review 存在");
|
||||
assert_eq!(human.node_type, "human");
|
||||
// 边方向:ai → human
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
/// 内容图引用(README 内的架构图/截图等,喂 vision 用)。
|
||||
/// 采样层只收集 alt+src,Phase 2 上线后由 commands 层读 base64 喂 vision。
|
||||
/// 当前 ChatMessage.content:String(F-260614-05 未做)走纯文本降级,
|
||||
/// 当前 ChatMessage.content:String(多模态未做)走纯文本降级,
|
||||
/// 此结构仅为采样层留接口,不读 base64。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImageRef {
|
||||
|
||||
@@ -53,7 +53,7 @@ fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result<AiProviderRecord,
|
||||
config: row.get("config")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
// F-260614-04: enabled/weight 列老库经 v19 迁移补建,DEFAULT 1 / DEFAULT 50。
|
||||
// enabled/weight 列老库经 v19 迁移补建,DEFAULT 1 / DEFAULT 50。
|
||||
// from_row 按 i32 取列值兼容(SQLite 无真 BOOLEAN),0→false/非0→true。
|
||||
enabled: row.get::<_, i32>("enabled").unwrap_or(1) != 0,
|
||||
// weight 读侧 clamp [0,100]:与 insert/update_full 落库的 `.min(100)` 对齐,
|
||||
@@ -95,7 +95,7 @@ fn ai_tool_execution_from_row(row: &Row<'_>) -> std::result::Result<AiToolExecut
|
||||
Ok(AiToolExecutionRecord {
|
||||
id: row.get("id")?,
|
||||
conversation_id: row.get("conversation_id")?,
|
||||
// F-260619-04:message_id 列老库经 v21 迁移补建。unwrap_or(None) 兜底:
|
||||
// message_id 列老库经 v21 迁移补建。unwrap_or(None) 兜底:
|
||||
// 新库空表直接有列;老库行 ALTER 后 NULL;极端情况(迁移未跑/手工删列)防御。
|
||||
message_id: row.get("message_id").unwrap_or(None),
|
||||
tool_call_id: row.get("tool_call_id")?,
|
||||
@@ -124,7 +124,7 @@ impl_repo!(
|
||||
let is_default = if rec.is_default { 1i32 } else { 0i32 };
|
||||
// model_configs:Vec<ModelConfig> → JSON 字符串落 TEXT 列
|
||||
let model_configs_json = serde_json::to_string(&rec.model_configs).unwrap_or_else(|_| "[]".into());
|
||||
// F-260614-04: enabled/weight 落库(SQLite 无 BOOLEAN,i32 承载)。
|
||||
// enabled/weight 落库(SQLite 无 BOOLEAN,i32 承载)。
|
||||
let enabled_i = if rec.enabled { 1i32 } else { 0i32 };
|
||||
let weight_i = rec.weight.min(100) as i32;
|
||||
conn.execute(
|
||||
@@ -323,6 +323,40 @@ impl AiToolExecutionRepo {
|
||||
// AiConversationRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。
|
||||
|
||||
impl AiConversationRepo {
|
||||
/// 写入对话版本化快照 checkpoint(INSERT OR IGNORE,同 id 已存在则跳过)。
|
||||
///
|
||||
/// 参数化绑定(替代原调用方的 format! 拼 SQL + execute_batch),防 snapshot 含引号/
|
||||
/// 特殊字符致注入或损坏。列对齐 conversation_checkpoints(id, conv_id, snapshot,
|
||||
/// token_total, created_at)。
|
||||
pub async fn insert_checkpoint(
|
||||
&self,
|
||||
id: &str,
|
||||
conv_id: &str,
|
||||
snapshot: &str,
|
||||
token_total: i64,
|
||||
created_at: &str,
|
||||
) -> Result<()> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_string();
|
||||
let conv_id = conv_id.to_string();
|
||||
let snapshot = snapshot.to_string();
|
||||
let created_at = created_at.to_string();
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
guard
|
||||
.execute(
|
||||
"INSERT OR IGNORE INTO conversation_checkpoints \
|
||||
(id, conv_id, snapshot, token_total, created_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![id, conv_id, snapshot, token_total, created_at],
|
||||
)
|
||||
.map_err(storage_err)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清空对话消息内容(保留 conversation 记录本身,只清 messages JSON + 清零 token 计数)
|
||||
///
|
||||
/// "清空对话"语义:对话壳保留(侧栏仍可见,可继续在该对话内聊),仅清空历史消息。
|
||||
@@ -420,7 +454,7 @@ mod tests {
|
||||
use crate::models::AiProviderRecord;
|
||||
use df_ai_core::model::{Capability, IntelligenceTier, Modality, ModelConfig};
|
||||
|
||||
/// model_configs DB roundtrip + 老库空兼容(F-01 阶段1)
|
||||
/// model_configs DB roundtrip + 老库空兼容
|
||||
#[tokio::test]
|
||||
async fn ai_provider_model_configs_roundtrip_and_old_db_compat() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
|
||||
@@ -20,7 +20,7 @@ use super::{now_millis_str, storage_err, validate_column_name};
|
||||
|
||||
/// `knowledges` 表对应 `KnowledgeRecord` 15 个字段的列名(顺序与结构体一致)。
|
||||
///
|
||||
/// 多处 `search`/`search_vector` 内联 COLS 串的 DRY 收口(CR-260615-03):集中一处定义,
|
||||
/// 多处 `search`/`search_vector` 内联 COLS 串的 DRY 收口:集中一处定义,
|
||||
/// 配合下方 `KNOWLEDGE_COL_COUNT` 断言,任一处加列漏改会被测试 `test_knowledge_cols_matches_record`
|
||||
/// 立即捕获(`knowledge_from_row` 按 name 取列,SELECT 漏列会运行时 rusqlite 报错,故提前断言)。
|
||||
///
|
||||
@@ -38,7 +38,7 @@ const KNOWLEDGE_COLS_WITH_EMBEDDING: &str = concat!(
|
||||
|
||||
/// `ideas` 表对应 `IdeaRecord` 14 个字段的列名(顺序与结构体一致)。
|
||||
///
|
||||
/// 同 KNOWLEDGE_COLS 的列漂移防护(CR-260615-03):idea 表 INSERT/UPDATE/from_row 三处
|
||||
/// 同 KNOWLEDGE_COLS 的列漂移防护:idea 表 INSERT/UPDATE/from_row 三处
|
||||
/// 各写一份列名串,加列须三处同步(如 V24 加 related_ids 即三处齐改),漏一处
|
||||
/// 只在运行时 rusqlite 报错(INSERT 列数与参数数不匹配 / from_row 取不到列)。集中一处
|
||||
/// 定义 + 配合 `IDEA_COL_COUNT` 断言 + 测试 `test_idea_cols_matches_record`,加列漏改即捕获。
|
||||
@@ -147,7 +147,7 @@ fn knowledge_event_from_row(row: &Row<'_>) -> std::result::Result<KnowledgeEvent
|
||||
// ============================================================
|
||||
|
||||
// ============================================================
|
||||
// IdeaQuery — 多条件查询入参(F-260621-02 status 下沉 + 关键词 + 排序 + 分页)
|
||||
// IdeaQuery — 多条件查询入参(status 下沉 + 关键词 + 排序 + 分页)
|
||||
// ============================================================
|
||||
|
||||
/// 灵感多条件查询入参。
|
||||
@@ -256,7 +256,7 @@ impl_repo!(
|
||||
// KnowledgeRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。
|
||||
|
||||
impl IdeaRepo {
|
||||
/// 多条件查询:动态 WHERE 拼接(status / keyword) + 白名单排序 + 分页(F-260621-02)。
|
||||
/// 多条件查询:动态 WHERE 拼接(status / keyword) + 白名单排序 + 分页。
|
||||
///
|
||||
/// 复用 `KnowledgeRepo::search` 的动态 WHERE 模式:if-let 分支按可选条件拼 SQL 片段,
|
||||
/// 各分支化参数绑定到 `?N` 占位符。`order_by` 经 `validate_idea_order_by` 白名单校验后
|
||||
@@ -925,7 +925,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
// ---------- COLS 漂移防护(CR-260615-03) ----------
|
||||
// ---------- COLS 漂移防护 ----------
|
||||
|
||||
/// KNOWLEDGE_COLS 列数须等于 KNOWLEDGE_COL_COUNT(任一处漂移:加列漏改 / 串错位 → 立即失败)。
|
||||
/// `knowledge_from_row` 按 name 取列,SELECT 漏列会在运行时被 rusqlite 报错;此断言提前到测试期捕获。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! AI 消息 Repo — ai_messages 表(F-260619-03 消息拆分存储)
|
||||
//! AI 消息 Repo — ai_messages 表(消息拆分存储)
|
||||
//!
|
||||
//! 每条 ChatMessage 一行的独立表,替代 `ai_conversations.messages` 整对话 JSON 列存。
|
||||
//! 全专用方法(insert_batch / list_by_conversation / delete_range / update_status /
|
||||
@@ -239,7 +239,6 @@ impl AiMessageRepo {
|
||||
|
||||
/// 全量重写对话的消息(单事务 DELETE + INSERT OR IGNORE,原子)。
|
||||
///
|
||||
/// F-260619-03 批次 B(save_conversation 写路径切 ai_messages)的核心方法:
|
||||
/// 全量重写语义——以入参 records 为该对话的**唯一真相**,先删该 conv 全部旧行再批量插。
|
||||
/// 单事务保证「删 + 插」原子,无中间空窗(reload 不会读到半删半插的中间态)。
|
||||
///
|
||||
@@ -472,7 +471,7 @@ mod tests {
|
||||
assert_eq!(got[0].content, "替换后的结果", "其他消息不应被改");
|
||||
}
|
||||
|
||||
// ---------- replace_conversation(F-260619-03 批次 B)----------
|
||||
// ---------- replace_conversation ----------
|
||||
|
||||
/// replace_conversation 全量重写:删旧 + 插新原子,list 一致
|
||||
#[tokio::test]
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo
|
||||
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
|
||||
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
|
||||
//! - [`mod@message_repo`]:AiMessageRepo(F-260619-03 消息拆分存储,全专用方法不走宏)
|
||||
//! - [`mod@message_repo`]:AiMessageRepo(消息拆分存储,全专用方法不走宏)
|
||||
//!
|
||||
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
|
||||
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
|
||||
|
||||
@@ -17,7 +17,7 @@ use super::impl_repo;
|
||||
use super::{normalize_stored_path, now_millis_str, storage_err, validate_column_name};
|
||||
|
||||
// ============================================================
|
||||
// 项目查询入参(F-260621-02 P2/P3 查询维度补全)
|
||||
// 项目查询入参(P2/P3 查询维度补全)
|
||||
// ============================================================
|
||||
|
||||
/// 项目列表查询条件(可选字段,全 None = 当前 list_active 全量行为)。
|
||||
@@ -204,7 +204,7 @@ impl ProjectRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按条件查询未删除项目(F-260621-02 P2/P3:关键词搜索 + 排序 + 分页)。
|
||||
/// 按条件查询未删除项目(P2/P3:关键词搜索 + 排序 + 分页)。
|
||||
///
|
||||
/// 复用 `KnowledgeRepo::search` 动态 WHERE 拼接模式:按可选字段 if-let 拼 SQL 子句 +
|
||||
/// 分支化参数绑定。始终排除 `deleted_at IS NOT NULL`(软删),与 `list_active` 行为对齐。
|
||||
|
||||
@@ -119,8 +119,8 @@ impl SettingsRepo {
|
||||
pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
|
||||
Some(match table {
|
||||
"ideas" => &[
|
||||
// IDEA-FIX-02: id\created_at 不列入 — 主键与创建时间不可通过通用 update_field 改写
|
||||
// (对标 tasks 白名单 B-260616-16 同款防护,防篡改主键/伪造创建时间)
|
||||
// id\created_at 不列入 — 主键与创建时间不可通过通用 update_field 改写
|
||||
// (对标 tasks 白名单同款防护,防篡改主键/伪造创建时间)
|
||||
"title", "description", "status", "priority", "score", "tags", "source",
|
||||
"promoted_to", "ai_analysis", "scores", "related_ids", "updated_at",
|
||||
],
|
||||
@@ -141,15 +141,15 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
|
||||
// 否则破坏「review_rounds 唯一写入路径」收口、引入旁路写导致计数错乱。
|
||||
"project_id", "title", "description", "priority", "branch_name",
|
||||
"assignee",
|
||||
// workflow_def_id / base_branch: 预留字段,阶段4 Git/workflow_defs 集成前无写入路径。
|
||||
// workflow_def_id / base_branch: 预留字段,Git/workflow_defs 集成前无写入路径。
|
||||
// 当前推进链用硬编码三模板(task_workflow_templates.rs,不建 workflow_defs 表,
|
||||
// tasks.workflow_def_id 留 None),无任何代码写这两列。白名单列入仅为阶段4 预留 +
|
||||
// tasks.workflow_def_id 留 None),无任何代码写这两列。白名单列入仅为预留 +
|
||||
// 允许手动/未来填充,勿判死代码删除。base_branch 同理(code kind 闸门接 git 前预留)。
|
||||
"workflow_def_id", "base_branch",
|
||||
// output_json:ai_execute 写产出 / ai_self_review 读产出自审 / human_review 展示对象
|
||||
// (决策 a:task 中心,产出跟 task 走)。非状态机收口字段,合法可写。
|
||||
"output_json",
|
||||
// idea_id(F-260619-01 任务关联灵感,1对1 单向):任务可关联/解关联一条灵感,
|
||||
// idea_id(任务关联灵感,1对1 单向):任务可关联/解关联一条灵感,
|
||||
// 非状态机收口字段,合法可写(idea_id 存在性由外键约束 + 上层校验兜底)。
|
||||
"idea_id",
|
||||
// 知识图谱 Phase 1 V29 三列(对标设计 §2.1):非状态机收口字段,合法可写。
|
||||
@@ -160,7 +160,7 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
|
||||
// 走专用方法 set_status_for_aggregation,不经通用 update_field。
|
||||
"queue", "parent_id", "content_json",
|
||||
"updated_at",
|
||||
// TODO(B-260616-16): project_id 跨表存在性校验待 commands/task.rs 层补。
|
||||
// TODO: project_id 跨表存在性校验待 commands/task.rs 层补。
|
||||
// 通用 CRUD 层(db repo)只懂表/列语义,不持有跨表业务约束(查 projects 表存在性)。
|
||||
// project_id 当前可在白名单内改写,合法目标存在性由上层命令层校验。
|
||||
],
|
||||
@@ -218,7 +218,7 @@ pub(crate) fn validate_column_name(field: &str, table: &str) -> Result<()> {
|
||||
match allowed_columns_for(table) {
|
||||
Some(cols) if cols.contains(&field) => Ok(()),
|
||||
Some(_) => Err(Error::Storage(format!("表 {} 不允许的字段名: {}", table, field))),
|
||||
// 未登记表保守拒绝(FR-S6: 原放行 Ok,若未来未登记表走通用查询路径,列名直进字符串拼接即 SQL 注入;与 is_allowed_column 的 None=>false 对齐)
|
||||
// 未登记表保守拒绝:原放行 Ok,若未来未登记表走通用查询路径,列名直进字符串拼接即 SQL 注入;与 is_allowed_column 的 None=>false 对齐
|
||||
None => Err(Error::Storage(format!("表 {} 未登记列白名单,拒绝防注入", table))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 任务列表查询入参(F-260621-02 查询维度补全)
|
||||
// 任务列表查询入参(查询维度补全)
|
||||
// ============================================================
|
||||
|
||||
/// 任务列表动态查询入参。全可选,空 query = 等价当前全量行为(向后兼容)。
|
||||
@@ -213,7 +213,7 @@ impl TaskRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 原子推进任务状态(任务推进链 F-260616-02 唯一 status 写入路径)
|
||||
/// 原子推进任务状态(任务推进链唯一 status 写入路径)
|
||||
///
|
||||
/// 下沉 SQL `WHERE id=? AND status=?expected` 做 CAS(Compare-And-Swap)防 TOCTOU:
|
||||
/// 并发推进/旁路修改若已改 status,affected_rows==0,本方法返回 None,调用方
|
||||
@@ -296,7 +296,7 @@ impl TaskRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 动态条件列出未删除任务(F-260621-02 查询维度补全)。
|
||||
/// 动态条件列出未删除任务(查询维度补全)。
|
||||
///
|
||||
/// 复用 KnowledgeRepo::search 的「动态 WHERE + 参数绑定」模式,但用累积式条件收集
|
||||
/// (Vec<String> WHERE 子句 + Vec<rusqlite::Value> 参数)替代 if-let 二分支——
|
||||
@@ -528,7 +528,7 @@ impl TaskRepo {
|
||||
///
|
||||
/// 本方法返回 `Vec<(status, count)>`(SQL GROUP BY 一次查询,数据量小 ~50 无压力),
|
||||
/// 聚合规则的具体判定由调用方(commands 层)实现 —— 本层只提供原始计数,不持有
|
||||
/// 业务聚合逻辑(CRUD 层只懂表/列语义,对标 B-260616-16 跨表校验下沉思路)。
|
||||
/// 业务聚合逻辑(CRUD 层只懂表/列语义,对标跨表校验下沉思路)。
|
||||
pub async fn count_children_by_status(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
|
||||
@@ -23,12 +23,12 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
|
||||
// 迁移步骤链: 顺序执行,跳过已应用的版本(current_version < N 才跑)。
|
||||
// 新增版本时,在此数组追加一项 (N, migrate_vN) 即可,无需改逻辑。
|
||||
// V20 = F-260619-01(任务关联灵感 idea_id);V21 = 消息拆分存储 + audit message_id;
|
||||
// V20 = 任务关联灵感 idea_id;V21 = 消息拆分存储 + audit message_id;
|
||||
// V22 = 灵感评估历史持久化(idea_evaluations 追加型审计表);
|
||||
// V23 = knowledges.embedding_status 列(嵌入失败可补偿重试);
|
||||
// V24 = ideas.related_ids 列(灵感间关联关系持久化打底);
|
||||
// V25 = idea_evaluations (idea_id, version) 唯一约束(评估版本并发重复兜底);
|
||||
// V26 = F-260621-02 任务索引缺口补全(priority/assignee,对齐 idx_tasks_status 同类索引)。
|
||||
// V26 = 任务索引缺口补全(priority/assignee,对齐 idx_tasks_status 同类索引)。
|
||||
// V27 = TD-260621-05 审批状态统一(ai_tool_executions.status executed→completed,
|
||||
// 对齐 DTO 契约 audit/mod.rs:53 只列 completed + 前端 i18n auditLog.status 无 executed 键 +
|
||||
// 治 AuditLog executed 记录显示错位蓝pending标签+raw"executed")。
|
||||
@@ -45,7 +45,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
|
||||
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
|
||||
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 36] = [
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 37] = [
|
||||
(1, migrate_v1),
|
||||
(2, migrate_v2),
|
||||
(3, migrate_v3),
|
||||
@@ -82,6 +82,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(34, migrate_v34),
|
||||
(35, migrate_v35),
|
||||
(36, migrate_v36),
|
||||
(37, migrate_v37),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -289,7 +290,7 @@ fn migrate_v14(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V15: 幂等补 tasks.review_rounds 列(review 退回累计轮数,F-260616-04)
|
||||
/// V15: 幂等补 tasks.review_rounds 列(review 退回累计轮数)
|
||||
///
|
||||
/// 任务推进链状态机退回时累加:in_review→in_progress / testing→in_review 各 +1,
|
||||
/// 由 advance_task(df-nodes::task_advance_node)原子写入。默认 0(从未退回过的任务)。
|
||||
@@ -342,7 +343,7 @@ fn migrate_v17(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V18: 幂等补 ai_providers.model_configs 列(模型能力配置,F-01 阶段1)
|
||||
/// V18: 幂等补 ai_providers.model_configs 列(模型能力配置)
|
||||
///
|
||||
/// 模型 4 维度(模态/能力/价格/智力)+ 路由控制配置 JSON 字符串。TEXT NULL 向后兼容:
|
||||
/// 老库行默认 NULL,from_row 经 deserialize_model_configs 解析为空 Vec(配合 default_model 过渡)。
|
||||
@@ -357,7 +358,7 @@ fn migrate_v18(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V19: 幂等补 ai_providers.enabled + ai_providers.weight 列(F-260614-04 多 Provider 负载均衡池)
|
||||
/// V19: 幂等补 ai_providers.enabled + ai_providers.weight 列(多 Provider 负载均衡池)
|
||||
///
|
||||
/// - `enabled INTEGER NOT NULL DEFAULT 1`:provider 是否进入负载均衡池。
|
||||
/// 老库行迁移后默认 1(所有现存 provider 默认启用,单 provider 路径零变化)。
|
||||
@@ -373,21 +374,21 @@ fn migrate_v19(conn: &Connection) -> Result<()> {
|
||||
"ALTER TABLE ai_providers ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v19: 补建 ai_providers.enabled 列(多 Provider 负载均衡池,F-260614-04)");
|
||||
tracing::info!("v19: 补建 ai_providers.enabled 列(多 Provider 负载均衡池)");
|
||||
}
|
||||
if !column_exists(conn, "ai_providers", "weight") {
|
||||
conn.execute(
|
||||
"ALTER TABLE ai_providers ADD COLUMN weight INTEGER NOT NULL DEFAULT 50",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v19: 补建 ai_providers.weight 列(多 Provider 负载均衡池,F-260614-04)");
|
||||
tracing::info!("v19: 补建 ai_providers.weight 列(多 Provider 负载均衡池)");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [19])?;
|
||||
tracing::info!("迁移 v19 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V20:幂等补 tasks.idea_id 列(F-260619-01 任务关联灵感,1对1 单向)
|
||||
/// V20:幂等补 tasks.idea_id 列(任务关联灵感,1对1 单向)
|
||||
///
|
||||
/// 任务可关联到一条灵感(任务→灵感单向),复用 projects.idea_id 模式
|
||||
/// (REFERENCES ideas(id) 外键)。TEXT NULL 向后兼容:老库行默认 NULL,TaskRecord
|
||||
@@ -400,7 +401,7 @@ fn migrate_v20(conn: &Connection) -> Result<()> {
|
||||
"ALTER TABLE tasks ADD COLUMN idea_id TEXT REFERENCES ideas(id)",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v20: 补建 tasks.idea_id 列(任务关联灵感,F-260619-01)");
|
||||
tracing::info!("v20: 补建 tasks.idea_id 列(任务关联灵感)");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [20])?;
|
||||
tracing::info!("迁移 v20 完成");
|
||||
@@ -432,7 +433,7 @@ fn migrate_v21(conn: &Connection) -> Result<()> {
|
||||
// 1. 建 ai_messages 表(IF NOT EXISTS 幂等)
|
||||
conn.execute_batch(V21_SQL)?;
|
||||
|
||||
// 2. 幂等补 ai_tool_executions.message_id 列(消息级溯源 audit,F-260619-04)
|
||||
// 2. 幂等补 ai_tool_executions.message_id 列(消息级溯源 audit)
|
||||
// 表存在性兜底:run() 正常流程下 V9 已先建该表,但测试/手动调用可能跳过 V9。
|
||||
// 表不存在时跳过 ALTER(新库会由 V9_SQL 建表带 message_id 列;此处只补老库已有表)。
|
||||
let tool_exec_table_exists: bool = conn
|
||||
@@ -563,7 +564,7 @@ fn migrate_v21(conn: &Connection) -> Result<()> {
|
||||
///
|
||||
/// 不登记通用列白名单(allowed_columns_for):本表走专用 list_by_idea,
|
||||
/// 宏生成的 query/update_field 未登记表会被 validate_column_name 保守拒绝
|
||||
/// (FR-S6),与追加型审计语义一致(历史不改),不开放通用写路径。
|
||||
/// 与追加型审计语义一致(历史不改),不开放通用写路径。
|
||||
fn migrate_v22(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(V22_SQL)?;
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [22])?;
|
||||
@@ -660,7 +661,7 @@ fn migrate_v25(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V26:补建 tasks 表 priority/assignee 索引(F-260621-02 索引缺口)
|
||||
/// V26:补建 tasks 表 priority/assignee 索引(索引缺口)
|
||||
///
|
||||
/// list_by_query 已支持 priority/assignee 过滤下推(TaskQuery.priority/assignee),
|
||||
/// 但缺索引 → 数据量增长后全表扫描。补建索引对齐已有的 idx_tasks_status /
|
||||
@@ -1115,6 +1116,25 @@ fn migrate_v36(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V37: conversation_checkpoints 表(对话版本化快照)
|
||||
fn migrate_v37(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS conversation_checkpoints (\
|
||||
id TEXT PRIMARY KEY,\
|
||||
conv_id TEXT NOT NULL,\
|
||||
snapshot TEXT NOT NULL,\
|
||||
token_total INTEGER NOT NULL,\
|
||||
label TEXT,\
|
||||
created_at TEXT NOT NULL\
|
||||
);\
|
||||
CREATE INDEX IF NOT EXISTS idx_ck_conv_id \
|
||||
ON conversation_checkpoints(conv_id, created_at DESC);",
|
||||
)?;
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [37])?;
|
||||
tracing::info!("迁移 v37 完成: 建 conversation_checkpoints 表");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||
///
|
||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||
@@ -1199,7 +1219,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
priority INTEGER NOT NULL DEFAULT 2,
|
||||
branch_name TEXT,
|
||||
assignee TEXT,
|
||||
-- F-260619-01 任务关联灵感(1对1 单向,复用 projects.idea_id 模式)。老库由 V20 迁移补列。
|
||||
-- 任务关联灵感(1对1 单向,复用 projects.idea_id 模式)。老库由 V20 迁移补列。
|
||||
idea_id TEXT REFERENCES ideas(id),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
@@ -1245,7 +1265,7 @@ CREATE TABLE IF NOT EXISTS node_executions (
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_project_id ON tasks(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||
-- V26 补建(F-260621-02 索引缺口):priority/assignee 过滤下推索引,新库一次性建;
|
||||
-- V26 补建(索引缺口):priority/assignee 过滤下推索引,新库一次性建;
|
||||
-- 老库由 migrate_v26 CREATE INDEX IF NOT EXISTS 补建,两边索引定义须一致。
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee);
|
||||
@@ -1375,7 +1395,7 @@ CREATE TABLE IF NOT EXISTS ai_tool_executions (
|
||||
decided_by TEXT
|
||||
);
|
||||
|
||||
-- F-260619-03 消息拆分存储:每条 ChatMessage 一行的独立表。
|
||||
-- 消息拆分存储:每条 ChatMessage 一行的独立表。
|
||||
-- 与 V21 迁移建表 SQL 镜像(V21 用于老库 ALTER,此处给新库直接建最终态)。
|
||||
-- 改动须两边同步(V21_SQL 见下方)。
|
||||
CREATE TABLE IF NOT EXISTS ai_messages (
|
||||
@@ -1431,7 +1451,7 @@ CREATE TABLE IF NOT EXISTS app_settings (
|
||||
";
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — V21 迁移幂等安全(新库/老库/坏数据三态,F-260619-03)
|
||||
// 单元测试 — V21 迁移幂等安全(新库/老库/坏数据三态)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1653,7 +1673,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// V20 迁移幂等安全(F-260619-01 任务关联灵感)
|
||||
// V20 迁移幂等安全(任务关联灵感)
|
||||
// ============================================================
|
||||
|
||||
/// 构造最小老库 schema:tasks 表(无 idea_id 列,模拟 V1 建表老形态)+ schema_version。
|
||||
@@ -1800,4 +1820,95 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(v, 27);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 全量迁移测试 — 新库从零跑完整 V1-V37 路径
|
||||
// ------------------------------------------------------------
|
||||
// 目的:某 migrate_vN 的 SQL 手滑写错(列名/类型/缺索引/缺表)只能等运行时暴露,
|
||||
// 此测试一次性覆盖全部迁移路径。任何一条迁移 SQL 写错、列名拼错、缺建表
|
||||
// 都会被这里捕获,不必等到应用启动真机才报错。
|
||||
// ============================================================
|
||||
|
||||
/// 辅助:断言表存在且 PRAGMA table_info 返回的列数 > 0(即表非空、有列定义)。
|
||||
fn assert_table_has_columns(conn: &Connection, table: &str) {
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
&format!("SELECT COUNT(*) FROM pragma_table_info('{}')", table),
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("查 {} 列信息失败: {}", table, e));
|
||||
assert!(
|
||||
count > 0,
|
||||
"表 {} 应存在且至少 1 列(实际 {} 列)—— 可能 migrate_vN 建表 SQL 写错或被遗漏",
|
||||
table,
|
||||
count
|
||||
);
|
||||
}
|
||||
|
||||
/// 全量迁移:新库从零跑完 V1-V37,验证关键表齐全 + 列数 > 0 + 关键列存在。
|
||||
///
|
||||
/// 覆盖至少:task / ai_conversations / ai_messages / ai_tool_executions /
|
||||
/// conversation_checkpoints / ai_providers / projects / ideas。
|
||||
/// 抽查关键列:ai_providers.enabled/weight、conversation_checkpoints.snapshot、
|
||||
/// tasks.idea_id(这些列由不同 vN 加,任一漏加此处失败)。
|
||||
#[tokio::test]
|
||||
async fn test_full_migration_on_fresh_db() {
|
||||
// 用 Database::open_in_memory 打开新库,内部自动跑 migrations::run() 全量迁移
|
||||
let db = crate::db::Database::open_in_memory()
|
||||
.await
|
||||
.expect("新库应能跑完全量迁移");
|
||||
|
||||
let conn = db.conn();
|
||||
let conn = conn.lock().await;
|
||||
|
||||
// 1. 关键表存在且列数 > 0
|
||||
for table in [
|
||||
"tasks",
|
||||
"ai_conversations",
|
||||
"ai_messages",
|
||||
"ai_tool_executions",
|
||||
"conversation_checkpoints",
|
||||
"ai_providers",
|
||||
"projects",
|
||||
"ideas",
|
||||
] {
|
||||
assert_table_has_columns(&conn, table);
|
||||
}
|
||||
|
||||
// 2. 抽查关键列存在(跨多个 vN 加的列,任一漏加此处失败)
|
||||
// ai_providers.enabled / weight(V19 ALTER)
|
||||
assert!(
|
||||
column_exists(&conn, "ai_providers", "enabled"),
|
||||
"ai_providers.enabled 列缺失(V19 加)"
|
||||
);
|
||||
assert!(
|
||||
column_exists(&conn, "ai_providers", "weight"),
|
||||
"ai_providers.weight 列缺失(V19 加)"
|
||||
);
|
||||
// conversation_checkpoints.snapshot(V37 建表)
|
||||
assert!(
|
||||
column_exists(&conn, "conversation_checkpoints", "snapshot"),
|
||||
"conversation_checkpoints.snapshot 列缺失(V37 建表)"
|
||||
);
|
||||
// tasks.idea_id(V1 建表已带,V20 老库兜底——新库应有)
|
||||
assert!(
|
||||
column_exists(&conn, "tasks", "idea_id"),
|
||||
"tasks.idea_id 列缺失(V1 建表已带)"
|
||||
);
|
||||
|
||||
// 3. schema_version 应推进到 37(全量迁移成功落版本号)
|
||||
let max_version: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(version), 0) FROM schema_version",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.expect("查 schema_version 应成功");
|
||||
assert_eq!(
|
||||
max_version, 37,
|
||||
"全量迁移后 schema_version 应为 37(实际 {}),说明某条 migrate_vN 链路断在中间",
|
||||
max_version
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ pub struct TaskRecord {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub output_json: Option<String>,
|
||||
/// 关联灵感 ID(F-260619-01,1对1 单向,任务→灵感)。
|
||||
/// 关联灵感 ID(1对1 单向,任务→灵感)。
|
||||
/// 复用 projects.idea_id 模式(REFERENCES ideas(id) 外键),可空(任务可不关联灵感)。
|
||||
/// 老任务无 idea_id → None。#[serde(default)] 兼容旧前端 JSON(无该字段时为 None)。
|
||||
#[serde(default)]
|
||||
@@ -291,7 +291,7 @@ pub struct AiProviderRecord {
|
||||
pub base_url: String,
|
||||
pub default_model: String,
|
||||
pub models: Option<String>, // JSON array of model names
|
||||
/// 模型能力配置数组(F-01 阶段1,4 维度 + 路由控制)。
|
||||
/// 模型能力配置数组(4 维度 + 路由控制)。
|
||||
/// DB TEXT 列存 JSON 字符串,from_row 经 deserialize_model_configs 解析。
|
||||
/// 向后兼容:老库 NULL/空/老字符串数组 → 空 Vec 或转默认 ModelConfig。default_model 保留过渡。
|
||||
#[serde(default, deserialize_with = "deserialize_model_configs")]
|
||||
@@ -300,11 +300,11 @@ pub struct AiProviderRecord {
|
||||
pub config: Option<String>, // JSON extra config
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
/// provider 是否进入负载均衡池(F-260614-04)。false = 仅作为配置存在,不参与主链路由/选池。
|
||||
/// provider 是否进入负载均衡池。false = 仅作为配置存在,不参与主链路由/选池。
|
||||
/// 老库迁移默认 1(单 provider 路径零变化)。
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
/// provider 在负载均衡池中的选择权重(0-100,F-260614-04)。高权重优先被选为主;
|
||||
/// provider 在负载均衡池中的选择权重(0-100)。高权重优先被选为主;
|
||||
/// 同权重时退化近似轮询。老库迁移默认 50。
|
||||
#[serde(default = "default_weight")]
|
||||
pub weight: u32,
|
||||
@@ -371,9 +371,9 @@ pub struct AiConversationRecord {
|
||||
pub struct AiToolExecutionRecord {
|
||||
pub id: String,
|
||||
pub conversation_id: Option<String>,
|
||||
/// 消息级溯源:工具调用所属的 ChatMessage.id(F-260619-04)。
|
||||
/// 消息级溯源:工具调用所属的 ChatMessage.id。
|
||||
/// NULL = 老库行 / 消息级溯源未启用期的记录 / 无法关联的调用。
|
||||
/// 升级后,audit 写入从 ContextManager 取当前 assistant 消息 id 填入(P1 接入,P0 只建列)。
|
||||
/// 升级后,audit 写入从 ContextManager 取当前 assistant 消息 id 填入。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub message_id: Option<String>,
|
||||
pub tool_call_id: String,
|
||||
@@ -387,7 +387,7 @@ pub struct AiToolExecutionRecord {
|
||||
pub decided_by: Option<String>, // human/auto
|
||||
}
|
||||
|
||||
/// 消息记录(ai_messages 表,F-260619-03 消息拆分存储)。
|
||||
/// 消息记录(ai_messages 表,消息拆分存储)。
|
||||
///
|
||||
/// 每条 ChatMessage 一行,替代 `ai_conversations.messages` 的整对话 JSON 列存。
|
||||
/// 主键 `id` = ChatMessage.id(构造时 ULID 风格生成),(conversation_id, seq) UNIQUE
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! FR-S1 api_key 密钥管理 — 真实密钥存 OS keyring,DB `api_key` 列迁移后存空串。
|
||||
//! api_key 密钥管理 — 真实密钥存 OS keyring,DB `api_key` 列迁移后存空串。
|
||||
//!
|
||||
//! **下沉层(方案 B,2026-06-16)**:原位于 `src-tauri/src/commands/ai/secret.rs`,
|
||||
//! 下沉纯密钥逻辑(get/set/delete/resolve/ensure/migrate + failcount sidecar)到 df-storage,
|
||||
@@ -67,7 +67,7 @@ fn write_failcounts(map: &HashMap<String, u32>) {
|
||||
text.push('\n');
|
||||
}
|
||||
if let Err(e) = fs::write(failcount_path(), text) {
|
||||
tracing::debug!("[FR-S1] 迁移失败计数文件写入失败(忽略): {}", e);
|
||||
tracing::debug!("[密钥迁移] 迁移失败计数文件写入失败(忽略): {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,22 +114,55 @@ pub async fn get_provider_secret_async(id: String) -> Option<String> {
|
||||
}
|
||||
|
||||
/// 消费点用:解析 provider 真实密钥 — DB 优先,fallback keyring(兼容未迁移老库)
|
||||
///
|
||||
/// **不静默压空**:keyring 无记录/读取故障(None 已合并 keyring Err)时,这里**不**用 `unwrap_or_default()`
|
||||
/// 静默吞成空串 —— 那会让调用方拿空 api_key 发请求吃 401,错误伪装成「密钥无效」且无线索。改为
|
||||
/// warn 留痕(区分「真正未配置密钥」与「keyring 后端故障」),仍返空串交由下游 `ensure_resolved_key`
|
||||
/// 早失败给出用户可读错误 —— 签名不变,调用方零改动。
|
||||
pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
|
||||
if !record.api_key.is_empty() {
|
||||
return record.api_key.clone();
|
||||
}
|
||||
get_provider_secret(&record.id).unwrap_or_default()
|
||||
match get_provider_secret(&record.id) {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"[密钥解析] provider {} (id={}) 系统钥匙串无密钥或读取故障 —— \
|
||||
下游 ensure_resolved_key 将报「密钥缺失」。排查:1) 设置中是否保存过密钥;\
|
||||
2) OS 钥匙串后端是否可用(Win Credential Manager / macOS Keychain / Linux Secret Service)",
|
||||
record.name, record.id
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`resolve_provider_secret`] 的 async 版本 — DB 有明文时同步返(不触 keyring),
|
||||
/// 否则 `spawn_blocking` 调 keyring 防 D-Bus / COM 阻塞 tokio runtime。
|
||||
///
|
||||
/// 注:DB 明文路径直接 clone 同步返,只有 fallback keyring 才走 spawn_blocking。
|
||||
///
|
||||
/// **不静默压空**:同同步版,keyring 无记录/读取故障时 warn 留痕(不吞成空串致 401 难定位),
|
||||
/// 仍返空串交由下游 `ensure_resolved_key` 早失败给用户可读错误 —— 签名不变,调用方零改动。
|
||||
pub async fn resolve_provider_secret_async(record: AiProviderRecord) -> String {
|
||||
if !record.api_key.is_empty() {
|
||||
return record.api_key;
|
||||
}
|
||||
get_provider_secret_async(record.id).await.unwrap_or_default()
|
||||
// 先取 id/name 再 await,避免 record 部分移动后无法在 warn 中引用。
|
||||
let id = record.id.clone();
|
||||
let name = record.name.clone();
|
||||
match get_provider_secret_async(id.clone()).await {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"[密钥解析] provider {} (id={}) 系统钥匙串无密钥或读取故障 —— \
|
||||
下游 ensure_resolved_key 将报「密钥缺失」。排查:1) 设置中是否保存过密钥;\
|
||||
2) OS 钥匙串后端是否可用(Win Credential Manager / macOS Keychain / Linux Secret Service)",
|
||||
name, id
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 写入密钥到 keyring(覆盖)
|
||||
@@ -172,14 +205,14 @@ pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result
|
||||
let n = record_migration_fail(&p.id);
|
||||
if n >= MIGRATION_FAIL_THRESHOLD {
|
||||
tracing::warn!(
|
||||
"[FR-S1] provider {} keyring 迁移已连续失败 {} 次,明文 api_key 长期滞留 SQLite 文件(无加密)。\
|
||||
"[密钥迁移] provider {} keyring 迁移已连续失败 {} 次,明文 api_key 长期滞留 SQLite 文件(无加密)。\
|
||||
建议:1) 确认 OS 钥匙串可用(Win Credential Manager / macOS Keychain);\
|
||||
2) keyring 后端异常时排查对应平台后端;3) 必要时手动在设置中重新保存密钥触发写入",
|
||||
p.id, n
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"[FR-S1] keyring 迁移失败 {} (累计 {}/{},保留明文下次重试): {}",
|
||||
"[密钥迁移] keyring 迁移失败 {} (累计 {}/{},保留明文下次重试): {}",
|
||||
p.id, n, MIGRATION_FAIL_THRESHOLD, e
|
||||
);
|
||||
}
|
||||
@@ -188,29 +221,52 @@ pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result
|
||||
let pid = p.id.clone();
|
||||
p.api_key.clear();
|
||||
if let Err(e) = repo.insert(p).await {
|
||||
tracing::warn!("[FR-S1] 迁移后清空 DB api_key 失败 {}: {}", pid, e);
|
||||
tracing::warn!("[密钥迁移] 迁移后清空 DB api_key 失败 {}: {}", pid, e);
|
||||
}
|
||||
// 迁移成功 → 清零该 provider 的失败计数(下次若再出现失败从 1 重新累计)
|
||||
clear_migration_failcount(&pid);
|
||||
migrated += 1;
|
||||
}
|
||||
if migrated > 0 {
|
||||
tracing::info!("[FR-S1] {} 条 provider 密钥迁移至 OS keyring", migrated);
|
||||
tracing::info!("[密钥迁移] {} 条 provider 密钥迁移至 OS keyring", migrated);
|
||||
}
|
||||
Ok(migrated)
|
||||
}
|
||||
|
||||
/// 校验已解析的密钥是否可用:空(含纯空白)→明确错误信息,非空→Ok。
|
||||
/// 校验已解析的密钥是否可用并归一化:空(含纯空白)→明确错误信息;非空→返回归一化后的 String。
|
||||
///
|
||||
/// 归一化 = trim → 剥首尾配对引号(`"`/`'`)→ 再 trim。覆盖用户粘贴脏 key 的常见场景:
|
||||
/// 复制带前后引号/换行/全角空格/尾部空白,校验能过但原样发 provider 致 401 误报"API Key 无效"。
|
||||
/// 调用方应用返回值(归一化后的 key)替代原 resolved 传给 provider。
|
||||
///
|
||||
/// 用于消费点(build_provider 前)早失败,避免空 key 发请求吃 401,错误伪装成"API Key 无效"。
|
||||
pub fn ensure_resolved_key(provider_name: &str, resolved: &str) -> Result<(), String> {
|
||||
pub fn ensure_resolved_key(provider_name: &str, resolved: &str) -> Result<String, String> {
|
||||
// 先 trim 判空(纯空白视为无密钥,防粘贴时只有空格)。
|
||||
if resolved.trim().is_empty() {
|
||||
Err(format!(
|
||||
return Err(format!(
|
||||
"未读取到「{}」的 API 密钥(系统钥匙串无记录或已损坏),请在设置中重新填写并保存",
|
||||
provider_name
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
));
|
||||
}
|
||||
// 归一化:trim → 剥首尾引号(成对,支持 ""xxx"" / ''xxx'' 多层)→ 再 trim。
|
||||
let mut cleaned = resolved.trim().to_string();
|
||||
while cleaned.len() >= 2 {
|
||||
let first = cleaned.chars().next().unwrap();
|
||||
let last = cleaned.chars().last().unwrap();
|
||||
if (first == '"' || first == '\'') && first == last {
|
||||
cleaned = cleaned[first.len_utf8()..cleaned.len() - last.len_utf8()].trim().to_string();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 剥引号后可能变空(如粘贴仅一对引号)→ 视为无密钥。
|
||||
if cleaned.is_empty() {
|
||||
return Err(format!(
|
||||
"未读取到「{}」的 API 密钥(系统钥匙串无记录或已损坏),请在设置中重新填写并保存",
|
||||
provider_name
|
||||
));
|
||||
}
|
||||
Ok(cleaned)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -230,7 +286,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ensure_resolved_key_accepts_nonempty() {
|
||||
assert!(ensure_resolved_key("GLM", "sk-abc").is_ok());
|
||||
assert_eq!(ensure_resolved_key("GLM", "sk-abc").unwrap(), "sk-abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -239,9 +295,31 @@ mod tests {
|
||||
assert!(err.contains("我的提供商"), "错误信息应含 provider 名便于定位");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_resolved_key_trims_surrounding_whitespace() {
|
||||
// 粘贴带前后空白/换行 → 归一化为干净 key
|
||||
assert_eq!(ensure_resolved_key("GLM", " sk-abc \n").unwrap(), "sk-abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_resolved_key_strips_surrounding_quotes() {
|
||||
// 粘贴带前后引号(双引号/单引号,多层嵌套)→ 剥引号
|
||||
assert_eq!(ensure_resolved_key("GLM", "\"sk-abc\"").unwrap(), "sk-abc");
|
||||
assert_eq!(ensure_resolved_key("GLM", "'sk-abc'").unwrap(), "sk-abc");
|
||||
assert_eq!(ensure_resolved_key("GLM", "\"\"sk-abc\"\"").unwrap(), "sk-abc");
|
||||
assert_eq!(ensure_resolved_key("GLM", " \"sk-abc\"\n").unwrap(), "sk-abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_resolved_key_rejects_only_quotes() {
|
||||
// 仅一对引号(剥后为空)→ 视为无密钥
|
||||
assert!(ensure_resolved_key("GLM", "\"\"").is_err());
|
||||
assert!(ensure_resolved_key("GLM", " '' ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_prefers_db_when_non_empty() {
|
||||
// DB api_key 非空 → 直接返回 DB 值,不触发 keyring(FR-S1 兼容未迁移老库)
|
||||
// DB api_key 非空 → 直接返回 DB 值,不触发 keyring(兼容未迁移老库)
|
||||
// 纯逻辑路径,不碰 OS keyring,CI 任意 OS 安全。
|
||||
let rec = AiProviderRecord {
|
||||
id: "t1".into(), name: "t".into(), provider_type: "openai_compat".into(),
|
||||
|
||||
@@ -84,7 +84,7 @@ pub trait TunnelClient: Send + Sync {
|
||||
|
||||
/// 发送原始事件(桌面端 → 云后端 → 小程序),payload 为 AiChatEvent 序列化 JSON Value
|
||||
///
|
||||
/// Phase3 阶段2(D2 全 19 变体透传):EventBus subscriber 把 AiChatEvent Value 经此方法
|
||||
/// D2 全 19 变体透传:EventBus subscriber 把 AiChatEvent Value 经此方法
|
||||
/// 透传(不经 TunnelEvent 强类型子集)。与 [`send_event`](TunnelClient::send_event) 伴行,
|
||||
/// 后者保留作高频子集快捷方式(D5 强类型保留)。非阻塞入队,连接断开返 NotConnected。
|
||||
async fn send_raw_event(&self, payload: serde_json::Value) -> Result<()>;
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::{ExecutionId, NodeId};
|
||||
|
||||
/// 人工审批选择类型(F-260615-01)
|
||||
/// 人工审批选择类型
|
||||
/// - Single: 单选(decision 单值),缺省值,向后兼容现有调用方
|
||||
/// - Multiple: 多选(decisions 数组)
|
||||
///
|
||||
@@ -100,7 +100,7 @@ pub enum WorkflowEvent {
|
||||
title: String,
|
||||
description: String,
|
||||
options: Vec<String>,
|
||||
/// F-260615-01: 选择类型,缺省 Single(向后兼容)
|
||||
/// 选择类型,缺省 Single(向后兼容)
|
||||
#[serde(default)]
|
||||
select_type: SelectType,
|
||||
},
|
||||
@@ -109,7 +109,7 @@ pub enum WorkflowEvent {
|
||||
execution_id: ExecutionId,
|
||||
node_id: NodeId,
|
||||
decision: String,
|
||||
/// F-260615-01: 多选结果(Single 模式长度=1,Multiple 模式长度≥1)
|
||||
/// 多选结果(Single 模式长度=1,Multiple 模式长度≥1)
|
||||
#[serde(default)]
|
||||
decisions: Vec<String>,
|
||||
comment: Option<String>,
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::collections::HashMap;
|
||||
/// DAG 定义 — 可序列化/反序列化,用于持久化和模板
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct DagDef {
|
||||
// BUG-260621-01: nodes/edges 加 #[serde(default)]。
|
||||
// nodes/edges 加 #[serde(default)]。
|
||||
// 前端 run_workflow 推进链传空 dag `{}`(设计意图:空 dag + target_status →
|
||||
// workflow.rs:91 dag.nodes.is_empty() 进 template_for 自动选模板)。原无 default
|
||||
// 时,`{}` 在 Tauri IPC 入口反序列化 DagDef 即报 `missing field nodes`,挡在选模板
|
||||
@@ -91,7 +91,7 @@ impl Default for DagDef {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// BUG-260621-01: 前端 run_workflow 推进链传空 dag `{}`,serde 必须能反序列化
|
||||
// 前端 run_workflow 推进链传空 dag `{}`,serde 必须能反序列化
|
||||
// 成空 DagDef(非报 missing field),后续 workflow.rs:91 dag.nodes.is_empty()
|
||||
// 进 template_for 选模板。nodes/edges 加 #[serde(default)] 前此用例会失败。
|
||||
#[test]
|
||||
|
||||
@@ -98,27 +98,27 @@ impl DagExecutor {
|
||||
let mut any_edge_passed = false; // 任一入边放行(含无条件边)
|
||||
if let Some(preds) = adjacency_in.get(node_id) {
|
||||
for (pred_id, cond) in preds {
|
||||
if let Some(out) = outputs.get(pred_id) {
|
||||
if eval_conditions {
|
||||
if let Some(cond) = cond {
|
||||
has_any_cond_edge = true;
|
||||
// 以 source output.data 为 context 求值;失败保守 false
|
||||
let passed = ConditionEngine::evaluate(cond, &out.data)
|
||||
.unwrap_or(false);
|
||||
if passed {
|
||||
inputs.insert(pred_id.clone(), out.clone());
|
||||
any_edge_passed = true;
|
||||
}
|
||||
} else {
|
||||
// 无 condition 的入边:无条件放行(必收集)
|
||||
inputs.insert(pred_id.clone(), out.clone());
|
||||
any_edge_passed = true;
|
||||
}
|
||||
} else {
|
||||
// flag 关:condition 完全忽略,旧行为全收集
|
||||
inputs.insert(pred_id.clone(), out.clone());
|
||||
any_edge_passed = true;
|
||||
let Some(out) = outputs.get(pred_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// flag 关:condition 完全忽略,旧行为全收集;
|
||||
// flag 开 + 无 condition 入边:无条件放行(必收集);
|
||||
// flag 开 + 有 condition 入边:以 source output.data 为 context 求值,
|
||||
// 失败保守 false(对齐引擎语义)。
|
||||
let passed = match (eval_conditions, cond) {
|
||||
(false, _) => true,
|
||||
(true, None) => true,
|
||||
(true, Some(c)) => {
|
||||
ConditionEngine::evaluate(c, &out.data).unwrap_or(false)
|
||||
}
|
||||
};
|
||||
if eval_conditions && cond.is_some() {
|
||||
has_any_cond_edge = true;
|
||||
}
|
||||
if passed {
|
||||
inputs.insert(pred_id.clone(), out.clone());
|
||||
any_edge_passed = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -41,13 +41,21 @@ impl StateMachine {
|
||||
}
|
||||
|
||||
/// 获取节点状态
|
||||
///
|
||||
/// 锁中毒时降级返回 `NodeStatus::Pending`(保守默认:视为未启动,
|
||||
/// 执行器不会误判为已完成/失败),并 `tracing::error!` 记录,不 panic。
|
||||
pub fn get(&self, node_id: &NodeId) -> NodeStatus {
|
||||
self.states
|
||||
.lock()
|
||||
.expect("状态机锁中毒")
|
||||
.get(node_id)
|
||||
.cloned()
|
||||
.unwrap_or(NodeStatus::Pending)
|
||||
match self.states.lock() {
|
||||
Ok(states) => states.get(node_id).cloned().unwrap_or(NodeStatus::Pending),
|
||||
Err(poisoned) => {
|
||||
tracing::error!(
|
||||
"状态机锁中毒,get({}) 降级返回 Pending:{}",
|
||||
node_id,
|
||||
poisoned
|
||||
);
|
||||
NodeStatus::Pending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断状态转换是否合法
|
||||
@@ -61,8 +69,23 @@ impl StateMachine {
|
||||
}
|
||||
|
||||
/// 状态转换 — 校验合法性后更新,非法转换返回错误
|
||||
///
|
||||
/// 锁中毒时降级返回 `Err`(携带「状态机锁中毒」上下文)并 `tracing::error!` 记录,
|
||||
/// 由调用方决定如何处理(通常是 `set_running`/`set_completed`/`set_failed`
|
||||
/// 把 Err 上抛 → 节点执行流捕获后置 Failed),不 panic 拖垮 runtime。
|
||||
pub fn transition(&self, node_id: NodeId, target: NodeStatus) -> anyhow::Result<()> {
|
||||
let mut states = self.states.lock().expect("状态机锁中毒");
|
||||
let mut states = match self.states.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => {
|
||||
tracing::error!(
|
||||
"状态机锁中毒,transition({}, {}) 降级返 Err:{}",
|
||||
node_id,
|
||||
target.as_str(),
|
||||
poisoned
|
||||
);
|
||||
anyhow::bail!("状态机锁中毒,节点 {} 状态转换失败", node_id);
|
||||
}
|
||||
};
|
||||
let current = states
|
||||
.get(&node_id)
|
||||
.cloned()
|
||||
@@ -104,15 +127,35 @@ impl StateMachine {
|
||||
///
|
||||
/// 注:原 set_waiting/set_skipped 同为旁路置位但全仓零调用,已删除。
|
||||
pub fn set_cancelled(&self, node_id: NodeId) {
|
||||
self.states
|
||||
.lock()
|
||||
.expect("状态机锁中毒")
|
||||
.insert(node_id, NodeStatus::Cancelled);
|
||||
match self.states.lock() {
|
||||
Ok(mut states) => {
|
||||
states.insert(node_id, NodeStatus::Cancelled);
|
||||
}
|
||||
Err(poisoned) => {
|
||||
tracing::error!(
|
||||
"状态机锁中毒,set_cancelled({}) 降级丢弃取消信号:{}",
|
||||
node_id,
|
||||
poisoned
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有状态快照(clone 返回,调用方持独立副本)
|
||||
///
|
||||
/// 锁中毒时降级返回空 HashMap(调用方遍历视为无已完成节点,保守安全),
|
||||
/// 并 `tracing::error!` 记录,不 panic。
|
||||
pub fn snapshot(&self) -> HashMap<NodeId, NodeStatus> {
|
||||
self.states.lock().expect("状态机锁中毒").clone()
|
||||
match self.states.lock() {
|
||||
Ok(states) => states.clone(),
|
||||
Err(poisoned) => {
|
||||
tracing::error!(
|
||||
"状态机锁中毒,snapshot() 降级返回空 HashMap:{}",
|
||||
poisoned
|
||||
);
|
||||
HashMap::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查节点是否被取消
|
||||
|
||||
@@ -0,0 +1,732 @@
|
||||
# 上下文管理演进 — 任务技术设计
|
||||
|
||||
> 创建: 2026-07-20 | 状态: 设计阶段 | 关联文档: `上下文管理演进与发散思考-2026-07-20.md`
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [T2 工具命名空间(详细设计)](#t2-工具命名空间详细设计)
|
||||
- [T4 工作流 DAG 注入(详细设计)](#t4-工作流-dag-注入详细设计)
|
||||
- [T3 L3 结构化摘要(设计概要)](#t3-l3-结构化摘要设计概要)
|
||||
|
||||
---
|
||||
|
||||
## T2 工具命名空间(详细设计)
|
||||
|
||||
### 现有流程 vs 新流程
|
||||
|
||||
```
|
||||
现有流程:
|
||||
AiToolRegistry.execute(name, args)
|
||||
→ 工具执行 → 全量结果 String
|
||||
→ push(ChatMessage::tool_result(全量内容)) ← 不管大小,全塞进消息队列
|
||||
→ build_for_request 时,大 tool_result 撑爆预算
|
||||
→ 压缩/裁剪 → 丢失细节
|
||||
|
||||
新流程:
|
||||
AiToolRegistry.execute(name, args)
|
||||
→ 工具执行 → 全量结果 String
|
||||
→ if should_use_namespace(全量内容)
|
||||
→ store_to_namespace(key, 全量内容) ← 存入独立存储
|
||||
→ push(ChatMessage::tool_result(引用路径)) ← 主队列只插引用
|
||||
else
|
||||
→ push(ChatMessage::tool_result(全量内容)) ← 小结果不动
|
||||
→ build_for_request 时,引用只占 ~20 tokens
|
||||
→ 压缩不影响 namespace 内容
|
||||
→ 模型通过引用路径按需读取(或系统自动补全)
|
||||
```
|
||||
|
||||
### 数据结构
|
||||
|
||||
```rust
|
||||
// crates/df-ai/src/namespace_store.rs
|
||||
|
||||
/// namespace 引用路径格式: "namespace://tool_name/args_hash"
|
||||
/// 例: "namespace://read_file/a1b2c3d4"
|
||||
pub const NAMESPACE_REF_PREFIX: &str = "namespace://";
|
||||
|
||||
/// 落入 namespace 的字节阈值(> 2048 bytes)
|
||||
pub const NAMESPACE_BYTE_THRESHOLD: usize = 2048;
|
||||
|
||||
/// 落入 namespace 的行数阈值(> 50 行)
|
||||
pub const NAMESPACE_LINE_THRESHOLD: usize = 50;
|
||||
|
||||
/// namespace 存储
|
||||
///
|
||||
/// 内存中为 HashMap,提供常规 CRUD;
|
||||
/// 外部可注入持久化实现(接 SQLite / 内存 fallback)。
|
||||
/// 设计为独立于 ContextManager 的结构——不被压缩/裁剪影响。
|
||||
pub struct NamespaceStore {
|
||||
/// key = 引用路径 hash, value = 原始工具结果
|
||||
entries: HashMap<String, NamespaceEntry>,
|
||||
/// 总字节数上限(防内存爆炸,超限淘汰最旧条目)
|
||||
max_bytes: usize,
|
||||
current_bytes: usize,
|
||||
}
|
||||
|
||||
pub struct NamespaceEntry {
|
||||
pub key: String, // 完整引用路径
|
||||
pub tool_name: String,
|
||||
pub args: serde_json::Value,
|
||||
pub content: String, // 原始工具执行结果
|
||||
pub content_summary: String, // extract_key_info 后的摘要(供 LLM 预览)
|
||||
pub created_at: Instant,
|
||||
pub access_count: u64,
|
||||
}
|
||||
```
|
||||
|
||||
### 关键决策点
|
||||
|
||||
#### 决策 1:何时进 namespace
|
||||
|
||||
```rust
|
||||
pub fn should_use_namespace(content: &str, tool_name: &str) -> bool {
|
||||
// 1. 明确标记为"大结果"的工具(read_file / list_directory 几乎总是大)
|
||||
if is_always_large_tool(tool_name) {
|
||||
return true;
|
||||
}
|
||||
// 2. 按大小阈值判定
|
||||
content.len() > NAMESPACE_BYTE_THRESHOLD
|
||||
|| content.lines().count() > NAMESPACE_LINE_THRESHOLD
|
||||
}
|
||||
|
||||
fn is_always_large_tool(tool_name: &str) -> bool {
|
||||
matches!(tool_name, "read_file" | "list_directory" | "grep" | "diff_files")
|
||||
}
|
||||
```
|
||||
|
||||
#### 决策 2:模型看到什么
|
||||
|
||||
LLM 在 tool_result 消息中看到的不是全量内容,而是:
|
||||
|
||||
```
|
||||
当前(小结果原样,大结果原样):
|
||||
[tool_result] 342 行文件内容...(占 2000 tokens)
|
||||
|
||||
新流程(引用路径 + 摘要预览):
|
||||
[tool_result] <namespace://read_file/a1b2c3>
|
||||
[tool_result]
|
||||
文件: config.rs
|
||||
行数: 342
|
||||
关键节点: timeout:15 (行15), pool_size:10 (行42)
|
||||
```
|
||||
|
||||
**LLM 的理解能力验证**:如果模型需要读取完整内容,system prompt 需说明:
|
||||
|
||||
```
|
||||
[上下文说明]
|
||||
当工具结果以 <namespace://path> 格式返回时,表示完整内容已存储。
|
||||
如果你需要查看完整内容,回复 /read_namespace path
|
||||
系统将自动拉取完整内容替换当前引用。
|
||||
```
|
||||
|
||||
或自动更激进——当 LLM 的回复引用某 namespace 路径时,系统在下一轮自动拉取:
|
||||
|
||||
```
|
||||
LLM: "config.rs 第 15 行的 timeout 需要改"
|
||||
系统检测: /read_namespace 触发 → 从 namespace 拉取 config.rs 完整内容
|
||||
→ 下轮 tool_result 消息中替换为完整内容
|
||||
→ LLM 看到原文,精确引用行号
|
||||
```
|
||||
|
||||
#### 决策 3:持久化与生命周期
|
||||
|
||||
```rust
|
||||
impl NamespaceStore {
|
||||
/// 存入 namespace
|
||||
pub fn store(&mut self, key: &str, tool_name: &str, args: &Value, content: &str) -> String {
|
||||
let summary = extract_key_info(content); // 复用现有 extract_key_info
|
||||
let entry = NamespaceEntry {
|
||||
key: key.to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
args: args.clone(),
|
||||
content: content.to_string(),
|
||||
content_summary: summary,
|
||||
created_at: Instant::now(),
|
||||
access_count: 0,
|
||||
};
|
||||
self.current_bytes += content.len();
|
||||
// 超限淘汰:从最旧开始删
|
||||
while self.current_bytes > self.max_bytes {
|
||||
// ... LRU 淘汰
|
||||
}
|
||||
format!("{}{}/{}", NAMESPACE_REF_PREFIX, tool_name, key)
|
||||
}
|
||||
|
||||
/// 读取完整内容
|
||||
pub fn read(&mut self, full_path: &str) -> Option<&str> {
|
||||
let key = self.parse_key(full_path)?;
|
||||
let entry = self.entries.get_mut(&key)?;
|
||||
entry.access_count += 1;
|
||||
Some(entry.content.as_str())
|
||||
}
|
||||
|
||||
/// 读取摘要(用于自动补全判断)
|
||||
pub fn read_summary(&self, full_path: &str) -> Option<&str> {
|
||||
let key = self.parse_key(full_path)?;
|
||||
self.entries.get(&key).map(|e| e.content_summary.as_str())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 接线点:process_tool_calls
|
||||
|
||||
改动点位于 `audit/mod.rs::process_tool_calls`,工具执行完成后的 push 路径:
|
||||
|
||||
```rust
|
||||
// current (audit/mod.rs ~380行附近)
|
||||
pub(crate) async fn process_tool_calls(..., conv_id: &str) -> usize {
|
||||
// ... 现有审批/执行逻辑 ...
|
||||
for (index, draft) in tc_list {
|
||||
// ... 执行工具 ...
|
||||
let result = tools_arc.execute(&draft.name, &draft.args).await;
|
||||
|
||||
// ★ 新增:namespace 判断
|
||||
let content = if should_use_namespace(&result, &draft.name) {
|
||||
let session = session_arc.lock().await;
|
||||
let ns = &mut session.namespace_store; // AiSession 新增字段
|
||||
let ref_path = ns.store(&draft.name, &draft.args, &result);
|
||||
ref_path // ← 主队列只推引用路径
|
||||
} else {
|
||||
result // ← 小结果原样推
|
||||
};
|
||||
|
||||
// 后续 push 不变
|
||||
let msg = ChatMessage::tool_result(draft.tool_call_id.clone(), &content);
|
||||
session.conv(conv_id).messages.push(msg);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AiSession 新增字段
|
||||
|
||||
```rust
|
||||
// src-tauri/src/commands/ai/mod.rs 或 state.rs
|
||||
|
||||
pub struct AiSession {
|
||||
pub conversations: HashMap<String, AiConversation>,
|
||||
pub current_conv: Option<String>,
|
||||
pub generating: HashSet<String>,
|
||||
|
||||
// ★ 新增
|
||||
pub namespace_store: NamespaceStore,
|
||||
}
|
||||
```
|
||||
|
||||
### 边界情况推演
|
||||
|
||||
#### 情况 1:模型不理解为 namespace 引用
|
||||
|
||||
```
|
||||
LLM 收到 [tool_result] <namespace://read_file/a1b2c3>
|
||||
LLM 回复: "读了 config.rs,但我不知道内容是什么"
|
||||
→ 用户体验差
|
||||
```
|
||||
|
||||
**对策**:引用路径不是结束。在 `process_tool_calls` push 引用时,额外 push 一条 assistant 消息或修改 tool_result 内容格式:
|
||||
|
||||
```
|
||||
[tool_result] 文件 config.rs (342 行)
|
||||
| 关键行: timeout:15 (行15), pool_size:10 (行42)
|
||||
| 完整内容: <namespace://read_file/a1b2c3>
|
||||
```
|
||||
|
||||
即:`extract_key_info(已有)+ 引用路径` 混合格式。LLM 可以靠摘要感知文件内容,只有需要精确行号时才触发 `/read_namespace`。
|
||||
|
||||
#### 情况 2:同一文件在 2 轮内被多次引用
|
||||
|
||||
```
|
||||
第 3 轮: read_file("config.rs") → namespace 存 342 行 → 引用
|
||||
第 5 轮: 模型需要第 15 行 → /read_namespace → 系统拉取
|
||||
```
|
||||
|
||||
第 5 轮拉取后,第 6 轮应该做什么?
|
||||
|
||||
**方案 A(推荐)**:拉取后仅在**当轮** `tool_result` 替换为完整内容。下一轮重新压缩时,如果内容大再次进入 namespace。
|
||||
|
||||
```
|
||||
第 5 轮: [tool_result] config.rs 342 行全文(/read_namespace 触发的)
|
||||
第 6 轮: 压缩 → config.rs 内容被压缩掉 → 正常
|
||||
第 7 轮: 模型再次需要 → 再次 /read_namespace → namespace 还在
|
||||
```
|
||||
|
||||
**方案 B**:拉取后一直保留在消息队列中。
|
||||
|
||||
→ 不推荐,回到了 tool_result 膨胀的老路。
|
||||
|
||||
#### 情况 3:namespace 内存爆炸
|
||||
|
||||
```
|
||||
最大对话: 500 轮,平均每轮存 2 个 namespace 条目,每条约 3k bytes
|
||||
→ 500 × 2 × 3k = 3MB
|
||||
```
|
||||
|
||||
设 `max_bytes = 10MB`(宽松上限),最旧条目自动淘汰。淘汰后如果有模型再次引用:
|
||||
|
||||
```
|
||||
LLM: /read_namespace namespace://read_file/a1b2c3
|
||||
系统: 条目已淘汰 → 重新执行工具 → 重新 namespace 存储 → 返回内容
|
||||
```
|
||||
|
||||
等价于「cache miss」,对用户透明。
|
||||
|
||||
#### 情况 4:DB 持久化需不需要存 namespace
|
||||
|
||||
**不需要**。namespace 是运行时缓存优化,不是持久化真相源。全量消息已在 `save_conversation` 写入 `ai_messages` 表(uncompressed),压缩后的摘要也在 system 消息中。namespace 淘汰后,可通过 DB 重新构建,但需要保证:
|
||||
|
||||
```rust
|
||||
// save_conversation 中
|
||||
if msg.content.starts_with(NAMESPACE_REF_PREFIX) {
|
||||
// 写 DB 前将引用替换回原始内容(从 namespace 中取)
|
||||
// 替代方案:DB 也存引用路径,恢复时从 namespace 重建
|
||||
}
|
||||
```
|
||||
|
||||
**建议**:DB 存引用路径。恢复时 namespace 可能已淘汰,此时:
|
||||
|
||||
1. 重新执行工具(不可行——工具可能有副作用)
|
||||
2. DB 保留原文(`save_conversation` 时展开引用)
|
||||
|
||||
**结论**:DB 存原文。save 时 namespace 条目肯定存在(刚执行完),展开引用写入 DB。恢复时直接读 DB 原文,namespace 只服务于运行时。
|
||||
|
||||
```rust
|
||||
// save_conversation 中展开引用
|
||||
let content = if msg.content.starts_with(NAMESPACE_REF_PREFIX) {
|
||||
namespace_store.read(&msg.content)
|
||||
.unwrap_or(&msg.content) // 兜底:用引用路径自身(可能性低)
|
||||
} else {
|
||||
&msg.content
|
||||
};
|
||||
record.content = content.to_string();
|
||||
```
|
||||
|
||||
### 推演:T2 在 T-heavy 场景中的行为
|
||||
|
||||
```
|
||||
第 3 轮: read_file("config.rs") → 342 行
|
||||
应进 namespace ✓
|
||||
主队列: [tool_result] 文件 config.rs (342行), 行15:timeout, 行42:pool
|
||||
主队列 token: ~80(→原来 2000)
|
||||
节省: 1920 tokens
|
||||
|
||||
第 4-7 轮: 另有 3 次大工具结果 → 每次省 ~1500-2000 tokens
|
||||
|
||||
第 8 轮: 压缩触发
|
||||
当前: 主队列总 history_tokens ≈ 5000(含 4 条引用)
|
||||
→ 未达压缩阈值(原应为 20000+)
|
||||
压缩不触发 ✓ 用户无感知
|
||||
|
||||
第 15 轮: 用户回来 "config.rs 的 timeout 在第几行?"
|
||||
LLM 在压缩摘要中仍能找到"行15:timeout" ← 摘要来自 extract_key_info
|
||||
→ 精确回答 ✓ 不需要 /read_namespace
|
||||
|
||||
第 16 轮: "删掉那行,改成 30"
|
||||
LLM 需要完整文件结构来做 diff
|
||||
→ /read_namespace namespace://read_file/xxx
|
||||
→ 系统拉取完整 342 行
|
||||
→ 第 16 轮 tool_result 出现完整内容
|
||||
→ 第 17 轮压缩 → 完整内容又被 namespace 吸收 → 回到摘要+引用
|
||||
```
|
||||
|
||||
**总节省**:15 轮对话中,原本平均每轮 4000 tokens 的大 tool_result 占用 → 现在每轮 ~100 tokens 引用 + 摘要。**累积节省 ~60k tokens,约 $0.18(Sonnet)。**
|
||||
|
||||
---
|
||||
|
||||
## T4 工作流 DAG 注入(详细设计)
|
||||
|
||||
### 现有流程 vs 新流程
|
||||
|
||||
```
|
||||
现有:
|
||||
run_agentic_loop system_prompt 拼接:
|
||||
[系统指令] + [工具定义] + [pinned_goals] + [知识注入]
|
||||
→ build_for_request
|
||||
→ LLM 只能从消息历史中推断"当前在做什么"
|
||||
|
||||
新流程:
|
||||
run_agentic_loop system_prompt 拼接前:
|
||||
if conv.workflow_id != None:
|
||||
dag = df_workflow::Dag::load(conv.workflow_id)
|
||||
dag_block = render_dag_to_system_block(dag)
|
||||
system_prompt = dag_block + system_prompt_原有内容
|
||||
→ build_for_request(system_prompt + ...)
|
||||
→ LLM 看到结构化 DAG,"任务进展"一目了然
|
||||
```
|
||||
|
||||
### 数据结构
|
||||
|
||||
```rust
|
||||
// src-tauri/src/commands/ai/workflow_context.rs
|
||||
|
||||
/// 工作流上下文块 — 注入 system prompt 的 DAG 摘要
|
||||
pub struct WorkflowContextBlock {
|
||||
pub workflow_id: String,
|
||||
pub workflow_name: String,
|
||||
pub total_nodes: usize,
|
||||
pub completed_nodes: usize,
|
||||
pub current_node: Option<WorkflowNodeSummary>,
|
||||
pub next_nodes: Vec<WorkflowNodeSummary>,
|
||||
}
|
||||
|
||||
/// DAG 节点摘要 — 不进完整 DAG,只进关键上下文
|
||||
pub struct WorkflowNodeSummary {
|
||||
pub node_id: String,
|
||||
pub node_type: String, // "AINode" | "ScriptNode" | "HumanNode" | ...
|
||||
pub label: String, // 用户或 LLM 设定的节点名称
|
||||
pub status: String, // "completed" | "running" | "pending" | "blocked"
|
||||
pub output_summary: Option<String>, // 节点输出的摘要(关键产出)
|
||||
}
|
||||
```
|
||||
|
||||
### 关键决策点
|
||||
|
||||
#### 决策 1:DAG 全量注入还是摘要注入
|
||||
|
||||
```
|
||||
DAG 可能很大(50+ 节点)。全量注入 ≈ 2000+ tokens,不可接受。
|
||||
```
|
||||
|
||||
**方案**:只注入「当前活跃路径」。从 DAG 的起始节点到当前节点 + 后续 2 层子节点。其余节点不注入。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "DAG 全量(50 节点)"
|
||||
N1["N1 ✅"] --> N2["N2 ✅"]
|
||||
N1 --> N3["N3 ✅"]
|
||||
N2 --> N4["N4 🚧 当前"]
|
||||
N2 --> N5["N5 pending"]
|
||||
N3 --> N6["N6 pending"]
|
||||
N4 --> N7["N7 pending"]
|
||||
N5 --> N8["N8 pending"]
|
||||
N6 --> N9["N9 pending"]
|
||||
N3 --> N10["N10 pending"]
|
||||
N4 --> N11["N11 pending"]
|
||||
end
|
||||
|
||||
subgraph "注入内容(当前活跃路径,5 节点)"
|
||||
I1["N1: 读源码 ✅"]
|
||||
I2["N2: 分析依赖 ✅"]
|
||||
I3["N4: 改配置 🚧"]
|
||||
I4["N5: 验证配置 pending"]
|
||||
I5["N7: 提交变更 pending"]
|
||||
end
|
||||
```
|
||||
|
||||
```rust
|
||||
pub fn build_active_path(dag: &Dag, current_node_id: &str) -> WorkflowContextBlock {
|
||||
let path = dag.path_to_root(current_node_id); // 到根的全路径
|
||||
let next = dag.children(current_node_id, 2); // 后续 2 层
|
||||
|
||||
WorkflowContextBlock {
|
||||
workflow_id: dag.id.clone(),
|
||||
workflow_name: dag.name.clone(),
|
||||
total_nodes: dag.nodes.len(),
|
||||
completed_nodes: dag.nodes.iter().filter(|n| n.status == "completed").count(),
|
||||
current_node: summarize_node(dag.get_node(current_node_id)),
|
||||
next_nodes: next.into_iter().map(|n| summarize_node(n)).collect(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 决策 2:注入位置
|
||||
|
||||
注入 system prompt 的最前方(优先级高于工具定义):
|
||||
|
||||
```
|
||||
[system]
|
||||
[工作流] 审批模块改造 (ID: wf-abc)
|
||||
✅ 1/3 读 config.rs 源码 — 产出: timeout:15 位于行15
|
||||
🚧 2/3 改 timeout 配置 — 当前步骤
|
||||
⬜ 3/3 验证配置生效
|
||||
──────────────────────────────────────
|
||||
[工具定义] 30 个工具的 JSON schema...
|
||||
[目标提示] ...
|
||||
[system prompt 原有内容]
|
||||
```
|
||||
|
||||
**理由**:工作流状态是会话的最上层语境。模型先看到"我们在做什么",再看到"有什么工具可用"。
|
||||
|
||||
#### 决策 3:无工作流时的行为
|
||||
|
||||
```rust
|
||||
if let Some(wf_id) = &conv.workflow_id {
|
||||
if let Ok(dag) = df_workflow::Dag::load(db, wf_id) {
|
||||
let block = build_active_path(&dag, &conv.current_node_id);
|
||||
system_prompt = format!("{}\n{}", block.to_system_text(), system_prompt);
|
||||
}
|
||||
// Dag::load 失败 → 静默跳过(workflow 可能被删了)
|
||||
}
|
||||
// workflow_id == None → 行为完全不变
|
||||
```
|
||||
|
||||
### 推演:T4 在 T-resume 场景中的行为
|
||||
|
||||
```
|
||||
上午:
|
||||
用户在工作流 wf-abc 中:
|
||||
N1: 读源码 ✅
|
||||
N2: 分析依赖 ✅
|
||||
N3: 改配置 🚧(当前)
|
||||
最后操作: 改了一半配置,离开
|
||||
|
||||
下午回来:
|
||||
现有: 消息历史被压缩 → 摘要"用户讨论过配置修改"
|
||||
→ LLM 答:"你讨论了配置,要继续吗?"
|
||||
→ 用户需要重新说明
|
||||
|
||||
T4: system prompt 注入:
|
||||
[工作流] 审批模块改造
|
||||
✅ 1/3 读 config.rs — timeout:15
|
||||
✅ 2/3 分析 db.rs 依赖 — 连接池:10
|
||||
🚧 3/3 改 timeout 配置 — 已设为30,未验证
|
||||
→ LLM: "你上午在改 timeout 配置,已经改了还没验证,
|
||||
要继续验证还是改其他?"
|
||||
→ 用户直接继续,不需要重新说明
|
||||
```
|
||||
|
||||
**精度依赖**:`WorkflowNodeSummary.output_summary` 是关键。如果工作流节点在完成时已有结构化的产出记录(`read_file → 行号:timeout:15`),LLM 就能精确回溯。这需要 df-workflow 的节点在完成时主动记录产出摘要——目前不一定有。
|
||||
|
||||
**缺口**:如果工作流节点跑完但没有产出摘要(`output_summary: None`),LLM 只能看到"节点已完成",不知道完成了什么。`output_summary` 需要在 `AiNode::execute` 完成时自动产生:
|
||||
|
||||
```rust
|
||||
// df-nodes/src/ai_node.rs: 执行完成后
|
||||
if let Some(workflow_node_id) = current_workflow_node {
|
||||
let summary = extract_key_info(&result); // 复用
|
||||
workflow::update_node_output(db, workflow_node_id, summary);
|
||||
}
|
||||
```
|
||||
|
||||
这个联动在 T4 之前需要确保。
|
||||
|
||||
### T4 + T2 组合推演
|
||||
|
||||
```
|
||||
T2 提供了 namespace 精确行号回溯
|
||||
T4 提供了工作流节点结构化映射
|
||||
|
||||
组合:
|
||||
LLM: "config.rs 的 timeout 在哪一行?"
|
||||
T4 DAG 节点: "N1: 读 config.rs"
|
||||
T2 namespace: 关联 namespace://read_file/xxx 到 N1 的产出
|
||||
→ LLM 从 DAG 节点取到 "timeout:15 位于行15"
|
||||
→ 不需要调用 /read_namespace
|
||||
→ 精确行号回答,零额外 token
|
||||
|
||||
如果 T2 没有、T4 没有:
|
||||
LLM: 从压缩摘要猜 → 可能错
|
||||
|
||||
如果 T2 有、T4 没有:
|
||||
LLM: 从 DAG 节点取出 "读 config.rs",但需要/read_namespace 拿行号
|
||||
→ 多一次 tool round
|
||||
|
||||
如果 T2 没有、T4 有:
|
||||
LLM: DAG 节点只有 "读源码 ✅",没有行号细节
|
||||
→ 重新 read_file
|
||||
|
||||
组合后: 最大精度,最小 token 开销。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## T3 L3 结构化摘要(设计概要)
|
||||
|
||||
### 改 compress_via_llm 返回类型
|
||||
|
||||
```rust
|
||||
// current
|
||||
pub(crate) async fn compress_via_llm(...) -> Result<String, String>;
|
||||
|
||||
// new
|
||||
pub struct CompressedSummary {
|
||||
/// JSON 卡片序列化字符串
|
||||
pub json_card: String,
|
||||
/// 自然语言摘要(向前兼容)
|
||||
pub nl_summary: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn compress_via_llm(...) -> Result<CompressedSummary, String>;
|
||||
```
|
||||
|
||||
### 改 compress_prompt
|
||||
|
||||
```rust
|
||||
pub(crate) fn compress_prompt(lang: &str) -> &'static str {
|
||||
match lang {
|
||||
"en" => "You are a conversation summarizer. Compress the following conversation \
|
||||
into a structured summary that preserves the essential context for \
|
||||
continuing the work. \
|
||||
\n\
|
||||
**You MUST output TWO parts separated by a delimiter:**\n\
|
||||
\n\
|
||||
Part 1 — JSON (between <<<JSON>>> and <<<END_JSON>>>):\n\
|
||||
{\n\
|
||||
\"topics\": [\"topic1\", \"topic2\"],\n\
|
||||
\"decisions\": [{\"what\": \"...\", \"why\": \"...\"}],\n\
|
||||
\"unresolved\": [\"question1\"],\n\
|
||||
\"key_files\": [{\"path\": \"...\", \"change\": \"...\"}],\n\
|
||||
\"token_saved\": <estimated_tokens>\n\
|
||||
}\n\
|
||||
\n\
|
||||
Part 2 — Natural language summary (between <<<NL>>> and <<<END_NL>>>):\n\
|
||||
A concise paragraph summarizing the conversation.\n\
|
||||
\n\
|
||||
Rules:\n\
|
||||
- JSON must be valid.\n\
|
||||
- Keep the natural language summary concise; prefer bullet points.\n\
|
||||
- Preserve file paths, identifiers, and error messages verbatim in both parts.\n\
|
||||
- Drop small talk; keep only technically load-bearing facts.\n\
|
||||
- Do NOT invent facts.\n",
|
||||
_ => {
|
||||
// 中文版本同上,翻译为中文
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### insert_at 处的消费
|
||||
|
||||
```rust
|
||||
// context_lifecycle.rs: 压缩成功后
|
||||
match compress_outcome {
|
||||
Ok(Some(summary)) => {
|
||||
// ★ 新格式:JSON + NL 双格式嵌入 system 消息
|
||||
let system_text = format!(
|
||||
"{}<<<STRUCTURED_SUMMARY>>>\n{}\n<<<END_STRUCTURED_SUMMARY>>>\n{}",
|
||||
SUMMARY_MARKER,
|
||||
summary.json_card,
|
||||
summary.nl_summary,
|
||||
);
|
||||
conv.messages.insert_at(0, ChatMessage::system(&system_text));
|
||||
// ...
|
||||
}
|
||||
Ok(None) => { /* noop */ }
|
||||
Err(e) => {
|
||||
// LLM 压缩失败 → 关键词摘要兜底(同前)
|
||||
// 关键词摘要是纯文本格式,不需要 JSON 结构
|
||||
// → insert_at(0, system(keyword_fallback)) ← 与旧行为一致
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 退化检测适配
|
||||
|
||||
当前 `clean_summary` 函数只处理纯文本退化。新格式引入后,退化检测需增加 JSON 解析验证:
|
||||
|
||||
```rust
|
||||
fn clean_summary(raw: &str) -> Result<CompressedSummary, String> {
|
||||
// 提取 JSON 段
|
||||
let json = extract_between(raw, "<<<JSON>>>", "<<<END_JSON>>>")?;
|
||||
let nl = extract_between(raw, "<<<NL>>>", "<<<END_NL>>>")?;
|
||||
|
||||
// 验证 JSON 合法性
|
||||
let card: SummaryCard = serde_json::from_str(&json)
|
||||
.map_err(|e| format!("JSON 解析失败: {}", e))?;
|
||||
|
||||
// 退化检测 NL 段
|
||||
if is_degenerated_repetition(&nl) {
|
||||
return Err("NL 段退化重复".to_string());
|
||||
}
|
||||
|
||||
Ok(CompressedSummary {
|
||||
json_card: json,
|
||||
nl_summary: nl,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 推演:T3 在检索场景中的行为
|
||||
|
||||
```
|
||||
无 T3(当前):
|
||||
压缩摘要: "用户讨论了审批配置,把 timeout 改成了 30"
|
||||
LLM 读取后知道"改过",但具体决策原因不清晰
|
||||
→ 需要推测或 ask user
|
||||
|
||||
有 T3:
|
||||
system 消息中嵌入:
|
||||
<<<STRUCTURED_SUMMARY>>>
|
||||
{"decisions": [{"what": "timeout: 15→30", "why": "用户反馈15min太短"}]}
|
||||
<<<END_STRUCTURED_SUMMARY>>>
|
||||
自然语言: 用户讨论审批配置,决定超时改为 30 分钟
|
||||
|
||||
LLM 直接从 JSON 读取决策原因:
|
||||
→ "15→30,原因是用户反馈太短"
|
||||
→ 精确回答 ✓
|
||||
→ 不需要 ask user
|
||||
```
|
||||
|
||||
**收益量化**:每次压缩后,JSON 卡片占 ~300 tokens,自然语言占 ~200 tokens。比纯自然语言的 ~400 tokens 多了 ~100 tokens。但 JSON 的结构化让 LLM 的检索精度从「需要推理」变为「可以直接读」,减少了后续追问的轮次。
|
||||
|
||||
---
|
||||
|
||||
## T1 动态压缩阈值(设计概要)
|
||||
|
||||
代码改动精确到行:
|
||||
|
||||
```rust
|
||||
// src-tauri/src/commands/ai/agentic/mod.rs: 调用处(line ~1117)
|
||||
if maybe_auto_compress(
|
||||
&session_arc,
|
||||
&conv_id,
|
||||
&app_handle,
|
||||
&provider,
|
||||
&provider_config,
|
||||
&llm_concurrency,
|
||||
iteration,
|
||||
sys_tokens, // ★ 新增参数
|
||||
).await {
|
||||
```
|
||||
|
||||
```rust
|
||||
// src-tauri/src/commands/ai/agentic/context_lifecycle.rs: 函数签名(line ~72)
|
||||
pub(super) async fn maybe_auto_compress(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
app_handle: &AppHandle,
|
||||
provider: &Box<dyn LlmProvider>,
|
||||
provider_config: &AiProviderRecord,
|
||||
llm_concurrency: &LlmConcurrency,
|
||||
iteration: usize,
|
||||
sys_tokens: u32, // ★ 新增
|
||||
) -> bool {
|
||||
```
|
||||
|
||||
```rust
|
||||
// 同上,line ~88-92 触发条件
|
||||
let budget = mgr.budget_limit();
|
||||
let available = budget.saturating_sub(sys_tokens); // ★ 新增
|
||||
let should = protect_start > 0
|
||||
&& (available as u64) * 6 / 10 < history_tokens as u64 // ★ 改 budget→available
|
||||
&& mgr.has_compressible_messages(protect_start);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序验证(依赖关系)
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
T1["T1: 动态阈值<br>~4 行改动"] --> T2["T2: 命名空间<br>~300 行"]
|
||||
T1 -.->|可选前置| T3["T3: 结构化摘要<br>~100 行"]
|
||||
T2 --> T4["T4: 工作流DAG<br>~500 行"]
|
||||
T3 --> T5["T5: WorkingContext<br>~500 行"]
|
||||
|
||||
T2 -.-> T4
|
||||
T3 -.-> T4
|
||||
|
||||
style T1 fill:#c8e6c9
|
||||
style T2 fill:#c8e6c9
|
||||
style T3 fill:#fff9c4
|
||||
style T4 fill:#fff9c4
|
||||
style T5 fill:#ffccbc
|
||||
```
|
||||
|
||||
**并发策略(AI coding 多 agent 并行)**:
|
||||
|
||||
| 并行流 | 任务 | 前置 | 可独立启动? |
|
||||
|--------|------|------|------------|
|
||||
| 流 A | T1 + T2 | T1 无前置,T2 无前置 | ✅ 立即,T1 与 T2 可并行编码 |
|
||||
| 流 B | T3 | 无(与 T2 独立) | ✅ 可同时启动 |
|
||||
| 流 C | T4 | 依赖 T2 的 namespace 接口签名(非实现) | ⚠️ 接口定义后即可启动 |
|
||||
| 流 D | T5 | 依赖 T3 的 CompressedSummary 结构 | ⚠️ T3 验收后 |
|
||||
@@ -0,0 +1,352 @@
|
||||
# 上下文管理演进 — 执行计划
|
||||
|
||||
> 关联: `上下文管理演进-任务技术设计-2026-07-20.md` | 状态: 待执行
|
||||
|
||||
---
|
||||
|
||||
## Wave 划分
|
||||
|
||||
3 个 Wave,每个 Wave 内 agent 可并行,Wave 间串行 gate。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "Wave 1 (4 agent 并行)"
|
||||
A1["Agent 1: T1 动态阈值"]
|
||||
A2["Agent 2: T2 命名空间存储"]
|
||||
A3["Agent 3: 接口契约定义"]
|
||||
end
|
||||
A3 -.->|"定义 downstream 接口"| A2
|
||||
A1 --> G1["Gate 1: cargo check + 单测"]
|
||||
A2 --> G1
|
||||
|
||||
G1 --> subgraph "Wave 2 (3 agent 并行)"
|
||||
B1["Agent 4: T3 结构化摘要"]
|
||||
B2["Agent 5: T4 工作流 DAG"]
|
||||
end
|
||||
B1 --> G2["Gate 2: cargo check + 集成验证"]
|
||||
B2 --> G2
|
||||
|
||||
G2 --> subgraph "Wave 3 (2 agent 串行)"
|
||||
C1["Agent 6: T5 WorkingContext"]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wave 1:基础改造(4 agent 并行)
|
||||
|
||||
### Agent 1:T1 动态压缩阈值
|
||||
|
||||
**改动清单**:精确到行
|
||||
|
||||
| 文件 | 行 | 操作 |
|
||||
|------|----|------|
|
||||
| `src-tauri/src/commands/ai/agentic/context_lifecycle.rs` | 64 签名 | `maybe_auto_compress` 加 `sys_tokens: u32` 参数 |
|
||||
| 同上 | 88-92 触发条件 | `let budget` → `let available = budget.saturating_sub(sys_tokens)`;比较用 `available` 替代 `budget` |
|
||||
| `src-tauri/src/commands/ai/agentic/mod.rs` | ~1117 调用处 | `maybe_auto_compress(...)` 加 `sys_tokens` 实参(同作用域 line 964 已定义) |
|
||||
|
||||
**接口契约**:`maybe_auto_compress` 签名变更——调用方需传入 `sys_tokens`。不影响其他模块。
|
||||
|
||||
**验证**:`cargo check -p devflow`;观察日志 `available` 值在 system prompt 大时显著小于 `budget`。
|
||||
|
||||
**回退**:删参数 + 恢复 `budget` 变量,2 行。
|
||||
|
||||
---
|
||||
|
||||
### Agent 2:T2 工具命名空间存储
|
||||
|
||||
**接口契约**(Wave 1 必须先执行):
|
||||
|
||||
```rust
|
||||
// crates/df-ai/src/namespace_store.rs — 新增,其他 agent 依赖此接口
|
||||
pub struct NamespaceStore { .. }
|
||||
|
||||
impl NamespaceStore {
|
||||
pub fn new(max_bytes: usize) -> Self;
|
||||
pub fn store(&mut self, key: &str, tool_name: &str, args: &Value, content: &str) -> String;
|
||||
pub fn read(&mut self, full_path: &str) -> Option<&str>;
|
||||
pub fn read_summary(&self, full_path: &str) -> Option<&str>;
|
||||
}
|
||||
|
||||
pub fn should_use_namespace(content: &str, tool_name: &str) -> bool;
|
||||
pub const NAMESPACE_REF_PREFIX: &str = "namespace://";
|
||||
pub const NAMESPACE_BYTE_THRESHOLD: usize = 2048;
|
||||
```
|
||||
|
||||
**接线改动**:
|
||||
|
||||
| 文件 | 行 | 操作 |
|
||||
|------|----|------|
|
||||
| `crates/df-ai/src/namespace_store.rs` | 新增 | 完整实现 + 单元测试 |
|
||||
| `crates/df-ai/src/lib.rs` | — | `pub mod namespace_store;` + `pub use` 重导出 |
|
||||
| `crates/df-ai/src/context_helpers.rs` | — | 新增 `NAMESPACE_REF_PREFIX`、`should_use_namespace`、`is_namespace_ref` |
|
||||
| `src-tauri/src/commands/ai/audit/mod.rs` | `process_tool_calls` ~380 行 | tool 执行完成后:`if should_use_namespace` → `ns.store(...)` → push 引用;else → push 原文 |
|
||||
| `src-tauri/src/commands/ai/mod.rs` 或 `state.rs` | `AiSession` struct | 新增 `namespace_store: NamespaceStore` 字段 |
|
||||
| `src-tauri/src/commands/ai/conversation.rs` | `save_conversation` | namespace 引用展开为原文再写 DB |
|
||||
|
||||
**验证**:
|
||||
1. `cargo test -p df-ai` — `namespace_store` 单元测试(store → read → LRU 淘汰)
|
||||
2. 手动:`read_file("large.txt")` → DB 中 `tool_result` 内容为原文(引用已展开)
|
||||
3. `history_tokens` 统计应显著下降(大工具结果只计引用 token)
|
||||
|
||||
**与 Agent 3 的边界**:`namespace_store.rs` 实现不依赖 AiSession,纯数据结构的 `HashMap` 操作,零耦合,可独立开发和测试。接线到 AiSession 和 process_tool_calls 由本 agent 完成。
|
||||
|
||||
---
|
||||
|
||||
### Agent 3:接口契约定义(跨 Wave 共享)
|
||||
|
||||
定义 Wave 2/Wave 3 依赖的数据结构,**仅定义不实现**,确保下游 agent 可独立编码。
|
||||
|
||||
```rust
|
||||
// ── Wave 2 共享契约 ──
|
||||
|
||||
/// 压缩摘要双格式(T3 输出,T5 消费)
|
||||
pub struct CompressedSummary {
|
||||
pub json_card: String, // JSON 卡片(主题/决策/待办)
|
||||
pub nl_summary: String, // 自然语言摘要(向前兼容)
|
||||
}
|
||||
|
||||
// ── Wave 3 共享契约 ──
|
||||
|
||||
/// 工作流 DAG 上下文块(T4 输出 → 注入 system prompt)
|
||||
pub struct WorkflowContextBlock {
|
||||
pub workflow_id: String,
|
||||
pub workflow_name: String,
|
||||
pub current_node: Option<String>,
|
||||
pub completed: usize,
|
||||
pub total: usize,
|
||||
pub next_steps: Vec<String>,
|
||||
}
|
||||
|
||||
/// WorkingContext 常驻上下文(T5 实现)
|
||||
pub struct WorkingContext {
|
||||
pub goals: Vec<GoalItem>,
|
||||
pub decisions: Vec<DecisionItem>,
|
||||
pub unresolved: Vec<String>,
|
||||
pub current_step: Option<StepInfo>,
|
||||
pub version: WorkingContextVersion,
|
||||
}
|
||||
|
||||
pub enum GoalStatus { Active, Completed, Cancelled }
|
||||
pub struct GoalItem { pub id: String, pub text: String, pub status: GoalStatus }
|
||||
pub struct DecisionItem { pub question: String, pub decision: String, pub reason: String }
|
||||
pub struct StepInfo { pub step_name: String, pub status: StepStatus }
|
||||
pub enum StepStatus { Pending, InProgress, Done, Blocked }
|
||||
pub struct WorkingContextVersion {
|
||||
pub goals_changed_at: u32,
|
||||
pub decisions_changed_at: u32,
|
||||
pub unresolved_changed_at: u32,
|
||||
pub step_changed_at: u32,
|
||||
pub current_turn: u32,
|
||||
}
|
||||
```
|
||||
|
||||
**验证**:`cargo check` — 编译器验证所有 use 路径和类型引用正确,无需运行时测试。
|
||||
|
||||
**简化**:Agent 3 先把上述结构体定义放进 `crates/df-ai/src/context_helpers.rs`,`pub use` 重导出。Wave 2/3 的 agent 直接引用,不用等。
|
||||
|
||||
---
|
||||
|
||||
## Gate 1(Wave 1 → Wave 2)
|
||||
|
||||
```
|
||||
条件:
|
||||
□ cargo check --workspace 全部通过
|
||||
□ cargo test -p df-ai 全部通过
|
||||
□ cargo test -p devflow 全部通过(已有测试不受影响)
|
||||
|
||||
验证项:
|
||||
□ 日志: compress 触发时 available 减去 sys_tokens(T1)
|
||||
□ 手动: 大工具结果 DB 存原文(T2)
|
||||
□ 手动: namespace 引用格式正确(T2)
|
||||
|
||||
回滚:
|
||||
单文件回退: T1 删参数,T2 删 `namespace_store.rs` + 恢复 process_tool_calls 原文 push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wave 2:结构化改进(3 agent 并行)
|
||||
|
||||
### Agent 4:T3 L3 结构化摘要
|
||||
|
||||
**前置依赖**:Agent 3 定义了 `CompressedSummary` 结构
|
||||
|
||||
**改动清单**:
|
||||
|
||||
| 文件 | 行 | 操作 |
|
||||
|------|----|------|
|
||||
| `src-tauri/src/commands/ai/compress.rs` | 45-52 签名 | `compress_via_llm` 返回类型 `Result<String>` → `Result<CompressedSummary>` |
|
||||
| 同上 | 82-86 prompt 构造 | 改 `compress_prompt` 模板要求输出 JSON+NL 双格式 |
|
||||
| 同上 | 114-119 clean + 返回 | `clean_summary` 适配双格式:提取 JSON 段 + NL 段,分别验证退化 |
|
||||
| 同上 | 新增 | `extract_between` 辅助函数(提取 `<<<JSON>>>...<<<END_JSON>>>` 之间的内容) |
|
||||
| `src-tauri/src/commands/ai/prompt.rs` | 240-290 | `compress_prompt` 模板新增 JSON+NL 双格式输出要求 |
|
||||
| `src-tauri/src/commands/ai/agentic/context_lifecycle.rs` | 145-155 消费处 | `Ok(Some(summary))` 分支中 `insert_at` 内容改为双格式嵌入 |
|
||||
| 同上 178-210 兜底 | 关键词摘要降级 | 关键词降级不改,仍是纯文本(`keyword_fallback` 无 JSON) |
|
||||
|
||||
**验证**:
|
||||
1. `cargo test -p devflow` — compress 单测通过
|
||||
2. 手动:触发压缩 → DB 中 system 消息含 `<<<STRUCTURED_SUMMARY>>>` 标记
|
||||
3. 手动:关键词摘要降级路径仍为纯文本(不受影响)
|
||||
|
||||
**与 Agent 5 无依赖**:T3 只改 compress 路径,T4 只改 system prompt 注入,零耦合。
|
||||
|
||||
---
|
||||
|
||||
### Agent 5:T4 工作流 DAG 注入
|
||||
|
||||
**前置依赖**:Agent 3 定义了 `WorkflowContextBlock`,Agent 2 的 namespace 接口已就绪(弱依赖——仅用于产出关联)
|
||||
|
||||
**改动清单**:
|
||||
|
||||
| 文件 | 行 | 操作 |
|
||||
|------|----|------|
|
||||
| `src-tauri/src/commands/ai/workflow_context.rs` | 新增 | `load_dag()`, `build_active_path()`, `summarize_node()` |
|
||||
| `src-tauri/src/commands/ai/agentic/mod.rs` | ~1110-1128 | `build_for_request` 之前:检测 `conv.workflow_id` → 加载 DAG → 构建 context block → 注入 system prompt |
|
||||
| `crates/df-ai/src/context_helpers.rs` | 新增常量 | `WORKFLOW_DAG_MARKER` |
|
||||
|
||||
**核心逻辑**:
|
||||
|
||||
```rust
|
||||
// workflow_context.rs
|
||||
pub fn build_active_path(dag: &Dag, current_node_id: &str) -> WorkflowContextBlock {
|
||||
let path = dag.path_to_root(current_node_id); // 到根路径
|
||||
let next = dag.children(current_node_id, 2); // 后续 2 层
|
||||
WorkflowContextBlock {
|
||||
workflow_id: dag.id.clone(),
|
||||
workflow_name: dag.name.clone(),
|
||||
total_nodes: dag.nodes.len(),
|
||||
completed_nodes: dag.nodes.iter().filter(|n| n.status == "completed").count(),
|
||||
current_node: summarize_node(dag.get_node(current_node_id)),
|
||||
next_nodes: next.into_iter().map(|n| summarize_node(n)).collect(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**关键安全**:
|
||||
|
||||
```rust
|
||||
// mod.rs: system_prompt 注入处
|
||||
if let Some(wf_id) = &conv.workflow_id {
|
||||
if let Ok(dag) = df_workflow::Dag::load(&db, wf_id) {
|
||||
let block = build_active_path(&dag, &conv.current_node_id);
|
||||
system_prompt = format!("[工作流] {}\n{}", block.to_system_text(), system_prompt);
|
||||
}
|
||||
// Dag::load 失败 → 静默跳过
|
||||
}
|
||||
// workflow_id == None → 行为完全不变
|
||||
```
|
||||
|
||||
**验证**:
|
||||
1. `cargo check -p devflow` — 依赖 `df-workflow` crate 的 `Dag::load` 接口
|
||||
2. 手动:创建带工作流的对话 → system prompt 含 `[工作流]` 块
|
||||
3. 手动:自由对话(无 workflow)→ system prompt 不变
|
||||
|
||||
---
|
||||
|
||||
## Gate 2(Wave 2 → Wave 3)
|
||||
|
||||
```
|
||||
条件:
|
||||
□ cargo check --workspace 全部通过
|
||||
□ cargo test --workspace 全部通过
|
||||
□ 前端 vue-tsc --noEmit 通过
|
||||
|
||||
验证项:
|
||||
□ 手动: 压缩后 DB system 消息含 JSON 标记(T3)
|
||||
□ 手动: 工作流对话注入 DAG 块(T4)
|
||||
□ 手动: 自由对话不受影响(T4 安全)
|
||||
□ 全量: history_tokens 在 T2 基础上再次下降(T3 摘要更精简)
|
||||
|
||||
回滚:
|
||||
T3: 回退 compress.rs 签名 + prompt.rs 模板
|
||||
T4: 删 workflow_context.rs + 恢复 mod.rs system_prompt 拼接
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wave 3:常驻上下文(1 agent 串行)
|
||||
|
||||
### Agent 6:T5 WorkingContext 分层注入
|
||||
|
||||
**前置依赖**:
|
||||
|
||||
- Agent 3 的 `WorkingContext` 结构定义需在 `context_helpers.rs` 中(已在 Wave 1 Gate 前到位)
|
||||
- Agent 4 的 `CompressedSummary` 用于 `reset_all_with_summary`(弱依赖——无 NL 摘要时可跳过)
|
||||
|
||||
**改动清单**:
|
||||
|
||||
| 文件 | 行 | 操作 |
|
||||
|------|----|------|
|
||||
| `crates/df-ai/src/context_helpers.rs` | 已有定义 | `WorkingContext` / `WorkingContextVersion` 等已由 Agent 3 定义 |
|
||||
| `crates/df-ai/src/context/mod.rs` | `ContextManager` struct | 新增字段 `pub working_context: Option<WorkingContext>` |
|
||||
| `src-tauri/src/commands/ai/agentic/mod.rs` | ~960 system_prompt 拼接处 | 在 `build_for_request` 之前调 `WorkingContext::build_injection()` 注入 L1a+L1b |
|
||||
| 同上 | tool_registry 注册 | 注册 `read_context` 工具(L1c) |
|
||||
| `src-tauri/src/commands/ai/agentic/context_lifecycle.rs` | ~145-155 压缩成功后 | 调 `ctx.version.reset_all_with_summary(&summary)` |
|
||||
| `src-tauri/src/commands/ai/commands/chat.rs` | `ai_chat_clear_context` | 归档后 reset WorkingContext(归档 = 上下文状态变更) |
|
||||
|
||||
**脏标记核心逻辑**:
|
||||
|
||||
```rust
|
||||
impl WorkingContext {
|
||||
pub fn build_injection(&self, recent_turns: u32) -> String {
|
||||
let mut parts = vec![];
|
||||
|
||||
// L1a: 当前步骤(每轮必带,~200 tokens)
|
||||
if let Some(step) = &self.current_step {
|
||||
parts.push(format!("当前步骤: {}", step.step_name));
|
||||
}
|
||||
|
||||
// L1b: 过去 recent_turns 轮内有变更才注入
|
||||
let threshold = self.version.current_turn.saturating_sub(recent_turns);
|
||||
let has_active = self.goals.iter().any(|g| g.status == GoalStatus::Active);
|
||||
if has_active && self.version.goals_changed_at >= threshold {
|
||||
let active: Vec<_> = self.goals.iter()
|
||||
.filter(|g| g.status == GoalStatus::Active)
|
||||
.collect();
|
||||
parts.push(format!("目标({}): {}", active.len(),
|
||||
active.iter().map(|g| g.text.as_str()).collect::<Vec<_>>().join("; ")));
|
||||
}
|
||||
if self.version.unresolved_changed_at >= threshold && !self.unresolved.is_empty() {
|
||||
parts.push(format!("待解: {}", self.unresolved.join("; ")));
|
||||
}
|
||||
parts.join("\n")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**验证**:
|
||||
1. `cargo test -p df-ai` — `WorkingContext::build_injection` 单元测试(脏标记正确跳过/注入)
|
||||
2. 手动:观察 system prompt 含 L1a(当前步骤)和条件性的 L1b(有变更时)
|
||||
3. 手动:压缩触发后,下轮 L1b 正常注入(`reset_all` 生效)
|
||||
4. 开关:`GOAL_PIN_ENABLED=false`(或等价的 workincontext 开关)→ 行为恢复到改动前
|
||||
|
||||
---
|
||||
|
||||
## 汇总:执行序列
|
||||
|
||||
```
|
||||
Week 1:
|
||||
Day 1: Agent 3 定义契约(30 分钟) → 其余 3 agent 拿到接口开始编码
|
||||
Day 1-2: Agent 1 T1 + Agent 2 T2 并行编码
|
||||
Day 3: Gate 1 验证 + 修复
|
||||
|
||||
Week 2:
|
||||
Day 4-5: Agent 4 T3 + Agent 5 T4 并行编码
|
||||
Day 6: Gate 2 验证 + 修复
|
||||
|
||||
Week 3:
|
||||
Day 7-9: Agent 6 T5 编码(由于串行依赖,此阶段无并行)
|
||||
Day 10: 最终验证 + 全量回归测试
|
||||
```
|
||||
|
||||
## 回滚总策略
|
||||
|
||||
| 场景 | 操作 |
|
||||
|------|------|
|
||||
| T1 有问题 | 删 `sys_tokens` 参数 + 恢复 `budget` 变量 → 2 行回退 |
|
||||
| T2 有问题 | 删 `namespace_store.rs` + 恢复 `process_tool_calls` push 原文 → 1 文件回退 |
|
||||
| T3 有问题 | 回退 `compress.rs` + `prompt.rs` 到 `Result<String>` → 2 文件回退 |
|
||||
| T4 有问题 | 删 `workflow_context.rs` + 恢复 mod.rs system_prompt 拼接 → 1 文件回退 |
|
||||
| T5 有问题 | `GOAL_PIN_ENABLED=false` 等效开关 → 配置回退,不改代码 |
|
||||
| 全量回退 | `git revert <commit-hash>` |
|
||||
@@ -0,0 +1,882 @@
|
||||
# 上下文管理:现状评估、业界对标与发散演进
|
||||
|
||||
> 创建: 2026-07-20 | 状态: 构想审查 | 关联: F-15 上下文管理增强设计
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [一、当前 ContextManager 评估](#一当前-contextmanager-评估)
|
||||
- [二、业界方案对标](#二业界方案对标)
|
||||
- [三、融合设计:结构化分层上下文引擎](#三融合设计结构化分层上下文引擎)
|
||||
- [四、3 个近期改进的推演验证](#四3-个近期改进的推演验证)
|
||||
- [五、7 个发散方向](#五7-个发散方向)
|
||||
- [六、发散组合分析与冲突矩阵](#六发散组合分析与冲突矩阵)
|
||||
- [七、推荐路线](#七推荐路线)
|
||||
|
||||
---
|
||||
|
||||
## 一、当前 ContextManager 评估
|
||||
|
||||
### 核心架构
|
||||
|
||||
```
|
||||
build_for_request → 返回裁剪后视图(影响 LLM 上下文)
|
||||
all_messages_clone → 返回全量(影响 DB 存储, save_conversation)
|
||||
```
|
||||
|
||||
| 层级 | 组件 | 文件 |
|
||||
|------|------|------|
|
||||
| Token 估算 | `TokenEstimator` — chars×0.35 + 消息开销 | `context_helpers.rs:28` |
|
||||
| 窗口配置 | `ContextConfig` — max_tokens/output_reserve/safety_ratio | `context_helpers.rs:105` |
|
||||
| 消息管理 | `ContextManager` — 消息队列 + token 缓存 | `context/mod.rs:44` |
|
||||
| 自愈 | `sanitize模块` — 畸形配对过滤 + 序列合法性修复 | `context/sanitize.rs` |
|
||||
| 自动压缩 | `maybe_auto_compress` — F-15 自动触发 | `context_lifecycle.rs:64` |
|
||||
|
||||
### 三维评分
|
||||
|
||||
| 维度 | 评分 | 核心论据 |
|
||||
|------|------|---------|
|
||||
| **效率** | ⭐⭐⭐⭐ (8/10) | 核心路径零拷贝裁剪,O(n) 反向扫描查找,幂等压缩。瓶颈在 TokenEstimator 精度和压缩同步等待 |
|
||||
| **合理性** | ⭐⭐⭐⭐⭐ (9/10) | 三元组原子性、保护区、视图/持久化分离、畸形自愈——近乎工业级完整。略缺跨会话上下文 |
|
||||
| **成本** | ⭐⭐⭐⭐ (8/10) | 预算池 + 流式记录 + 关键词兜底形成三级防线。缺乏自动模型降级链和长时间会话自动分段 |
|
||||
|
||||
### 已知问题
|
||||
|
||||
1. **TokenEstimator 对所有字符一视同仁** — 中文(~1.8-2.5 tok/char)被低估,英文(~0.2-0.3)被高估。当前靠 `safety_ratio: 0.85` 和 `output_reserve: 8192` 补误差
|
||||
2. **压缩阈值硬编码 0.6** — 不考 system prompt 大小,大量工具结果时可能误判
|
||||
3. **无自动会话分段** — `archived_segment` 状态和 IPC 已就绪,但只能手动触发
|
||||
|
||||
---
|
||||
|
||||
## 二、业界方案对标
|
||||
|
||||
### 2.1 与 7 条行业公认合理标准对标
|
||||
|
||||
| # | 行业标准 | DevFlow 现状 | 差距 |
|
||||
|---|---------|-------------|------|
|
||||
| 1 | **运行时/LLM 上下文双层隔离** | 有部分隔离但不彻底。无类型层面的强制边界 | ⚠️ 概念上有但无强制 |
|
||||
| 2 | **分层记忆(瞬时→短期→长期)** | 只有「内存窗口 ↔ DB」两层,缺短期缓存层和向量检索层 | ⚠️ 有分层但缺中间层 |
|
||||
| 3 | **Token 预算管控** | `TokenEstimator` + `budget_limit` + 0.6 水位 + 降级关键词兜底 | ✅ 业界中上 |
|
||||
| 4 | **信息加权裁剪** | `PROTECT_COUNT=6` 保近期,但裁剪是纯位置滑动,不是按价值加权 | ⚠️ 机制粗糙 |
|
||||
| 5 | **命名空间隔离** | `conv_id` 做 Session 级隔离,无 User/Agent 维度 | ⚠️ 缺维度 |
|
||||
| 6 | **结构化沉淀** | 压缩摘要为纯文本,非结构化数据 | ❌ 文本摘要非结构化 |
|
||||
| 7 | **可持久快照** | DB 保留全量,但无版本概念,不可回滚 | ❌ 有持久无快照 |
|
||||
|
||||
### 2.2 具体方案比较
|
||||
|
||||
| 方案 | 核心优势 | 对 DevFlow 的参考价值 |
|
||||
|------|---------|---------------------|
|
||||
| **Claude Code 三层加载** | 常驻索引 + 按需主题 + 离线归档 | 当前 `pinned_goals` → 扩展为更丰富的 WorkingContext |
|
||||
| **OpenAI Agents SDK RunContext** | 运行时状态不进 prompt | 当前 AiSession 概念上有但无强制边界 |
|
||||
| **Mem0** | User/Session/Agent 三维命名空间 | 未来多租户/跨会话复用的架构参考 |
|
||||
| **Letta (MemGPT)** | 虚拟内存分页,模型自主调度 | 哲学不同:Letta 模型自主换页,DevFlow 系统强制压缩 |
|
||||
| **TencentDB Agent Memory** | 结构化任务画布替代文本历史 | **最有参考价值** — 与 df-workflow + df-nodes 天然契合 |
|
||||
| **LangGraph** | 可持久化 State + Checkpoint | 版本化快照模式参考 |
|
||||
| **AutoContextMemory** | 6 档阶梯式上下文管控 | 信息加权裁剪参考 |
|
||||
|
||||
### 2.3 核心启发
|
||||
|
||||
```
|
||||
TencentDB 任务画布思路 → 结构化卡片替代纯文本摘要
|
||||
↓
|
||||
WorkingContext 常驻 → 结构化目标/决策/步骤替代纯文本 pinned_goals
|
||||
↓
|
||||
MemoryAdapter trait → 插件化记忆层,不绑定 Vec<TrackedMessage>
|
||||
↓
|
||||
版本化快照 → 从覆盖写改为追加 checkpoint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、融合设计:结构化分层上下文引擎
|
||||
|
||||
### 3.1 架构总览
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph LLM 上下文窗口 (build_for_request 输出)
|
||||
L1["L1 常驻上下文<br>(永不裁剪,结构化 system 消息)"]
|
||||
L2["L2 活跃历史<br>(窗口滑动淘汰,user/assistant/tool)"]
|
||||
L3["L3 结构化摘要<br>(压缩/归档后锚点,JSON 卡片)"]
|
||||
end
|
||||
|
||||
subgraph 本地运行时状态 (不进 LLM)
|
||||
R1["原始消息全量 (SQLite DB)"]
|
||||
R2["工具执行日志 (分级缓存)"]
|
||||
R3["向量记忆索引 (df-knowledge)"]
|
||||
R4["工作流 DAG (df-workflow)"]
|
||||
end
|
||||
|
||||
L1 -->|注入 system| LLM["LLM 请求"]
|
||||
L2 -->|阈值超限| L3
|
||||
L3 -->|检索回溯| R1
|
||||
L3 -->|语义检索| R3
|
||||
|
||||
style L1 fill:#c8e6c9
|
||||
style L2 fill:#fff9c4
|
||||
style L3 fill:#bbdefb
|
||||
style R1 fill:#f5f5f5
|
||||
style R2 fill:#f5f5f5
|
||||
style R3 fill:#f5f5f5
|
||||
style R4 fill:#f5f5f5
|
||||
```
|
||||
|
||||
### 3.2 L1 常驻上下文:WorkingContext
|
||||
|
||||
融合 Claude Code 常驻索引 + DevFlow 现有 `pinned_goals` + Decision Journal:
|
||||
|
||||
```rust
|
||||
pub struct WorkingContext {
|
||||
pub goals: Vec<GoalItem>,
|
||||
pub decisions: Vec<DecisionItem>,
|
||||
pub unresolved: Vec<String>,
|
||||
pub current_step: Option<StepInfo>,
|
||||
pub key_artifacts: Vec<ArtifactRef>,
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 L1 注入策略:分层注入(非全量常驻)
|
||||
|
||||
`dirty_window_turns` 控制脏标记的可见窗口——只有最近 N 轮内有变更的字段才注入 system prompt。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "L1a 高频常驻 (~200 tokens)"
|
||||
A1["当前步骤 current_step"]
|
||||
end
|
||||
subgraph "L1b 条件注入 (~200-800 tokens)"
|
||||
B1["活跃目标 — dirty_window 内变更才注入"]
|
||||
B2["未决项 — dirty_window 内变更才注入"]
|
||||
end
|
||||
subgraph "L1c 工具按需"
|
||||
C1["完整决策历史 → read_context 工具"]
|
||||
end
|
||||
|
||||
A1 -->|每轮必带| LLM
|
||||
B1 -->|变更在窗口内| LLM
|
||||
B2 -->|变更在窗口内| LLM
|
||||
C1 -->|模型主动调用| LLM
|
||||
```
|
||||
|
||||
### 3.4 压缩联动(关键补丁)
|
||||
|
||||
压缩事件发生后,`reset_all_with_summary` 将所有脏标记重置到当前轮次,保证压缩后下轮 L1b 全量注入,补偿被压缩丢失的历史信息:
|
||||
|
||||
```rust
|
||||
// context_lifecycle.rs: 压缩成功后
|
||||
conv.messages.compress_old_messages(protect_start);
|
||||
conv.messages.insert_at(0, ChatMessage::system(&summary));
|
||||
|
||||
// 压缩后重置脏窗口
|
||||
conv.working_context.version.reset_all_with_summary(&summary, current_turn);
|
||||
```
|
||||
|
||||
### 3.5 L3 结构化摘要卡片
|
||||
|
||||
将压缩输出的纯文本摘要改为 JSON + NL 双格式:
|
||||
|
||||
```
|
||||
当前: system: "用户讨论了审批流程,确认了超时参数改为 30 分钟"
|
||||
|
||||
改进后: system: <<<STRUCTURED_SUMMARY>>>
|
||||
{
|
||||
"type": "conversation_segment",
|
||||
"topics": ["审批超时设置"],
|
||||
"decisions": [
|
||||
{"what": "超时改为 30 分钟", "why": "用户反馈 15 分钟太短"}
|
||||
],
|
||||
"unresolved": ["推送方式待定"],
|
||||
"token_saved": 45200
|
||||
}
|
||||
<<<END_STRUCTURED_SUMMARY>>>
|
||||
自然语言摘要: 用户讨论了审批流程,确认了超时参数改为 30 分钟
|
||||
```
|
||||
|
||||
### 3.6 信息加权裁剪
|
||||
|
||||
当前 `build_eviction_units` 按消息位置淘汰,改为按消息类型赋予保留权重:
|
||||
|
||||
| 消息角色 | 保留权重 | 理由 |
|
||||
|---------|---------|------|
|
||||
| User(短指令) | 100 | 高价值,优先保留 |
|
||||
| User(长消息) | 70 | 中等 |
|
||||
| Assistant(含 tool_calls) | 50 | 工具调用链需要保留 |
|
||||
| Assistant(纯文本) | 40 | 一般 |
|
||||
| Tool(工具结果) | 20 | 低价值,优先淘汰 |
|
||||
|
||||
---
|
||||
|
||||
## 四、3 个近期改进的推演验证
|
||||
|
||||
### 4.1 改进 1:TokenEstimator 按字符类型加权
|
||||
|
||||
**初始提议**:中文×1.8、英文×0.25、数字×0.20 等混合加权。
|
||||
|
||||
**推演结论**:**不做。** 理由:
|
||||
|
||||
1. 当前 `safety_ratio: 0.85` + `output_reserve: 8192` 提供了约 27k tokens 的余量,中英混合对话要超界需要极端场景
|
||||
2. 真正该修的入口不在 TokenEstimator:tool_result 膨胀应由 `extract_key_info` 在工具执行阶段削减,不是靠 TokenEstimator 精度来兜底
|
||||
3. 改加权逻辑有回归风险(content / parts / tool_calls / tool_call_id 多处调用了 `chars_ratio`)
|
||||
|
||||
### 4.2 改进 2:动态压缩阈值
|
||||
|
||||
**初始提议**:将 `budget * 6 / 10` 改为 `available * 6 / 10`(available = budget - sys_tokens)。
|
||||
|
||||
**推演结论**:**做。** 3 行改动,效果明确:
|
||||
|
||||
```rust
|
||||
// 当前
|
||||
let should = (budget as u64) * 6 / 10 < history_tokens as u64;
|
||||
|
||||
// 改为
|
||||
let available = mgr.budget_limit().saturating_sub(sys_tokens);
|
||||
let should = (available as u64) * 6 / 10 < history_tokens as u64;
|
||||
```
|
||||
|
||||
`sys_tokens` 在 `maybe_auto_compress` 调用处已由调用方算好(loop 顶部传给 `build_for_request`),透传即可。
|
||||
|
||||
### 4.3 改进 3:长时间会话自动存档
|
||||
|
||||
**初始提议**:达到压缩次数或消息总量阈值后自动触发 `archived_segment`。
|
||||
|
||||
**推演结论**:**暂缓。** 基础设施已就绪(`archived_segment` 状态、IPC `ai_chat_clear_context`、前端 `AiContextCleared` 事件),但:
|
||||
|
||||
1. 极长对话(>300 条)才需要,日常场景用不着
|
||||
2. 归档后压缩摘要与归档摘要的关系需要先理清(归档时会把之前的压缩摘要也一并标记 archived_segment,确保不产生孤立 system 消息)
|
||||
3. 建议 Phase 4/5 再做
|
||||
|
||||
### 4.4 优先级
|
||||
|
||||
| 排序 | 项 | 改动量 | 收益 | 建议 |
|
||||
|------|----|--------|------|------|
|
||||
| 🥇 | **2a 动态阈值** | 3 行 | 减少误压缩和漏压缩 | **立即做** |
|
||||
| 🥈 | 1 TokenEstimator | 多文件 | 被 safety_ratio 兜住 | **不做** |
|
||||
| 🥉 | 3 自动存档 | ~50 行 | 场景不迫切 | **Phase 4/5** |
|
||||
|
||||
---
|
||||
|
||||
## 五、7 个发散方向
|
||||
|
||||
### 发散①:画布导航 — 模型自主控制视野
|
||||
|
||||
**核心思想**:不再把上下文拼成平铺消息队列丢给模型,而是组织成多维画布,模型通过 `look_at` 工具自主导航:
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "look_at",
|
||||
"args": { "zone": "L1/decisions", "expand": true }
|
||||
}
|
||||
```
|
||||
|
||||
**差异**:窗口永远不会满(不需要装全部),压缩不存在(没有"全量传输"的概念)。模型控制视野范围。
|
||||
|
||||
**代价**:需要模型主动使用 `look_at`,模型不用时需降级回传统模式。新协议,高实现成本。
|
||||
|
||||
### 发散②:上下文版本分支 — 决策回溯可导航
|
||||
|
||||
**核心思想**:支持分支与合并上下文。`ContextManager` 加 `parent: Option<String>` 和 `fork_point: usize`:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[方案 A] --> B[继续 A]
|
||||
A --> C[方案 B]
|
||||
C --> D[发现 B 不可行]
|
||||
D --> E[合并回到 A]
|
||||
B --> E
|
||||
```
|
||||
|
||||
**差异**:放弃的方案上下文不会被新会话污染,可随时回溯精确内容。
|
||||
|
||||
**代价**:存储复杂度——N 个分支 = N× 内存。
|
||||
|
||||
### 发散③:主动参与 — 系统在对话中插话
|
||||
|
||||
**核心思想**:上下文管理器在 agentic loop 中拥有系统消息通道,可以在检测到模式时主动"说话":
|
||||
|
||||
```
|
||||
触发条件示例:
|
||||
连续 N 轮使用同一工具 → 建议创建快捷方式
|
||||
修改 A 后 5 轮内又修改 B → 建议关联工作流
|
||||
目标活跃但 N 轮无推进 → 提醒调整目标
|
||||
```
|
||||
|
||||
**差异**:从"你问它答"到"AI 主动观察并建议"——人机交互范式变化。
|
||||
|
||||
**代价**:冷却期管理和负面反馈机制是必需的,否则必然变成骚扰。
|
||||
|
||||
### 发散④:时间轴衰减 — 不裁不压,让信息连续"模糊"
|
||||
|
||||
**核心思想**:每条消息的精度随轮次连续衰减,而非在某一轮突然被压缩:
|
||||
|
||||
```rust
|
||||
pub struct TrackedMessage {
|
||||
pub message: ChatMessage,
|
||||
pub precision: f32, // 1.0=精确 → 0.0=完全模糊
|
||||
pub last_accessed: u32, // 最后被 LLM 读取的轮次
|
||||
}
|
||||
```
|
||||
|
||||
**差异**:压缩不是在某一轮突然发生的,而是每轮都在发生——精度衰减是连续的。
|
||||
|
||||
**代价**:需要新的衰减算法和精度阈值判断。与当前离散三态模型不兼容。
|
||||
|
||||
### 发散⑤:元控制器 — 上下文管理策略自感知
|
||||
|
||||
**核心思想**:把 `safety_ratio`、压缩阈值、`PROTECT_COUNT` 等硬编码常量变为动态自适应参数:
|
||||
|
||||
```rust
|
||||
pub struct MetaContextController {
|
||||
features: VecDeque<WindowFeature>, // 会话特征滑动窗口
|
||||
recommended: ContextConfig, // 当前推荐配置
|
||||
}
|
||||
```
|
||||
|
||||
**差异**:无需手调参数,系统在 5-10 轮后自动收敛到适合当前会话的策略。
|
||||
|
||||
**代价**:元控制器自己的参数(特征窗口大小、步长)也需要调——递归问题。
|
||||
|
||||
### 发散⑥:工作流即上下文 — DAG 作为上下文主干
|
||||
|
||||
**核心思想**:上下文就是当前工作流的 DAG 视图,消息按工作流节点组织,而非按时间排列:
|
||||
|
||||
```
|
||||
当前: [user][asst][tool][user][asst]... → 时间线
|
||||
改进: [节点1(完成)] [节点2(当前)] [节点3(待办)] → DAG
|
||||
```
|
||||
|
||||
**差异**:DevFlow 是唯一同时拥有工作流引擎和 AI 对话的产品,这种融合是**差异化最大的方向**。
|
||||
|
||||
**代价**:需要工作流与对话关联,自由对话不适用。
|
||||
|
||||
### 发散⑦:工具命名空间 — 工具结果不进主队列
|
||||
|
||||
**核心思想**:工具的执行结果不返回主消息队列,而是存入专属的 namespace:
|
||||
|
||||
```
|
||||
主队列:
|
||||
[asst] 正在读取 config.rs...
|
||||
[tool_result(namespace://read/config.rs)] ← 仅 20 tokens 的引用
|
||||
|
||||
工具工作区:
|
||||
namespace://read/config.rs → 342 行代码(按需读取,不占窗口)
|
||||
```
|
||||
|
||||
**差异**:大工具结果膨胀是当前压缩触发的主因,这直接消除膨胀源。
|
||||
|
||||
**代价**:需要 namespace 存储引擎 + 模型引用协议。工具执行路径不变,路由从 push messages 改为 push reference。
|
||||
|
||||
---
|
||||
|
||||
## 六、发散组合分析与冲突矩阵
|
||||
|
||||
### 6.1 内部不可调和冲突
|
||||
|
||||
#### 冲突 1:画布(①) vs 批传输(当前 Design + ②③④⑤⑥⑦)
|
||||
|
||||
```
|
||||
画布模式要求: 模型自主注视 → 增量读取 → 窗口永不装满
|
||||
批传输模式要求: 系统全量拼装 → 一次发给模型 → 压缩/裁剪管理窗口
|
||||
|
||||
当①启用时,压缩、裁剪、budget_limit 的假设前提不再成立——
|
||||
窗口装的不是全量消息,而是画布坐标系。
|
||||
```
|
||||
|
||||
#### 冲突 2:主动参与(③) vs 画布导航(①)
|
||||
|
||||
```
|
||||
①: 模型是自主的,系统是服务者
|
||||
③: 系统有更高优先级的信息需要模型注意
|
||||
|
||||
模型正在注视 L2,系统突然插话——注意力被打破。
|
||||
```
|
||||
|
||||
#### 冲突 3:时间轴衰减(④) vs 分支(②) vs 工作流(⑥)
|
||||
|
||||
三者对"什么是上下文的主要信息载体"的回答不同:
|
||||
|
||||
```
|
||||
④: 消息是主要载体 → 精度衰减作用于消息
|
||||
⑥: 工作流节点是主要载体 → 消息附着在节点上
|
||||
②: 分支是主要载体 → 消息在分支的线性时间线上
|
||||
|
||||
一条消息同时有精度值(④)+ 分支ID(②)+ 工作流节点ID(⑥)→
|
||||
三个矛盾的淘汰决策信号同时作用 → 需要额外仲裁逻辑
|
||||
```
|
||||
|
||||
### 6.2 组合爆炸
|
||||
|
||||
7 个发散引入至少 26 个新参数;状态空间约 3^7 ≈ 2187 种(无法穷举测试)。
|
||||
|
||||
### 6.3 三个互斥派系
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "派系 A: 工作流结构化 (推荐首做)"
|
||||
A6["⑥工作流即上下文"]
|
||||
A7["⑦工具命名空间"]
|
||||
A5["⑤元控制器 (精简版)"]
|
||||
A_RESULT["输出: 工作流DAG + 命名空间引用 替代平铺消息"]
|
||||
end
|
||||
|
||||
subgraph "派系 B: 记忆系统"
|
||||
B2["②上下文分支"]
|
||||
B4["④时间轴衰减"]
|
||||
B5["⑤元控制器"]
|
||||
B_RESULT["输出: 分支记忆 + 连续遗忘 替代离散压缩"]
|
||||
end
|
||||
|
||||
subgraph "派系 C: 交互范式"
|
||||
C1["①画布导航"]
|
||||
C3["③主动参与"]
|
||||
C5["⑤元控制器"]
|
||||
C_RESULT["输出: 自主模型 + 系统辅助 替代命令式对话"]
|
||||
end
|
||||
|
||||
A6 ---|不兼容| B2
|
||||
B4 ---|兼容| C1
|
||||
C3 ---|不兼容| C1
|
||||
```
|
||||
|
||||
| 派系 | 核心哲学 | 适合场景 | 与当前 Design 衔接成本 |
|
||||
|------|---------|---------|----------------------|
|
||||
| **A 工作流结构化** | 把上下文组织成 DAG | 有明确工作流的任务 | **低**(复用 df-workflow) |
|
||||
| **B 记忆系统** | 让上下文更像人脑记忆 | 超长对话、研究型任务 | 中(新算法 + 新状态) |
|
||||
| **C 交互范式** | 重新定义人机交互 | 高级用户、复杂决策 | 高(改变协议) |
|
||||
|
||||
### 6.4 双发散的乘数效应
|
||||
|
||||
| 组合 | 收益等级 | 价值 |
|
||||
|------|---------|------|
|
||||
| **⑥+⑦** | 🔥🔥🔥 | 工作流节点引用 namespace 路径,精确回溯无需重查 |
|
||||
| **①+②** | 🔥🔥 | 画布让 N 个分支的内存成本从 O(N) 降到 O(1) |
|
||||
| **③+⑤** | 🔥🔥🔥 | 没有⑤,③是骚扰;没有③,⑤的优化用户感觉不到 |
|
||||
| **④+⑦** | 🔥 | namespace 引用增加置信度信号 |
|
||||
| **①+②+⑥+⑦** | 🏆 | 最完整的中断恢复路径 |
|
||||
| **③+⑤+⑥** | 🔥🔥 | 工作流阻塞时智能主动提示 |
|
||||
|
||||
### 6.5 对「全组合」的结论
|
||||
|
||||
> **技术上可以,但架构上存在 3 对不可调和冲突、26+ 新参数、2187+ 状态空间。不可控。** 不推荐全组合。
|
||||
|
||||
| 维度 | 答案 |
|
||||
|------|------|
|
||||
| **技术上可行吗** | 可以,但需要全新架构,复杂度膨胀约 10 倍 |
|
||||
| **测试覆盖可行吗** | 不可行。2187 种组合状态 → 实际覆盖不到 5% |
|
||||
| **维护成本可接受吗** | 不可接受。团队需同时理解 7 个系统的交互 |
|
||||
| **价值可叠加吗** | 边际递减。②+④ 已覆盖 T-fork+T-resume 主要痛点 |
|
||||
| **更优路径** | **选派系 A 做深,接口预留 B 和 C** |
|
||||
|
||||
---
|
||||
|
||||
## 七、推荐路线
|
||||
|
||||
### 选派系 A(工作流结构化)为主干,预留接口
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Phase 4-5"
|
||||
A6["⑥ 工作流即上下文"]
|
||||
A7["⑦ 工具命名空间"]
|
||||
A5["⑤ 元控制器 (精简版: 仅调 3-5 个参数)"]
|
||||
COMBINED["A 组构成新 ContextManager"]
|
||||
end
|
||||
|
||||
subgraph "预留接口 (不实现)"
|
||||
IF1["Trait: ContextNavigation<br>(供①画布)"]
|
||||
IF2["Trait: ContextFork<br>(供②分支)"]
|
||||
IF3["Trait: ProactiveHint<br>(供③主动参与)"]
|
||||
IF4["Trait: PrecisionDecay<br>(供④衰减)"]
|
||||
end
|
||||
|
||||
A6 --> COMBINED
|
||||
A7 --> COMBINED
|
||||
A5 --> COMBINED
|
||||
COMBINED --> IF1
|
||||
COMBINED --> IF2
|
||||
COMBINED --> IF3
|
||||
COMBINED --> IF4
|
||||
```
|
||||
|
||||
### 实施步骤
|
||||
|
||||
| 步 | 内容 | 改造成本 | 用户可见收益 | 阶段 |
|
||||
|----|------|---------|------------|------|
|
||||
| 0 | **动态压缩阈值** — 将 sys_tokens 纳入 | 3 行 | 减少误/漏压缩 | 立即 |
|
||||
| 1 | **L3 结构化摘要** — JSON 卡片替代纯文本 | ~100 行 | 压缩后上下文质量可测提升 | Phase 4 |
|
||||
| 2 | **⑦ 工具命名空间** — 大结果不进主队列 | ~300 行 | tool_result 膨胀消除 | Phase 4 |
|
||||
| 3 | **⑥ 工作流 DAG 注入** — 消息按节点组织 | ~500 行 | 工作流感知的精确回溯 | Phase 4 |
|
||||
| 4 | **L1 WorkingContext + 分层注入** — 结构化常驻 | ~400 行 | 长对话目标保持 | Phase 5 |
|
||||
| 5 | **MemoryAdapter trait 提取** — 重构 | ~200 行 | 零(纯重构) | Phase 5 |
|
||||
| 6 | **版本化快照** — checkpoint 追加写 | ~150 行 | 断点恢复 | Phase 5 |
|
||||
|
||||
**接口预留**(不实现,仅定义 trait):
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait ContextNavigation { /* 供①画布实现 */ }
|
||||
#[async_trait]
|
||||
pub trait ContextFork { /* 供②分支实现 */ }
|
||||
#[async_trait]
|
||||
pub trait ProactiveHint { /* 供③主动参与实现 */ }
|
||||
#[async_trait]
|
||||
pub trait PrecisionDecay { /* 供④衰减实现 */ }
|
||||
```
|
||||
|
||||
### 一句话总结
|
||||
|
||||
> **做 ⑥+⑦ 结构化工作流上下文,留接口给记忆和交互范式,不做全组合。**
|
||||
|
||||
---
|
||||
|
||||
## 八、多方法交叉论证
|
||||
|
||||
> 本章用 7 种独立方法对同一组选项交叉验证。结论收敛到同一方向则信心高,出现分歧则标注并分析原因。
|
||||
|
||||
### 8.1 方法概览
|
||||
|
||||
| # | 方法 | 分析对象 | 输出 | 与已有分析的关系 |
|
||||
|---|------|---------|------|-----------------|
|
||||
| A | **决策矩阵** | 3 个派系 | 加权总分排序 | 把第 6 章定性结论量化 |
|
||||
| B | **成本收益分析** | 7 个发散 | 成本/收益散点图 | 补充第 5 章缺失的量化维度 |
|
||||
| C | **ROI 测算** | 派系 A 实施步 | 美元/月节省 vs 开发改动单元 | 把第 7 章路线图加上财务回报 |
|
||||
| D | **影响范围分析** | 7 个发散 | 波及文件 / 子系统热力图 | 补充实现成本的具体依据 |
|
||||
| E | **依赖图 & 拓扑排序** | 派系 A 实施步 | 前置条件图 | 验证第 7 章步骤顺序的合理性 |
|
||||
| F | **风险矩阵** | 7 个发散 | 概率×影响热力图 | 补充第 6 章冲突分析的失败模式 |
|
||||
| G | **根本原因回溯** | 7 个发散 → 原始痛点 | 覆盖度矩阵 | 从问题端验证发散是否对症 |
|
||||
| H | **帕累托分析** | 派系 A 实施步 | 累积收益曲线 | 找出 20% 努力得 80% 收益的关键步 |
|
||||
| I | **Kano 模型** | 7 个发散 | 用户满意度分类 | 从用户感知角度验证优先级 |
|
||||
|
||||
---
|
||||
|
||||
### 8.2 方法 A:决策矩阵
|
||||
|
||||
对 3 个派系在 6 个加权维度上打分(1-5)。权重表示该维度对 DevFlow 当前阶段的重要性。
|
||||
|
||||
| 维度 | 权重 | 派系 A(工作流结构化) | 派系 B(记忆系统) | 派系 C(交互范式) | 权重理由 |
|
||||
|------|------|---------------------|------------------|------------------|---------|
|
||||
| 贴合现有资产 | **5** | 5 — 复用 df-workflow + ToolRegistry | 2 — 全新记忆层 | 1 — 需改协议 | Phase 4 优先利用已有代码 |
|
||||
| 用户可见收益 | **4** | 5 — 压缩后仍可回溯精确行号 | 4 — 长对话不失忆 | 3 — 高级用户才感知 | 需要让用户感受到变化 |
|
||||
| 实现成本 | **4** | 4 — 增量改造(~900 行) | 2 — 新状态+新算法 | 1 — 新协议+新交互 | 资源有限,成本敏感 |
|
||||
| 测试覆盖度 | **3** | 4 — 可在现有单测基础上加 | 2 — 新算法需要全新测试 | 2 — 新协议需要集成测试 | 质量保障成本 |
|
||||
| 长期扩展性 | **3** | 5 — 为 B/C 预留接口 | 3 — 自成体系 | 3 — 自成体系 | 不堵死未来路径 |
|
||||
| 风险可控度 | **4** | 5 — 增量上线,可回退 | 3 — 新状态机,迁移风险 | 2 — 模型配合度不确定 | 线上稳定性 |
|
||||
|
||||
**加权总分**:
|
||||
|
||||
```
|
||||
派系 A = 5×5 + 4×5 + 4×4 + 3×4 + 3×5 + 4×5 = 25 + 20 + 16 + 12 + 15 + 20 = 108
|
||||
派系 B = 5×2 + 4×4 + 4×2 + 3×2 + 3×3 + 4×3 = 10 + 16 + 8 + 6 + 9 + 12 = 61
|
||||
派系 C = 5×1 + 4×3 + 4×1 + 3×2 + 3×3 + 4×2 = 5 + 12 + 4 + 6 + 9 + 8 = 44
|
||||
```
|
||||
|
||||
**结论**:派系 A 以 108 分大幅领先。差距主要在「贴合现有资产」(权重最高)和「实现成本」两个维度——这正是 Phase 4 阶段的核心约束。
|
||||
|
||||
---
|
||||
|
||||
### 8.3 方法 B:成本收益散点图
|
||||
|
||||
对 7 个发散方向估算实施成本(改动单元)和预期收益(用户可见改善度 1-10):
|
||||
|
||||
| 发散 | 实施成本(改动单元) | 收益(1-10) | 性价比 | 说明 |
|
||||
|------|----------------|-------------|--------|------|
|
||||
| ①画布导航 | 25-35 | 6 | 低 | 新传输协议 + 模型配合训练 |
|
||||
| ②分支 | 15-20 | 7 | 中 | 新数据结构 + 状态机 |
|
||||
| ③主动参与 | 10-15 | 5 | 中 | 需要用户行为研究 |
|
||||
| ④时间轴衰减 | 12-18 | 4 | 低 | 与现有三态模型不兼容 |
|
||||
| ⑤元控制器 | 8-12 | 6 | **高** | 改配置参数即可,收益面广 |
|
||||
| ⑥工作流即上下文 | 10-15 | 9 | **最高** | 复用 df-workflow,DAG 注入 |
|
||||
| ⑦工具命名空间 | 5-8 | 8 | **最高** | 直接消除 tool_result 膨胀根因 |
|
||||
|
||||
```
|
||||
收益
|
||||
10│ 🚩⑥
|
||||
9│ 🚩⑦
|
||||
8│
|
||||
7│ ②
|
||||
6│ ① ⑤
|
||||
5│ ③
|
||||
4│ ④
|
||||
3│
|
||||
2│
|
||||
1│
|
||||
└──────────────────────────▶ 成本(改动单元)
|
||||
5 10 15 20 25 30
|
||||
```
|
||||
|
||||
**结论**:⑥+⑦ 落在「低成本、高收益」象限(左上),⑤ 居中,②③ 在中成本区,①④ 在右下象限(不推荐)。
|
||||
|
||||
---
|
||||
|
||||
### 8.4 方法 C:ROI 测算
|
||||
|
||||
以典型高频用户(日均 30 会话,每会话 20 轮,使用 Claude Sonnet $3/M input tokens)为基准,测算派系 A 实施后的成本节省:
|
||||
|
||||
| 实施步 | 节省机制 | 单会话节省 | 月节省(30 日) | 实施改动单元 | ROI(月节省/改动) |
|
||||
|--------|---------|-----------|---------------|---------|-------------------|
|
||||
| **步 0:动态压缩阈值** | 减少误压缩 ≈ 10% 不必要的压缩 LLM 调用 | ~$0.01 | **~$9** | 0.5 | **$18/改动单元** |
|
||||
| **步 1:L3 结构化摘要** | LLM 压缩时产出 JSON 摘要,后续检索少一轮 tool call | ~$0.02 | **~$18** | 2 | **$9/改动单元** |
|
||||
| **步 2:工具命名空间** | 大 tool_result 不进主队列,输入减少 30-50% | ~$0.08 | **~$72** | 5 | **$14.4/改动单元** |
|
||||
| **步 3:工作流 DAG 注入** | 结构化信息替代重复消息,压缩效率提升 | ~$0.03 | **~$27** | 8 | **$3.4/改动单元** |
|
||||
| **步 4:WorkingContext 分层注入** | 条件注入比全量常驻省 90% L1 开销 | ~$0.01 | **~$9** | 6 | **$1.5/改动单元** |
|
||||
|
||||
**累计 ROI 曲线**:
|
||||
|
||||
```
|
||||
月节省
|
||||
$140│ 🟢 步2后爆发
|
||||
$120│ 🟢
|
||||
$100│ 🟢
|
||||
$80│ 🟢
|
||||
$60│ 🟢
|
||||
$40│ 🟢
|
||||
$20│ 🟢
|
||||
0└──────────────────────────────▶ 累计改动单元
|
||||
0.5 2.5 7.5 15.5 21.5
|
||||
```
|
||||
|
||||
**关键发现**:步 2(工具命名空间)贡献了 ~50% 的总节省。如果资源只能做一件事,做步 2。
|
||||
|
||||
---
|
||||
|
||||
### 8.5 方法 D:影响范围分析
|
||||
|
||||
对 7 个发散方向统计影响的 Rust 文件数(含新增和修改):
|
||||
|
||||
| 发散 | 新增文件 | 修改文件 | 涉及 crate | 核心改动点 |
|
||||
|------|---------|---------|-----------|----------|
|
||||
| ①画布 | 5-7 | 8-12 | df-ai, src-tauri, df-storage | `look_at` 工具注册、画布坐标系构建、导航状态机 |
|
||||
| ②分支 | 3-4 | 6-8 | df-ai, df-storage | `ContextFork` 分支存储、`parent_id`/`fork_point`、分支索引 |
|
||||
| ③主动参与 | 2-3 | 4-6 | df-ai, src-tauri | `ScheduledMessage` 队列、冷却期管理器、模式识别 |
|
||||
| ④衰减 | 3-4 | 5-7 | df-ai, df-ai-core | `precision` 字段、衰减函数、刷新策略 |
|
||||
| ⑤元控制器 | 2-3 | 3-5 | df-ai, src-tauri | `MetaContextController` 结构体、特征窗口、参数调节器 |
|
||||
| ⑥工作流 | 3-5 | 5-8 | df-ai, df-workflow, src-tauri | 工作流 DAG 序列化、节点↔消息关联、`workflow_id` |
|
||||
| ⑦命名空间 | 2-3 | 4-6 | df-ai, src-tauri, df-storage | `NamespaceStore`、引用路径编码、`extract_key_info` 接线 |
|
||||
|
||||
**子系统热力图**:
|
||||
|
||||
```
|
||||
①画布 ②分支 ③主动 ④衰减 ⑤元控 ⑥工作流 ⑦命名空间
|
||||
df-ai-core · · · ███ · · ·
|
||||
df-ai ███ ███ ██ ██ ██ ███ ███
|
||||
df-workflow · · · · · ███ ·
|
||||
df-storage ██ ██ · · · · ██
|
||||
src-tauri ███ ██ ███ · ███ ███ ██
|
||||
前端 ██ · ██ · · ██ ·
|
||||
|
||||
███=大量改动 ██=中等 █=少量 ·=无
|
||||
```
|
||||
|
||||
**结论**:
|
||||
- ⑤(元控)的影响面最小却收益面广——这是高性价比的信号
|
||||
- ⑥+⑦ 共同覆盖 df-ai + src-tauri 重叠区——可以合并实施,共享改造成本
|
||||
- ① 涉及前端 + df-storage + df-ai + src-tauri——全栈改动,风险最大
|
||||
|
||||
---
|
||||
|
||||
### 8.6 方法 E:依赖图 & 拓扑排序
|
||||
|
||||
对派系 A 的实施步进行前置依赖分析:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
S0[步0: 动态压缩阈值] --> S1[步1: L3结构化摘要]
|
||||
S1 --> S2[步2: 工具命名空间]
|
||||
S2 --> S3[步3: 工作流DAG注入]
|
||||
S3 --> S4[步4: WorkingContext分层注入]
|
||||
S4 --> S5[步5: MemoryAdapter trait]
|
||||
S5 --> S6[步6: 版本化快照]
|
||||
```
|
||||
|
||||
**拓扑排序分析**:
|
||||
|
||||
| 步 | 前置依赖 | 可并行? | 关键路径? |
|
||||
|----|---------|---------|-----------|
|
||||
| 步 0 | 无 | ✅ 可独立上线 | 否 |
|
||||
| 步 1 | 步 0(需要压缩触发确定) | ❌ 依赖 | 否(压缩已存在,只改输出格式) |
|
||||
| 步 2 | 无 | ✅ 可与任何步并行 | **是**(收益最高) |
|
||||
| 步 3 | 步 2(命名空间为 DAG 节点提供数据源) | ⚠️ 弱依赖 | **是**(差异化核心) |
|
||||
| 步 4 | 步 1(需要结构化摘要格式经验) | ⚠️ 推荐步 1 后 | 否 |
|
||||
| 步 5 | 步 0-4(重构现有逻辑) | ❌ 必须最后 | 否 |
|
||||
| 步 6 | 步 2(命名空间需要持久化) | ⚠️ 弱依赖 | 否 |
|
||||
|
||||
**关键路径**:步 2 → 步 3 → 步 5,这是派系 A 的核心价值链。步 0 和步 1 可以在任何时间插入。
|
||||
|
||||
**并行窗口**:步 2 与步 1 完全并行(互不依赖),可分配给两个独立 AI agent 同时编码。步 3 和步 4 可以部分重叠(共享 WorkingContext 的数据结构定义,但注入逻辑不同)。
|
||||
|
||||
---
|
||||
|
||||
### 8.7 方法 F:风险矩阵
|
||||
|
||||
对 7 个发散方向评估失败概率(1-5)和失败影响(1-5),风险值 = 概率 × 影响:
|
||||
|
||||
| 发散 | 失败模式 | 概率 | 影响 | 风险值 | 缓解措施 |
|
||||
|------|---------|------|------|--------|---------|
|
||||
| ①画布 | 模型不主动使用 `look_at`,降级回传统模式 | 4 | 3 | **12** 🔴 | 需 prompt 工程 + 用户教育,不可强制 |
|
||||
| ②分支 | 分支数量失控,内存爆炸 | 3 | 4 | **12** 🔴 | 设分支上限(如 5 个),超限自动合并 |
|
||||
| ③主动参与 | 用户觉得烦,关掉整个功能 | 4 | 3 | **12** 🔴 | 冷却期+负面反馈自愈(依赖⑤) |
|
||||
| ④衰减 | 衰减函数不匹配真实 tokenizer,精度信号无意义 | 3 | 3 | **9** 🟡 | 离线对比验证衰减曲线 vs 真实 token 分布 |
|
||||
| ⑤元控制器 | 参数震荡——元控自己的参数也需要调 | 3 | 2 | **6** 🟢 | 固定多数参数,只自适应 3 个核心参数 |
|
||||
| ⑥工作流 | 自由对话无工作流关联,收益为零 | 2 | 3 | **6** 🟢 | 检测 `workflow_id`,没有则跳过 DAG 注入 |
|
||||
| ⑦命名空间 | 模型不理解引用语法,从不读取 namespace | 2 | 2 | **4** 🟢 | 透明降级:模型读引用时自动补全内容 |
|
||||
|
||||
**风险热力图**:
|
||||
|
||||
```
|
||||
影响
|
||||
5│ ·
|
||||
4│ ② ①
|
||||
3│ ③ · ④
|
||||
2│ ⑦ ⑤ ⑥
|
||||
1│ ·
|
||||
└────────────────────────▶ 概率
|
||||
1 2 3 4 5
|
||||
|
||||
🟢 低风险(⑤⑥⑦)🟡 中风险(④)🔴 高风险(①②③)
|
||||
```
|
||||
|
||||
**重要发现**:
|
||||
- 派系 A 的 ⑥+⑦+⑤ 全部落在 🟢 低风险区域
|
||||
- 派系 B 的 ② 落在 🔴 高风险区域(分支爆炸)
|
||||
- 派系 C 的 ①③ 都落在 🔴 高风险区域(模型不配合 + 用户反感)
|
||||
- 风险分布与决策矩阵的得分分布高度一致——交叉验证通过 ✅
|
||||
|
||||
---
|
||||
|
||||
### 8.8 方法 G:根本原因回溯
|
||||
|
||||
从 DevFlow 中已确认的上下文管理痛点出发,看每个发散方向是否直接对症:
|
||||
|
||||
| 痛点 | 严重度 | 根因 | 对症的发散 | 不对症的发散 |
|
||||
|------|--------|------|-----------|-------------|
|
||||
| **P1: tool_result 膨胀撑爆窗口** | 🔴 P0 | `read_file` 等工具返回大量文本全部挤入主队列 | **⑦**命名空间(直接消除)| ①画布 ②分支 ③主动 ④衰减 ⑤元控 ⑥工作流 |
|
||||
| **P2: 压缩后目标丢失** | 🔴 P0 | `compressed` 消息不进 LLM,目标信息消失 | **⑥**工作流DAG保留结构化产出 | ①画布 ②分支 ③主动 ④衰减 ⑤元控 ⑦命名空间 |
|
||||
| **P3: 中断恢复模糊** | 🟡 P1 | `build_for_request` 只恢复窗口内消息,不恢复会话状态 | **⑥+⑦**(DAG+namespace 双重恢复) | ①画布 ②分支 ③主动 ④衰减 |
|
||||
| **P4: 触发时机不当** | 🟡 P1 | 压缩阈值硬编码,不考 system prompt 大小 | **⑤**元控制器(动态调参)| ①画布 ②分支 ③主动 ④衰减 ⑥ ⑦ |
|
||||
| **P5: 多方案对比困难** | 🟢 P2 | 线性历史无法表达多方案岔路,需切换分支 | **②**分支(直接解决) | ①画布 ③主动 ④衰减 ⑤元控 ⑥ ⑦ |
|
||||
|
||||
**覆盖度统计**:
|
||||
|
||||
| 发散方向 | 覆盖痛点数 | 覆盖的痛点 | 错过的痛点 |
|
||||
|---------|-----------|-----------|-----------|
|
||||
| ⑥工作流 | 2(P2, P3) | 目标丢失、中断恢复 | P1 tool膨胀、P4触发时机、P5多方案 |
|
||||
| ⑦命名空间 | 2(P1, P3) | tool膨胀、中断恢复 | P2目标丢失、P4触发时机、P5多方案 |
|
||||
| ⑤元控制器 | 1(P4) | 触发时机 | 其他 4 个 |
|
||||
| ②分支 | 1(P5) | 多方案对比 | 其他 4 个 |
|
||||
| ①画布 | 0 | — | 全部 5 个 |
|
||||
| ③主动参与 | 0 | — | 全部 5 个 |
|
||||
| ④时间轴衰减 | 0 | — | 全部 5 个 |
|
||||
|
||||
**结论**:⑥+⑦ 覆盖了 P0 和 P1 级的 3 个痛点(P1, P2, P3),合计覆盖 3/5 = 60% 的已知痛点。加上⑤覆盖 P4 后达到 4/5 = 80%。②覆盖 P5 后达到 100%。路径清晰:⑥+⑦ → +⑤ → +②。
|
||||
|
||||
---
|
||||
|
||||
### 8.9 方法 H:帕累托分析
|
||||
|
||||
对派系 A 的各实施步,计算累计收益占总收益的百分比:
|
||||
|
||||
| 实施步 | 累计改动单元 | 月节省 | 累计节省 | 累计节省占比 | 累计改动单元占比 |
|
||||
|--------|---------|--------|---------|------------|------------|
|
||||
| 步 0:动态阈值 | 0.5 | $9 | $9 | 6.7% | 2.3% |
|
||||
| 步 1:结构化摘要 | 2.5 | $18 | $27 | 20.0% | 11.6% |
|
||||
| **步 2:工具命名空间** | 7.5 | $72 | $99 | **73.3%** 🔥 | 34.9% |
|
||||
| 步 3:工作流 DAG | 15.5 | $27 | $126 | 93.3% | 72.1% |
|
||||
| 步 4:WorkingContext | 21.5 | $9 | $135 | 100% | 100% |
|
||||
|
||||
```
|
||||
累计节省占比
|
||||
100%│ ●
|
||||
75%│ ● ← 步2: 20%努力→73%收益
|
||||
50%│
|
||||
25│ ●
|
||||
0%│──●────●───────────────────────────▶ 累计改动单元占比
|
||||
0% 2.3% 11.6% 34.9% 72.1%
|
||||
```
|
||||
|
||||
**帕累托结论**:步 0-2(前 35% 努力)贡献 73% 收益。步 2(工具命名空间)是唯一的"低挂果实"——5 改动单元工作量,月节省 $72,占全部收益的 53%。**建议立即启动步 2,步 3-4 视资源情况决策。**
|
||||
|
||||
---
|
||||
|
||||
### 8.10 方法 I:Kano 模型
|
||||
|
||||
从用户感知角度将 7 个发散方向分类:
|
||||
|
||||
| 发散方向 | Kano 分类 | 判断依据 |
|
||||
|---------|----------|---------|
|
||||
| ⑤元控制器 | **基本型** | 用户不直接感知,但参数不当会导致对话中断(负面体验) |
|
||||
| ⑦工具命名空间 | **基本型 → 性能型** | 当前 tool 膨胀可接受但痛苦(Sprint 6 P1),改善后用户不说但感觉流畅 |
|
||||
| ⑥工作流即上下文 | **性能型** | 用户使用工作流时体验飞跃,自由对话时完全无感 |
|
||||
| ②上下文分支 | **性能型** | 多方案探索用户明显受益,单线用户不关心 |
|
||||
| ④时间轴衰减 | **无差异型** | 精度衰减在对话中无法被用户察觉(除非看日志) |
|
||||
| ①画布导航 | **魅力型** | 高级用户会惊叹"AI 自己知道要看哪里",但普通用户不敏感 |
|
||||
| ③主动参与 | **魅力型 → 反向型** | 做得好是惊喜,做不好是骚扰。双刃剑 |
|
||||
|
||||
**Kano 对实施顺序的指导**:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "先做(基本型→性能型)"
|
||||
A["⑤元控制器<br>(基本型,不做会出问题)"]
|
||||
B["⑦工具命名空间<br>(性能型,做了用户觉得流畅)"]
|
||||
end
|
||||
subgraph "再做(性能型)"
|
||||
C["⑥工作流即上下文<br>(差异化竞争力)"]
|
||||
D["②分支<br>(特定场景加分)"]
|
||||
end
|
||||
subgraph "可选"
|
||||
E["①画布<br>(魅力型,成本高)"]
|
||||
F["③主动参与<br>(双刃剑,需谨慎)"]
|
||||
G["④衰减<br>(无差异,不做)"]
|
||||
end
|
||||
|
||||
A --> B
|
||||
B --> C
|
||||
C --> D
|
||||
```
|
||||
|
||||
**结论**:Kano 模型给出的优先级顺序与决策矩阵、ROI、帕累托的分析高度一致——⑤→⑦→⑥→②→①③可选→④跳过。这是第 9 种独立方法得出同一结论,信心再度增强。
|
||||
|
||||
---
|
||||
|
||||
## 九、论证收敛总表
|
||||
|
||||
### 9.1 各方法结论汇合
|
||||
|
||||
| 方法 | 收敛结论 | 与主线是否一致 |
|
||||
|------|---------|--------------|
|
||||
| 推演法(第 4-6 章) | 派系 A > B > C,⑥+⑦ 为最优起点 | —(主线基准) |
|
||||
| **A 决策矩阵** | 派系 A 108 > B 61 > C 44 | ✅ 一致 |
|
||||
| **B 成本收益散点** | ⑥+⑦ 在低成本高收益象限 | ✅ 一致 |
|
||||
| **C ROI 测算** | 步 2 月节省 $72,5 改动单元回收 | ✅ 一致 |
|
||||
| **D 影响范围** | ⑥+⑦ 影响面可控,集中在 df-ai | ✅ 一致 |
|
||||
| **E 拓扑排序** | 步 2 和步 1 可并行,关键路径清晰 | ✅ 一致 |
|
||||
| **F 风险矩阵** | ⑥+⑦+⑤ 全 🟢,①②③ 全 🔴 | ✅ 一致 |
|
||||
| **G 根本原因回溯** | ⑥+⑦ 覆盖 3/5 痛点,② 补 1 个 | ✅ 一致 |
|
||||
| **H 帕累托分析** | 35% 努力得 73% 收益,步 2 为低挂果实 | ✅ 一致 |
|
||||
| **I Kano 模型** | ⑤基本→⑦性能→⑥性能→②性能 | ✅ 一致 |
|
||||
|
||||
**9 种独立方法结论全部收敛,无分歧。** 这是强信号。
|
||||
|
||||
### 9.2 最终建议
|
||||
|
||||
```
|
||||
立即启动(步 0 + 步 2):
|
||||
动态压缩阈值(3 行) + 工具命名空间(5 改动单元)
|
||||
→ 消除 tool 膨胀主因,月省 $81
|
||||
|
||||
Phase 4 追加(步 1 + 步 3):
|
||||
L3 结构化摘要(2 改动单元) + 工作流 DAG 注入(8 改动单元)
|
||||
→ 差异化竞争力,累计月省 $135
|
||||
|
||||
Phase 5 完善(步 4 + 步 5 + 步 6):
|
||||
WorkingContext 分层注入 + MemoryAdapter trait + 版本化快照
|
||||
→ 架构完善,累计月省 $135+
|
||||
|
||||
不做:
|
||||
画布导航(①)、时间轴衰减(④)—— 收益成本比低
|
||||
主动参与(③)、分支(②)—— Kano 双刃剑,待用户需求信号出现后再评估
|
||||
|
||||
预留接口:
|
||||
ContextNavigation / ContextFork / ProactiveHint / PrecisionDecay
|
||||
→ 4 个 trait,仅定义不实现(实现零行)
|
||||
```
|
||||
@@ -60,6 +60,9 @@ docs/
|
||||
│ ├── secret下沉与provider注入方案-2026-06-16.md # secret 纯密钥下沉 df-storage 方案
|
||||
│ ├── 推进链阶段2实施路径-2026-06-16.md # advance_task 走 df-nodes 实施路径
|
||||
│ ├── 意图识别层论证-2026-06-19.md # 通用前置意图识别层 8 维度论证
|
||||
│ ├── 上下文管理演进与发散思考-2026-07-20.md # 上下文管理现状评估、业界对标、7个发散方向与组合分析
|
||||
│ ├── 上下文管理演进-任务技术设计-2026-07-20.md # T2命名空间/T4工作流DAG/T3结构化摘要技术设计与推演
|
||||
│ ├── 上下文管理演进-执行计划-2026-07-20.md # Wave 划分、agent 并行策略、接口契约、验证 gate、回滚策略
|
||||
│ ├── 多主题上下文管理愿景-2026-06-19.md # 多主题多摘要愿景(远期方向,关联 F-15/意图识别)
|
||||
│ └── 查询效率优化方案-2026-06-19.md # 查询链路优化(SQL 下推/精确拉取/缓存/字段投影)
|
||||
├── 03-模块文档/
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//!
|
||||
//! 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。
|
||||
//!
|
||||
//! 流程(对齐阶段2 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
|
||||
//! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
|
||||
//! ① 读 active 克隆(不改 status / 不扣 token)→ 喂 LLM 出摘要;
|
||||
//! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system);
|
||||
//! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。
|
||||
@@ -17,18 +17,18 @@
|
||||
//! 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁,但 loop 串行无并发
|
||||
//! (本函数独占 session_arc,工具执行/审批分支在 stream 之后),故此窗口内 messages 不变。
|
||||
//!
|
||||
//! 安全(FR-S1):复用 loop 顶部已 build+验证 的 provider(不再 build_provider_for 重复 resolve
|
||||
//! 安全:复用 loop 顶部已 build+验证 的 provider(不再 build_provider_for 重复 resolve
|
||||
//! keyring),api_key 经 df_storage::secret 闭环;summary/error payload/日志均不含 api_key。
|
||||
//! is_compressing 防重入:set_compressing(true/false) 成对(LLM 调用前后均复位)。
|
||||
//! 单轮问答(history_tokens 未超 0.6*budget)不触发,零行为变化。
|
||||
//! F-260616-09 B 批2:messages 操作改 per_conv(设计 §4.2)。
|
||||
//! messages 操作改 per_conv。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_ai::context_helpers::extract_keyword_summary;
|
||||
use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
|
||||
use df_ai::provider::{ChatMessage, LlmProvider};
|
||||
use df_storage::models::AiProviderRecord;
|
||||
|
||||
@@ -51,7 +51,7 @@ const PROTECT_COUNT: usize = 6;
|
||||
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
|
||||
const KEYWORD_FALLBACK_ENABLED: bool = true;
|
||||
|
||||
/// F-15 阶段3: 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
|
||||
/// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
|
||||
///
|
||||
/// 从 `run_agentic_loop` 抽取,行为零变更。返回值:
|
||||
/// - `true`:conv 已删除,调用方应立即 `return`(退出整个 loop)。
|
||||
@@ -69,6 +69,7 @@ pub(super) async fn maybe_auto_compress(
|
||||
provider_config: &AiProviderRecord,
|
||||
llm_concurrency: &LlmConcurrency,
|
||||
iteration: usize,
|
||||
sys_tokens: u32,
|
||||
) -> bool {
|
||||
let prev_compressing = session_arc.lock().await.conv_read(&conv_id).map(|c| c.messages.is_compressing()).unwrap_or(false);
|
||||
if !prev_compressing {
|
||||
@@ -85,10 +86,10 @@ pub(super) async fn maybe_auto_compress(
|
||||
let mgr = &mgr.messages;
|
||||
let protect_start = mgr.len().saturating_sub(PROTECT_COUNT);
|
||||
let history_tokens = mgr.history_tokens();
|
||||
let budget = mgr.budget_limit();
|
||||
// 触发阈值 0.6*budget(整数比避免浮点):budget*6/10 < history_tokens
|
||||
let available = mgr.budget_limit().saturating_sub(sys_tokens);
|
||||
// 触发阈值 0.6*available(可用预算=budget-sys_tokens):available*6/10 < history_tokens
|
||||
let should = protect_start > 0
|
||||
&& (budget as u64) * 6 / 10 < history_tokens as u64
|
||||
&& (available as u64) * 6 / 10 < history_tokens as u64
|
||||
&& mgr.has_compressible_messages(protect_start);
|
||||
(should, protect_start, history_tokens)
|
||||
};
|
||||
@@ -99,9 +100,9 @@ pub(super) async fn maybe_auto_compress(
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
// L3 emit 双写:tunnel subscriber透传 miniapp
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
let (active_msgs, lang) = {
|
||||
let (mut active_msgs, lang) = {
|
||||
let mut session = session_arc.lock().await;
|
||||
let conv = session.conv(&conv_id);
|
||||
conv.messages.set_compressing(true);
|
||||
@@ -117,6 +118,30 @@ pub(super) async fn maybe_auto_compress(
|
||||
(active_msgs, lang)
|
||||
};
|
||||
|
||||
// T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。
|
||||
// 同 agentic/mod.rs 的 build_for_request 出口展开。未展开则压缩 LLM 只看到
|
||||
// "namespace://..." 占位符,摘要基于占位符丢主题。read_only None(条目被 LRU 淘汰 /
|
||||
// 重启后 namespace 清空 / 跨会话残留引用)→ 替换为 EVICTED_PLACEHOLDER 提示文案
|
||||
// (非保留无意义 namespace:// URI)+ warn 日志。
|
||||
{
|
||||
let session = session_arc.lock().await;
|
||||
for m in &mut active_msgs {
|
||||
if df_ai::namespace_store::is_namespace_ref(&m.content) {
|
||||
let path = m.content.clone();
|
||||
if let Some(original) = session.namespace_store.read_only(&path) {
|
||||
m.content = original.to_string();
|
||||
} else {
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
path = %path,
|
||||
"[namespace] 引用已淘汰,替换为提示文案"
|
||||
);
|
||||
m.content = df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 改进3 B:在 active_msgs move 进 compress_via_llm 前,先算关键词摘要兜底文本。
|
||||
// LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。
|
||||
// KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。
|
||||
@@ -128,7 +153,7 @@ pub(super) async fn maybe_auto_compress(
|
||||
|
||||
// 压缩调用(复用 loop 顶部已 build 的 provider,api_key 经 secret 闭环)。
|
||||
// 成功 → Some(summary);失败 → Err;无 active 可压缩(active_msgs 空)→ 视为 noop。
|
||||
let compress_outcome: Result<Option<String>, String> = if active_msgs.is_empty() {
|
||||
let compress_outcome: Result<Option<CompressedSummary>, String> = if active_msgs.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
compress_via_llm(
|
||||
@@ -144,15 +169,14 @@ pub(super) async fn maybe_auto_compress(
|
||||
match compress_outcome {
|
||||
Ok(Some(summary)) => {
|
||||
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。
|
||||
// compress_old_messages 幂等:此时 status 仍是 active(本流程未先标),它会把
|
||||
// [..protect_start] 内 active 标 compressed 并扣 token。返回的 cloned 与之前读的
|
||||
// active_msgs 等价(LLM 调用期间 messages 不变,见上方口径决策注)。
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
let conv = session.conv(&conv_id);
|
||||
let _compressed = conv.messages.compress_old_messages(protect_start);
|
||||
conv.messages.insert_at(0, ChatMessage::system(&summary));
|
||||
// T3: 插入 NL 摘要(向前兼容),JSON 卡片可供后续 WorkingContext(T5)使用
|
||||
conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary));
|
||||
conv.messages.set_compressing(false);
|
||||
// TODO(T5): 如有 WorkingContext,调用 version.reset_all(summary.json_card, turn)
|
||||
}
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
@@ -165,10 +189,10 @@ pub(super) async fn maybe_auto_compress(
|
||||
// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
|
||||
let ev = AiChatEvent::AiAutoCompressed {
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
summary,
|
||||
summary: summary.nl_summary,
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
// L3 emit 双写:tunnel subscriber透传 miniapp
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
Ok(None) => {
|
||||
@@ -216,7 +240,7 @@ pub(super) async fn maybe_auto_compress(
|
||||
);
|
||||
}
|
||||
}
|
||||
// BUG-260624-05:压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
|
||||
// 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
|
||||
// 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn(上方 line 957 已有)
|
||||
// + compress_old_messages 兜底释放 token + build_for_request 裁剪,loop 继续 stream_llm,
|
||||
// 用户自然看到后续回复。压缩降级对用户透明(非致命错误,不该停流)。
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
//! - **读侧收敛**:停止按钮三态(可停 / 停中 / 停失败可重试)、MaxRoundsCard 是否弹等
|
||||
//! 判别逻辑由 `ConvState` 变体直接表达,不再靠多变量组合反推。
|
||||
//!
|
||||
//! # 收口后(批3+)
|
||||
//! # 当前架构
|
||||
//!
|
||||
//! 本模块是**纯逻辑、无 IO**的 enum + 转换守卫。批3 双轨收口后 `generating` bool
|
||||
//! 本模块是**纯逻辑、无 IO**的 enum + 转换守卫。`generating` bool
|
||||
//! 与 `CONV_STATE_ENABLED` 开关已退役,`ConvState` 成为唯一真相源:guard 接入点
|
||||
//! (`GeneratingGuard` new/reset/drop 时同步迁移 `ConvState`)无条件执行迁移 + emit。
|
||||
//!
|
||||
@@ -52,7 +52,7 @@ use serde::{Deserialize, Serialize};
|
||||
/// `ConvState` 是面向「生成生命周期」的写侧真相(写收敛)。两者正交:如 `Generating` 态
|
||||
/// 同时有审批挂起时,`ConvState=Generating` 而 `SessionState=AwaitingApproval`。
|
||||
///
|
||||
/// 序列化(`Serialize`/`Deserialize`):批3 前端经事件总线读 enum 视图时使用(本批未接,
|
||||
/// 序列化(`Serialize`/`Deserialize`):前端经事件总线读 enum 视图时使用(本批未接,
|
||||
/// 预留,避免后续改动序列化兼容性)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -150,9 +150,7 @@ impl ConvState {
|
||||
/// 审批挂起(读视图 `SessionState::AwaitingApproval`)期间 `ConvState` 仍是 `Generating`,
|
||||
/// 故本方法返回 true——审批挂起不算「停止生成」。
|
||||
///
|
||||
/// 预留:批2+ 读侧迁移(try_continue / ai_chat_stop / 前端停止三态)消费。
|
||||
/// L2 读侧迁移(2026-06-22):已接入 ai_is_generating / ai_chat_stop / try_continue_agent_loop,
|
||||
/// 不再标 allow(dead_code)(有真实消费方)。
|
||||
/// 已接入 ai_is_generating / ai_chat_stop / try_continue_agent_loop,不再标 allow(dead_code)。
|
||||
pub fn is_active(self) -> bool {
|
||||
matches!(self, ConvState::Generating | ConvState::Compressed)
|
||||
}
|
||||
@@ -162,8 +160,8 @@ impl ConvState {
|
||||
/// 读侧便利方法:用于 `ai_conversation_create` / `ai_chat_send` 等入口判断
|
||||
/// 「能否接新请求」。`Error` 态视为可接(用户重试即从 Error 起步)。
|
||||
///
|
||||
/// 双轨收口批1(2026-06-25):chat 域入口拦截(ai_regenerate / ai_chat_send /
|
||||
/// ai_chat_edit)已接入,作真实读侧方法消费。删除 `allow(dead_code)` 标注。
|
||||
/// chat 域入口拦截(ai_regenerate / ai_chat_send /
|
||||
/// ai_chat_edit)已接入,作真实读侧方法消费。
|
||||
pub fn can_accept_request(self) -> bool {
|
||||
matches!(self, ConvState::Idle | ConvState::Error)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::commands::ai::knowledge_inject::inject_knowledge_into_prompt;
|
||||
use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry};
|
||||
use super::conv_state::ConvState;
|
||||
|
||||
/// BUG-260617-05: try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
|
||||
/// try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
|
||||
struct ContinueSnapshot {
|
||||
is_generating: bool,
|
||||
has_pending: bool,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
//! 工作流 DAG 上下文注入 — 当会话关联工作流时,将活跃路径注入 system prompt
|
||||
//!
|
||||
//! T4 实现。提供两个函数:
|
||||
//! - `build_dag_summary`: 从 DagDef 构建可读的 DAG 摘要文本
|
||||
//! - `extract_active_path`: 从完整 DAG 中提取当前活跃路径(简化)
|
||||
//!
|
||||
//! 调用方(前端/workflow 引擎)在启动工作流时将摘要存入
|
||||
//! `PerConvState.workflow_dag_summary`,agentic loop 自动注入 system prompt。
|
||||
|
||||
use df_workflow::dag_def::DagDef;
|
||||
|
||||
/// 从 DagDef 构建可读的 DAG 摘要文本
|
||||
///
|
||||
/// 输出格式(5 节点 DAG 为例):
|
||||
/// ```
|
||||
/// 步骤: 读源码 → 分析依赖 → 改配置 → 验证 → 提交
|
||||
/// 当前: 改配置
|
||||
/// 完成: 2/5
|
||||
/// ```
|
||||
pub fn build_dag_summary(dag: &DagDef, current_node_id: Option<&str>) -> String {
|
||||
let total = dag.nodes.len();
|
||||
if total == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// 拓扑排序(简化:按 edges 计算入度,分层输出)
|
||||
let layers = topological_layers(dag);
|
||||
|
||||
// 构建步骤链
|
||||
let step_chain: Vec<&str> = layers.iter()
|
||||
.flat_map(|layer| layer.iter().map(|id| id.as_str()))
|
||||
.collect();
|
||||
let chain_text = step_chain.join(" → ");
|
||||
|
||||
// 当前节点
|
||||
let current_label = current_node_id
|
||||
.and_then(|id| dag.nodes.get(id))
|
||||
.map(|n| n.label.as_deref().unwrap_or(&n.node_type))
|
||||
.unwrap_or("");
|
||||
|
||||
let mut result = format!("步骤: {}\n", chain_text);
|
||||
if !current_label.is_empty() {
|
||||
result.push_str(&format!("当前: {}\n", current_label));
|
||||
}
|
||||
result.push_str(&format!("完成: 待执行器上报"));
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 从 DagDef 构建活跃路径(从根节点到当前节点的路径 + 后续 2 层)
|
||||
///
|
||||
/// 返回简化的步骤链文本,适合注入 system prompt。
|
||||
pub fn extract_active_path(dag: &DagDef, current_node_id: Option<&str>) -> Vec<String> {
|
||||
let layers = topological_layers(dag);
|
||||
|
||||
if let Some(current) = current_node_id {
|
||||
// 找到当前节点所在的层
|
||||
let current_layer_idx = layers.iter().position(|layer| layer.iter().any(|x| x == current));
|
||||
|
||||
match current_layer_idx {
|
||||
Some(idx) => {
|
||||
// 从根到当前层的路径
|
||||
let path: Vec<String> = layers[..=idx].iter()
|
||||
.flat_map(|layer| layer.iter().map(|id| {
|
||||
dag.nodes.get(id)
|
||||
.map(|n| n.label.clone().unwrap_or_else(|| id.clone()))
|
||||
.unwrap_or_else(|| id.clone())
|
||||
}))
|
||||
.collect();
|
||||
|
||||
// 后续 2 层
|
||||
let next: Vec<String> = layers[idx + 1..]
|
||||
.iter()
|
||||
.take(2)
|
||||
.flat_map(|layer| layer.iter().map(|id| {
|
||||
dag.nodes.get(id)
|
||||
.map(|n| n.label.clone().unwrap_or_else(|| id.clone()))
|
||||
.unwrap_or_else(|| id.clone())
|
||||
}))
|
||||
.collect();
|
||||
|
||||
let mut result = path;
|
||||
result.extend(next);
|
||||
result
|
||||
}
|
||||
None => {
|
||||
// 当前节点不在 DAG 中 → 返回所有节点标签
|
||||
layers.iter().flat_map(|layer| layer.iter().map(|id| {
|
||||
dag.nodes.get(id)
|
||||
.map(|n| n.label.clone().unwrap_or_else(|| id.clone()))
|
||||
.unwrap_or_else(|| id.clone())
|
||||
})).collect()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 无当前节点 → 返回所有节点标签
|
||||
layers.iter().flat_map(|layer| layer.iter().map(|id| {
|
||||
dag.nodes.get(id)
|
||||
.map(|n| n.label.clone().unwrap_or_else(|| id.clone()))
|
||||
.unwrap_or_else(|| id.clone())
|
||||
})).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// 拓扑分层排序:按入度计算每层的节点
|
||||
fn topological_layers(dag: &DagDef) -> Vec<Vec<String>> {
|
||||
let mut in_degree: std::collections::HashMap<&str, usize> = dag.nodes.keys().map(|k| (k.as_str(), 0)).collect();
|
||||
|
||||
for edge in &dag.edges {
|
||||
if let Some(deg) = in_degree.get_mut(edge.target.as_str()) {
|
||||
*deg += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut layers = Vec::new();
|
||||
let mut remaining: std::collections::HashSet<&str> = dag.nodes.keys().map(|k| k.as_str()).collect();
|
||||
|
||||
while !remaining.is_empty() {
|
||||
let current_layer: Vec<String> = remaining.iter()
|
||||
.filter(|id| *in_degree.get(*id).unwrap_or(&0) == 0)
|
||||
.map(|id| id.to_string())
|
||||
.collect();
|
||||
|
||||
if current_layer.is_empty() {
|
||||
// 有环 → 剩余节点全放同一层
|
||||
layers.push(remaining.iter().map(|id| id.to_string()).collect());
|
||||
break;
|
||||
}
|
||||
|
||||
for id in ¤t_layer {
|
||||
remaining.remove(id.as_str());
|
||||
// 减少以该节点为起点的边的入度
|
||||
for edge in &dag.edges {
|
||||
if edge.source == *id {
|
||||
if let Some(deg) = in_degree.get_mut(edge.target.as_str()) {
|
||||
*deg = deg.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
layers.push(current_layer);
|
||||
}
|
||||
|
||||
layers
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_workflow::dag_def::{DagDef, NodeDef, EdgeDef};
|
||||
|
||||
fn make_dag() -> DagDef {
|
||||
let mut dag = DagDef::new();
|
||||
dag.add_node("n1".to_string(), "read_file".to_string(), serde_json::json!({}));
|
||||
dag.add_node("n2".to_string(), "analyze".to_string(), serde_json::json!({}));
|
||||
dag.add_node("n3".to_string(), "modify".to_string(), serde_json::json!({}));
|
||||
dag.add_edge("n1".to_string(), "n2".to_string());
|
||||
dag.add_edge("n2".to_string(), "n3".to_string());
|
||||
dag
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_topological_layers_linear() {
|
||||
let dag = make_dag();
|
||||
let layers = topological_layers(&dag);
|
||||
assert_eq!(layers.len(), 3);
|
||||
assert_eq!(layers[0], vec!["n1"]);
|
||||
assert_eq!(layers[1], vec!["n2"]);
|
||||
assert_eq!(layers[2], vec!["n3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_active_path_midway() {
|
||||
let dag = make_dag();
|
||||
let path = extract_active_path(&dag, Some("n2"));
|
||||
assert_eq!(path.len(), 3); // n1, n2, n3
|
||||
assert_eq!(path[1], "n2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_dag_summary_empty() {
|
||||
let dag = DagDef::new();
|
||||
assert_eq!(build_dag_summary(&dag, None), "");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! F-#97 / AE-04 / 阶段4 审批门控逻辑。
|
||||
//! 审批门控逻辑。
|
||||
//!
|
||||
//! 审批策略(方案 B:Persona 维度 + 扩展点):
|
||||
//! - reviewer/analyst 人设的工具调用全 auto(只读/分析,审批无意义)
|
||||
@@ -19,7 +19,7 @@ use super::cache::{find_cached_high_risk_result, pending_placeholder_for};
|
||||
use super::diff::build_write_file_diff;
|
||||
use super::reason::build_approval_reason;
|
||||
use super::record::audit_tool_call;
|
||||
use super::super::{AiChatEvent, AiSession, ApprovalKind, PendingApproval, ToolCallDraft, trust_key_for, TrustKey};
|
||||
use super::super::{AiChatEvent, AiSession, ApprovalKind, PendingApproval, ToolCallDraft, is_destructive_command, trust_key_for, TrustKey};
|
||||
|
||||
/// 按 risk_level + auto_exec_mode 判定是否自动执行。
|
||||
///
|
||||
@@ -88,12 +88,12 @@ pub(super) fn should_auto_for_persona(
|
||||
classify_risk_and_auto(risk_level, auto_exec_mode, tool_name, args)
|
||||
}
|
||||
|
||||
/// AE-2025-04 检查会话信任命中。
|
||||
/// 检查会话信任命中。
|
||||
///
|
||||
/// 首批信任工具:write_file / run_command。同会话已批准过同工具+同目录 →
|
||||
/// `TrustKey` 命中,返回 `Some(TrustKey)`;否则返回 `None`(走原审批流程)。
|
||||
///
|
||||
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,内部短 lock 读
|
||||
/// 签名改 `session_arc: &Arc<Mutex<AiSession>>`,内部短 lock 读
|
||||
/// `session_trust` 后立即 drop,信任查询不持锁,与 process_tool_calls 持锁 await 反模式解耦。
|
||||
pub(super) async fn check_trust_hits(
|
||||
draft: &ToolCallDraft,
|
||||
@@ -102,6 +102,22 @@ pub(super) async fn check_trust_hits(
|
||||
conv_id: &str,
|
||||
) -> Option<TrustKey> {
|
||||
let key = trust_key_for(&draft.name, args)?;
|
||||
// 安全门控(run_command 会话信任按目录不按命令的补丁):
|
||||
// run_command 的信任 key 只用 working_dir,一次批准 → 同目录任意命令 auto。
|
||||
// 破坏性命令(rm/del/format/... 不可逆)绝不 auto 放行,即使同目录已批准过,
|
||||
// 也必须每轮走正常审批。命中黑名单直接返 None,让流程继续走 pending 审批。
|
||||
if matches!(key, TrustKey::Execute { .. }) {
|
||||
if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) {
|
||||
if is_destructive_command(cmd) {
|
||||
tracing::info!(
|
||||
tool = %draft.name,
|
||||
new_tool_call_id = %draft.id,
|
||||
"[会话信任] 命中破坏性命令黑名单,跳过 auto 放行(走正常审批)"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 短 lock 读 session_trust(仅 contains 判定,无 await,纳秒级),命中即返 key
|
||||
let hit = {
|
||||
let session = session_arc.lock().await;
|
||||
@@ -118,7 +134,7 @@ pub(super) async fn check_trust_hits(
|
||||
/// **注意**:调用方应在调用前先 +=1 `pending_count`(保持与原 `handle_approval_tool`
|
||||
/// 行为一致——重试 skip 分支也在 `pending_count += 1` 之后返回)。
|
||||
///
|
||||
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有慢操作
|
||||
/// 签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有慢操作
|
||||
/// (build_write_file_diff/detect_retry_count/build_approval_reason/audit_tool_call)
|
||||
/// 在锁外 await,仅 `pending_approvals.insert` + `messages.push` 两处纯写改短 lock 段。
|
||||
pub(super) async fn insert_pending_approval(
|
||||
@@ -132,7 +148,7 @@ pub(super) async fn insert_pending_approval(
|
||||
db: &Arc<Database>,
|
||||
current_message_id: Option<&str>,
|
||||
) {
|
||||
// AE-2025-03(路径 B):write_file 挂起审批前预读旧文件生成 diff。
|
||||
// (路径 B):write_file 挂起审批前预读旧文件生成 diff。
|
||||
// 仅 write_file(覆盖整文件,有完整新旧内容可对比);其他工具 diff=None。
|
||||
// 旧文件不存在(新建)→ diff=None,前端回退显新 content。
|
||||
// 读失败不阻断审批(容错:文件无读权限等极端情况降级为无 diff 预览)。
|
||||
@@ -142,7 +158,7 @@ pub(super) async fn insert_pending_approval(
|
||||
None
|
||||
};
|
||||
|
||||
// 阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
|
||||
// (容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
|
||||
// 与 High risk 的 find_cached_high_risk_result 互补:去重按 (tool_name,args) 匹配
|
||||
// (High only),本 guard 按 tc_id 匹配(覆盖 Med + High 残留场景)。
|
||||
// 同 tc_id 已有审计落定记录 → retry_count≥1,跳过审批 + emit Completed,断死循环。
|
||||
@@ -179,12 +195,12 @@ pub(super) async fn insert_pending_approval(
|
||||
arguments: args.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
recovered: false,
|
||||
// 阶段3a:普通 RiskLevel 审批标 kind=Risk{diff}(下沉原 diff 字段)。
|
||||
// 普通风险审批标 kind=Risk{diff}(下沉原 diff 字段)。
|
||||
kind: ApprovalKind::Risk { diff: approval_diff.clone() },
|
||||
retry_count,
|
||||
created_at: Some(std::time::SystemTime::now()),
|
||||
});
|
||||
// 阶段2:占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan)
|
||||
// 占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan)
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
||||
}
|
||||
// 慢操作锁外:拼 reason(DB 读)+ emit + 审计落 pending 纪录(DB 写)
|
||||
@@ -203,7 +219,7 @@ pub(super) async fn insert_pending_approval(
|
||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await;
|
||||
}
|
||||
|
||||
/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 tc_id 重试计数。
|
||||
/// (容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 tc_id 重试计数。
|
||||
///
|
||||
/// 返回语义:
|
||||
/// - 0:审计表无该 tc_id 落定记录(或仅 pending),属首次审批执行,正常挂起。
|
||||
@@ -224,7 +240,7 @@ pub(super) async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id:
|
||||
None => return 0, // 无记录 = 首次
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("[阶段4-retry] 查审计表 tc_id={} 失败(降级无重试防护): {}", tc_id, e);
|
||||
tracing::warn!("[approval-retry] 查审计表 tc_id={} 失败(降级无重试防护): {}", tc_id, e);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
@@ -240,10 +256,10 @@ pub(super) async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id:
|
||||
///
|
||||
/// 1. 按 `auto_exec_mode`(low/medium/all) + `risk_level` + `patch_file` 小改动特例
|
||||
/// 判定是否应自动执行(`classify_risk_and_auto`)。若是 → 推入 `low_risk` 返回。
|
||||
/// 2. 否则走审批分支:会话信任(`check_trust_hits`)→ F-05 高危去重缓存 → 阶段4 重试 guard →
|
||||
/// 2. 否则走审批分支:会话信任(`check_trust_hits`)→ 高危去重缓存 → 重试 guard →
|
||||
/// `insert_pending_approval`(write_file diff + 挂起 + emit + 审计落 pending 记录)。
|
||||
///
|
||||
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有 session 访问
|
||||
/// 签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有 session 访问
|
||||
/// 都改短 lock 段(check_trust_hits/find_cached_high_risk_result 内部短 lock,命中后 push
|
||||
/// tool_result 短 lock 段)。慢操作(audit_tool_call/find_cached_high_risk_result 的 DB 查)
|
||||
/// 全在锁外 await,根治 process_tool_calls 持 session lock 期间 await 慢操作死锁反模式。
|
||||
@@ -270,9 +286,9 @@ pub(super) async fn handle_approval_tool(
|
||||
}
|
||||
|
||||
// ── Step 2: 会话信任检查 ──
|
||||
// AE-2025-04 会话级信任(Session Trust):首批 write_file / run_command,
|
||||
// 会话级信任(Session Trust):首批 write_file / run_command,
|
||||
// 同会话已批准过同工具+同目录 → TrustKey 命中 → 自动放行(跳过 pending + 二次确认)。
|
||||
// 命中后走与 F-05 去重命中相似的「直接执行 + Completed + 审计 decided_by=auto_trust」路径,
|
||||
// 命中后走与去重命中相似的「直接执行 + Completed + 审计 decided_by=auto_trust」路径,
|
||||
// 但与 F-05 不同:F-05 复用缓存 tool_result 跳过执行;trust 放行**真实执行工具**
|
||||
// (用户信任同目录同类操作,但仍要看每次的真实结果)。
|
||||
if let Some(key) = check_trust_hits(&draft, &args, session_arc, conv_id).await {
|
||||
@@ -283,7 +299,7 @@ pub(super) async fn handle_approval_tool(
|
||||
tool = %draft.name,
|
||||
dir = %dir_label,
|
||||
new_tool_call_id = %draft.id,
|
||||
"[AE-2025-04] 会话信任命中: 同会话已批准同类操作,自动放行(跳过审批+二次确认)"
|
||||
"[会话信任] 命中: 同会话已批准同类操作,自动放行(跳过审批+二次确认)"
|
||||
);
|
||||
// emit 轻量 toast 事件(前端 AiChat.vue 显示"🔓 自动放行: tool(dir)")
|
||||
// L3 emit 双写:会话信任自动放行 toast 双路发布(tunnel 透传 miniapp 即时反馈)。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! F-260616-05 高危工具去重缓存(第三批 helper 抽离,行为零变更)。
|
||||
//! 高危工具去重缓存。
|
||||
//!
|
||||
//! 从 audit/mod.rs 搬迁:`find_cached_high_risk_result` + `canonical_args_key`
|
||||
//! + `sort_object_keys` + `PENDING_APPROVAL_PLACEHOLDER`(去重与 process_tool_calls
|
||||
@@ -18,14 +18,14 @@ use super::super::AiSession;
|
||||
/// (find_cached_high_risk_result) 各持一份字面量致耦合——若两者漂移,
|
||||
/// 去重会把 pending 占位误判为已落定结果命中缓存,污染 LLM 上下文。
|
||||
///
|
||||
/// **阶段2(占位配对完整性)**:实际写入 messages 时用 [`pending_placeholder_for`] 生成带
|
||||
/// **占位配对完整性**:实际写入 messages 时用 [`pending_placeholder_for`] 生成带
|
||||
/// `__PENDING__:tc_id` 标记的完整占位文本,供 sanitize 识别"占位不可裁 + 出口断言自愈补头"。
|
||||
/// 本常量保留为基础文本(向前兼容 + 去重匹配基准)。
|
||||
pub(crate) const PENDING_APPROVAL_PLACEHOLDER: &str = "需要用户审批,等待确认";
|
||||
|
||||
/// 构造带唯一标记(`__PENDING__:tc_id`)的审批挂起占位 tool_result 内容。
|
||||
///
|
||||
/// 阶段2 解 400 orphan:占位 tool_result 内嵌 tc_id 标记,sanitize step3.5(反向 orphan 检测)
|
||||
/// 解 400 orphan:占位 tool_result 内嵌 tc_id 标记,sanitize step3.5(反向 orphan 检测)
|
||||
/// 据此把占位豁免保留(不丢),出口断言 `assert_placeholder_pairing` 据此补 TOOL_MISSING_PREFIX
|
||||
/// 占位头自愈,使占位 result 与(可能被裁掉的)tool_call 头闭合配对,防 provider 400 orphan。
|
||||
///
|
||||
@@ -40,15 +40,15 @@ pub(crate) fn pending_placeholder_for(tc_id: &str) -> String {
|
||||
|
||||
/// 判定 tool_result content 是否为审批挂起占位(含基础文本或带 __PENDING__ 标记)。
|
||||
///
|
||||
/// 替代原裸 `msg.content == PENDING_APPROVAL_PLACEHOLDER` 精确匹配(阶段2 占位带 tc_id 标记后
|
||||
/// 替代原裸 `msg.content == PENDING_APPROVAL_PLACEHOLDER` 精确匹配(占位带 tc_id 标记后
|
||||
/// 不再精确等于基础文本,需用子串/标记匹配)。兼容老占位(纯文本)与新占位(带标记)。
|
||||
pub(crate) fn is_pending_placeholder(content: &str) -> bool {
|
||||
df_ai::context_helpers::is_pending_placeholder(content)
|
||||
}
|
||||
|
||||
/// F-260616-05:高危工具去重(根治 run_command 超时→重试→重新审批循环)。
|
||||
/// 高危工具去重(根治 run_command 超时→重试→重新审批循环)。
|
||||
///
|
||||
/// ⚠ 性能注记(CR-260618-11#5):本函数在 session lock 持有期间对每个 high risk 工具
|
||||
/// ⚠ 性能注记:本函数在 session lock 持有期间对每个 high risk 工具
|
||||
/// 串行查 AiToolExecutionRepo::find_by_tool_call_id,工具数多时锁持有线性增长。
|
||||
/// 批量预取(改签名传 tool_call_ids 批量查)属架构改暂未做,后续 High risk 工具增多时优先处理。
|
||||
///
|
||||
@@ -70,7 +70,7 @@ pub(crate) fn is_pending_placeholder(content: &str) -> bool {
|
||||
///
|
||||
/// `session` 只读扫描 messages(不写),调用方据返回值决定是否跳过 insert pending。
|
||||
///
|
||||
/// F-260616-09 B 批2:经`conv_id` 索引 per_conv.messages(顶层 messages 批2 后是死字段)。
|
||||
/// 经`conv_id` 索引 per_conv.messages(顶层 messages 在拆分存储后是死字段)。
|
||||
/// conv_id 来源:process_tool_calls 入参 → 由 agentic/mod.rs run_agentic_loop 入参透传。
|
||||
pub(crate) async fn find_cached_high_risk_result(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
@@ -84,12 +84,12 @@ pub(crate) async fn find_cached_high_risk_result(
|
||||
// 规范化新调用的 args 为可比字符串(排序键,键序无关)
|
||||
let new_args_key = canonical_args_key(args);
|
||||
|
||||
// BUG-260624-03/P0 重构:短 lock 段读 messages + 找旧 tool_call_id + 旧 tool_result content,
|
||||
// 短 lock 段读 messages + 找旧 tool_call_id + 旧 tool_result content,
|
||||
// drop 锁后再锁外 await DB 查 status(原代码持锁 await audit_repo,违反持锁 await 慢操作禁令)。
|
||||
// 第一步:锁内(async block 包裹,出 block 自动 drop guard)反向扫描,定位旧 tool_call_id 与对应 tool_result content
|
||||
let cached: Option<(String, String)> = (async {
|
||||
let session = session_arc.lock().await;
|
||||
// F-260616-09 B 批2:读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv,
|
||||
// 读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv,
|
||||
// 故 conv_read 必命中;防御性 None 时返 None(无缓存命中,走原审批流程)。
|
||||
let Some(conv) = session.conv_read(conv_id) else { return None };
|
||||
// ContextManager::iter 返回 impl Iterator(非 DoubleEnded),collect 成 Vec 再反向遍历。
|
||||
@@ -134,7 +134,7 @@ pub(crate) async fn find_cached_high_risk_result(
|
||||
continue;
|
||||
}
|
||||
// 命中旧 tool_result:排除 pending 占位(基础文本或带 __PENDING__:tc_id 标记)
|
||||
// 阶段2:占位带标记后不再精确等于基础文本,改用 is_pending_placeholder 匹配。
|
||||
// 占位带标记后不再精确等于基础文本,改用 is_pending_placeholder 匹配。
|
||||
// 加固(子串误伤):`__PENDING__` 标记是 audit/cache.rs 占位模板独占信号(权威判定);
|
||||
// 老占位分支已收紧为精确全文等值(非 starts_with 前缀),杜绝用户真实 tool_result 内容
|
||||
// 恰以"需要用户审批..."开头被误判占位致去重误吞(详见 context_helpers::is_pending_placeholder)。
|
||||
@@ -167,7 +167,7 @@ fn canonical_args_key(args: &serde_json::Value) -> String {
|
||||
serde_json::to_string(&v).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// BUG-2026-07-07/P0-2:只读幂等工具结果缓存(治 LLM 死循环重调)。
|
||||
/// 只读幂等工具结果缓存(治 LLM 死循环重调)。
|
||||
///
|
||||
/// **根因链(实测 9357c27c)**:LLM 无"已调用过"记忆,对同参只读工具反复触发:
|
||||
/// - `read_file(application.xml)` 读了 17 次(两次返回完全相同的 8457c 全文)
|
||||
@@ -205,7 +205,7 @@ pub(crate) async fn find_cached_readonly_result(
|
||||
|
||||
let new_args_key = canonical_args_key(args);
|
||||
|
||||
// BUG-260624-03/P0 重构:短 lock 段读 messages + 锁外 await DB 查 status(原代码持锁 await)
|
||||
// 短 lock 段读 messages + 锁外 await DB 查 status(原代码持锁 await)
|
||||
let cached: Option<(String, String)> = (async {
|
||||
let session = session_arc.lock().await;
|
||||
let Some(conv) = session.conv_read(conv_id) else { return None };
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
//! AE-2025-03(路径 B):write_file 审批预览 diff 生成。
|
||||
//! write_file 审批预览 diff 生成。
|
||||
//!
|
||||
//! 第五批从 audit/mod.rs 抽离,行为零变更。`build_write_file_diff` 仅 process_tool_calls
|
||||
//! 在 write_file 高风险审批分支裸名调用,依赖 `super::super::tool_registry::generate_diff`
|
||||
//! (F-260615-10 LCS 行级 diff),故 diff.rs 直接 `use super::super::tool_registry::generate_diff`。
|
||||
//! (LCS 行级 diff),故 diff.rs 直接 `use super::super::tool_registry::generate_diff`。
|
||||
//!
|
||||
//! re-export 保持原路径透明:audit/mod.rs 通过 `use diff::build_write_file_diff` 裸名调用。
|
||||
|
||||
use super::super::tool_registry::generate_diff;
|
||||
|
||||
/// 从 write_file args 取 path(旧文件路径)+ content(新内容),
|
||||
/// 预读旧文件 → 复用 `generate_diff`(F-260615-10 LCS 行级 diff)注入审批事件。
|
||||
/// 预读旧文件 → 复用 `generate_diff`(LCS 行级 diff)注入审批事件。
|
||||
/// 旧文件不存在(新建)/ 读失败 / args 缺字段 → None(前端回退显新 content)。
|
||||
///
|
||||
/// **仅读不改**:审批未通过前不动文件;读路径不校验(write_file handler 自身会校验
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::state::AppState;
|
||||
/// 导致审批后审计记录永久卡 pending。
|
||||
pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status: &str, result: Option<String>) {
|
||||
// 区分 Err(DB 故障)与 Ok(None)(真无记录):原 unwrap_or_default 把 Err 压成 None,
|
||||
// DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索(B-260617-17 同款吞错)。
|
||||
// DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索。
|
||||
let mut rec = match state.ai_tool_executions.find_by_tool_call_id(tool_call_id).await {
|
||||
Ok(Some(rec)) => rec,
|
||||
Ok(None) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! F-260619-04 P2(方案 B): 灵感来源(create_idea)消息级溯源补全。
|
||||
//! 灵感来源(create_idea)消息级溯源补全。
|
||||
//!
|
||||
//! ## 背景
|
||||
//! P1 把知识/审计的 `source_ref` 升级到消息级(`conv_msg:{id}`),但**灵感 `create_idea`
|
||||
|
||||
@@ -20,16 +20,16 @@ use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApprov
|
||||
mod utils;
|
||||
pub(crate) use utils::{risk_from_str, risk_str, truncate_chars};
|
||||
|
||||
// diff(audit/diff.rs):AE-2025-03 write_file 审批预览 diff 生成。
|
||||
// diff(audit/diff.rs):write_file 审批预览 diff 生成。
|
||||
// 第五批从本文件抽离,行为零变更。
|
||||
mod diff;
|
||||
|
||||
// path_auth(audit/path_auth.rs):F-260619-03 Phase B/C 路径授权预校验。
|
||||
// path_auth(audit/path_auth.rs):路径授权预校验。
|
||||
// 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。
|
||||
mod path_auth;
|
||||
pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome};
|
||||
|
||||
// approval(audit/approval.rs):F-#97/AE-04/阶段4 审批门控逻辑。
|
||||
// approval(audit/approval.rs):审批门控逻辑。
|
||||
// 第六批从本文件抽离,行为零变更。use 保持 process_tool_calls 裸名调用。
|
||||
mod approval;
|
||||
use approval::{detect_retry_count, handle_approval_tool};
|
||||
@@ -63,8 +63,8 @@ pub use restore::restore_pending_approvals;
|
||||
mod finalize;
|
||||
pub(crate) use finalize::audit_finalize;
|
||||
|
||||
// cache(audit/cache.rs):F-260616-05 高危工具去重缓存 + BUG-2026-07-07/P0-2 只读工具缓存。
|
||||
// 第三批从本文件抽离,行为零变更(P0-2 为新增能力)。
|
||||
// cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。
|
||||
// 第三批从本文件抽离,行为零变更。
|
||||
mod cache;
|
||||
pub(super) use cache::{find_cached_readonly_result, pending_placeholder_for};
|
||||
|
||||
@@ -74,7 +74,7 @@ pub(super) use cache::{find_cached_readonly_result, pending_placeholder_for};
|
||||
mod data_change;
|
||||
pub(crate) use data_change::emit_data_changed;
|
||||
|
||||
// idea_source(audit/idea_source.rs):F-260619-04 P2(方案 B)灵感来源消息级溯源补全。
|
||||
// idea_source(audit/idea_source.rs):灵感来源消息级溯源补全。
|
||||
// create_idea 工具执行后,若 AI 未填 source 且有 message_id → 补 conv_msg:{id}(低侵入,不改 handler 接口)。
|
||||
// pub(crate) use 供本文件 process_tool_calls + chat.rs 审批执行路径调用(单点逻辑,多调用点)。
|
||||
mod idea_source;
|
||||
@@ -89,7 +89,7 @@ pub(crate) use idea_source::maybe_fill_idea_source;
|
||||
/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs
|
||||
/// stream_llm select! 心跳语义),execute 完 abort 心跳 task。
|
||||
///
|
||||
/// 根治 BUG-260624-03:工具执行在 stream_llm 之外(本模块),原本无 AiHeartbeat。
|
||||
/// 根治:工具执行在 stream_llm 之外(本模块),原本无 AiHeartbeat。
|
||||
/// 单次 execute 超过前端 STREAM_TIMEOUT_MS(130s)——bash 跑 cargo/测试、read 大文件、
|
||||
/// 全盘 search 等开发长命令——前端 watchdog 误判断流,抛"工具已执行完成后续中断"误报
|
||||
/// (实测:用户报"一边流一边抛",前一轮 delta 文本在屏 + 当前轮工具执行静默 > 130s)。
|
||||
@@ -137,19 +137,35 @@ async fn execute_with_heartbeat(
|
||||
});
|
||||
// RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。
|
||||
let _guard = HeartbeatGuard { stop, handle: heartbeat };
|
||||
// BUG-2026-07-19: tools.execute 无 timeout 时,卡死工具(run_command 长命令/read_file 大文件/同步
|
||||
// tools.execute 无 timeout 时,卡死工具(run_command 长命令/read_file 大文件/同步
|
||||
// 阻塞工具)永久挂起 → process_tool_calls 持 session lock 永久 → guard.reset 等 lock → AiCompleted
|
||||
// 永不发 → 前端"回答完卡住/超时清空"。60s timeout 兜底:超时返错误 tool_result,锁释放,loop 续跑。
|
||||
// 心跳 30s 续命前端 watchdog,60s timeout 覆盖绝大多数工具(run_command 已自带 10s 子超时)。
|
||||
match tokio::time::timeout(Duration::from_secs(60), tools.execute(name, args)).await {
|
||||
// 永不发 → 前端"回答完卡住/超时清空"。外层 timeout 兜底:超时返错误 tool_result,锁释放,loop 续跑。
|
||||
// 心跳 30s 续命前端 watchdog。
|
||||
//
|
||||
// 外层 timeout 取值(BUF-run-cmd-timeout):原硬编码 60s 会先于 run_command 自带 timeout_secs
|
||||
// (上限 600s)drop,致 run_command 跑长构建(cargo/npm)永远到不了用户配的 timeout_secs。
|
||||
// run_command 分支从 args.timeout_secs 取值(clamp [60,600],默认 60,与 tool_registry.rs:2707
|
||||
// run_command 内部 clamp 同源),其余工具仍 60s。run_command 内部还有自己的子 timeout,
|
||||
// 外层只需 >= 内部 timeout 即不抢断(run_command 内部超时会返带语义的 tool_result,优于外层裸中止)。
|
||||
let outer_secs: u64 = if name == "run_command" {
|
||||
args.get("timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(60)
|
||||
.clamp(60, 600)
|
||||
} else {
|
||||
60
|
||||
};
|
||||
match tokio::time::timeout(Duration::from_secs(outer_secs), tools.execute(name, args)).await {
|
||||
Ok(result) => result,
|
||||
Err(_elapsed) => {
|
||||
tracing::error!(
|
||||
conv_id = %conv_id,
|
||||
tool = %name,
|
||||
"[ai] 工具执行超时(60s),返回错误 tool_result(防 process_tool_calls 持 session lock 永久卡死)"
|
||||
outer_secs,
|
||||
"[ai] 工具执行超时({}s),返回错误 tool_result(防 process_tool_calls 持 session lock 永久卡死)",
|
||||
outer_secs
|
||||
);
|
||||
Err(anyhow::anyhow!("工具执行超时(60s),已中止(防死锁)"))
|
||||
Err(anyhow::anyhow!("工具执行超时({}s),已中止(防死锁)", outer_secs))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,26 +180,26 @@ pub(crate) async fn process_tool_calls(
|
||||
) -> usize {
|
||||
let mut tc_list: Vec<_> = tool_calls_acc.into_iter().collect();
|
||||
tc_list.sort_unstable_by_key(|(i, _)| *i);
|
||||
// B-260616-21 治本兜底:LLM 异常复用同 tool_use.id(stream_recv 按 content_block index 分桶,
|
||||
// 治本兜底:LLM 异常复用同 tool_use.id(stream_recv 按 content_block index 分桶,
|
||||
// 同 id 不同 index draft 可并存 → 每 draft emit AiToolCallStarted 致同 id emit 两次 → 前端 push 两卡,
|
||||
// Completed 按 id 只 update 首张 → 次张残留 running 0行)。process 层按 id 去重——同 id 保留
|
||||
// 最小 index 的首个,丢弃后续,保证 emit Started 的 id 唯一。前端 useAiEvents.ts:205 findToolCall
|
||||
// 守卫双保险。详 docs/02-架构设计/已编号方案/B-260616-21排查方案-2026-06-16.md。
|
||||
// 守卫双保险。
|
||||
let mut seen_ids: HashSet<String> = HashSet::new();
|
||||
tc_list.retain(|(_, draft)| seen_ids.insert(draft.id.clone()));
|
||||
let mut pending_count = 0usize;
|
||||
let audit_repo = AiToolExecutionRepo::new(db);
|
||||
|
||||
// F-260619-04 P1 消息级溯源:取当前 assistant 消息 id。
|
||||
// P1 消息级溯源:取当前 assistant 消息 id。
|
||||
// 调用前 agentic/mod.rs 已把本轮 assistant_with_tools 消息(LLM 返回带 tool_calls 的那条)
|
||||
// push 到 per_conv.messages(audit/mod.rs:882),此处取末条 assistant id 作为本轮工具
|
||||
// 调用所属的溯源 message_id,贯穿所有 audit_tool_call 写入。None 表示无 assistant 消息
|
||||
// (异常路径/老数据无 id),audit 落 message_id=None,展示侧兼容。
|
||||
//
|
||||
// BUG-260624-03/P0 重构:短 lock 段读 current_message_id(纯读,无 await),clone 出来后续传参。
|
||||
// 短 lock 段读 current_message_id(纯读,无 await),clone 出来后续传参。
|
||||
// 整个 process_tool_calls 不再要求调用方持锁,内部所有 session.xxx 访问均短 lock 段,
|
||||
// 慢操作(execute_with_heartbeat/audit_tool_call/detect_retry_count/DB 查)全在锁外 await。
|
||||
// BUG-2026-07-19: current_message_id 读加 200ms timeout(防 session lock 竞争卡死致
|
||||
// current_message_id 读加 200ms timeout(防 session lock 竞争卡死致
|
||||
// process_tool_calls 进不去 emit AiToolCallStarted → 前端工具卡片不呈现 + 45s 看门狗超时)。
|
||||
// 超时用 None(溯源降级,非致命)。治本:消除 session lock 长持有(save clone 段已优化)。
|
||||
let current_message_id: Option<String> = match tokio::time::timeout(
|
||||
@@ -226,7 +242,7 @@ pub(crate) async fn process_tool_calls(
|
||||
})
|
||||
.collect();
|
||||
|
||||
// ── F-260619-03 Phase B/C:文件工具路径授权预校验 ──
|
||||
// ── 文件工具路径授权预校验 ──
|
||||
// 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search)
|
||||
// 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单):
|
||||
// - 任一路径命中黑名单 → Denied:push 错误 tool_result + emit Completed,不挂起(Phase C)
|
||||
@@ -257,7 +273,7 @@ pub(crate) async fn process_tool_calls(
|
||||
// Phase C: Denied 路径 → 硬拒(push 错误 tool_result + emit Completed),不挂起 loop。
|
||||
// 工具返 Err 让 LLM 知路径被禁,自行调整;loop 继续下一轮(不暂停)。
|
||||
for (draft, reason) in path_denied {
|
||||
// BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
|
||||
// 拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
|
||||
let err_msg = serde_json::json!({
|
||||
"status": "rejected",
|
||||
"reason": "path_blacklist",
|
||||
@@ -294,7 +310,7 @@ pub(crate) async fn process_tool_calls(
|
||||
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
|
||||
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。
|
||||
//
|
||||
// BUG-260624-03/P0 重构:detect_retry_count(DB 读)/emit/audit_tool_call(DB 写)均在锁外,
|
||||
// detect_retry_count(DB 读)/emit/audit_tool_call(DB 写)均在锁外,
|
||||
// 仅 push tool_result + pending_approvals.insert 两处纯写改短 lock 段。
|
||||
for (draft, args, req) in path_auth_pending {
|
||||
pending_count += 1;
|
||||
@@ -304,7 +320,7 @@ pub(crate) async fn process_tool_calls(
|
||||
.map(|d| d.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let path_str = req.raw_paths.first().cloned().unwrap_or_default();
|
||||
// 锁外:阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
|
||||
// 锁外:(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
|
||||
let retry_count = detect_retry_count(&audit_repo, &draft.id).await;
|
||||
if retry_count >= 1 {
|
||||
let skip_msg = format!(
|
||||
@@ -348,7 +364,7 @@ pub(crate) async fn process_tool_calls(
|
||||
arguments: args.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
recovered: false,
|
||||
// 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
|
||||
// 路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
|
||||
kind: ApprovalKind::Path(req),
|
||||
retry_count,
|
||||
created_at: Some(std::time::SystemTime::now()),
|
||||
@@ -381,7 +397,7 @@ pub(crate) async fn process_tool_calls(
|
||||
|
||||
// 分类:Low 收集并行执行,Med/High 立即进审批门控(push 占位 tool_result)
|
||||
//
|
||||
// F-260616-05:High risk 在进审批门前先查去重缓存(find_cached_high_risk_result)。
|
||||
// High risk 在进审批门前先查去重缓存(find_cached_high_risk_result)。
|
||||
// 若 LLM 重试同命令(同 tool_name + 同 args,键序无关),命中已落定的旧 tool_result,
|
||||
// 把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过 insert pending + 跳过审批,
|
||||
// 断「超时→重试→重新审批」循环。Med 不去重(去重易误伤),Low 无审批本就不进此分支。
|
||||
@@ -431,8 +447,8 @@ pub(crate) async fn process_tool_calls(
|
||||
).await;
|
||||
}
|
||||
|
||||
// AE-04 trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,
|
||||
// BUG-260624-03/P0 重构后调用方不再持锁,本段所有 await 完全在锁外),
|
||||
// trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,
|
||||
// 调用方不再持锁,本段所有 await 完全在锁外),
|
||||
// push tool_result / audit 在 join_all 后串行回填(短 lock push + 锁外 audit)。对齐 Low risk 并行模式。
|
||||
// CR-51 修:原 inline .await execute 串行执行每个工具(阻塞期间锁被持有,run_command 慢命令
|
||||
// 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间。P0 重构后锁外并行,根本消除阻塞。
|
||||
@@ -486,7 +502,18 @@ pub(crate) async fn process_tool_calls(
|
||||
// 短 lock 段:push tool_result(纯写,无 await)
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
|
||||
// T2: 大工具结果进 namespace
|
||||
// 失败包结构化 envelope {"status":"failed","error":...}:让断路器按 status 判
|
||||
// (不解析内容文本,理论可靠)。emit 给前端的 result 与 audit 仍用原 err_msg 纯文本
|
||||
// (前端卡片/审计可读,与断路器信号解耦)。
|
||||
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));
|
||||
}
|
||||
// 锁外:审计(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;
|
||||
@@ -497,13 +524,13 @@ pub(crate) async fn process_tool_calls(
|
||||
// push tool_result / audit 在 join_all 后串行回填(短 lock push + 锁外 audit,与 Med/High 占位拼接)。
|
||||
// join_all 保序——结果顺序 = low_risk 输入顺序 = tc_list 原始 index 顺序,不额外 sort
|
||||
if !low_risk.is_empty() {
|
||||
// BUG-2026-07-07/P0-2:只读幂等工具去重缓存(治 LLM 死循环重调)。
|
||||
// 只读幂等工具去重缓存(治 LLM 死循环重调)。
|
||||
// 实测 9357c27c 会话:LLM 对 read_file(application.xml) 连调 17 次、search_files 连调 13 次,
|
||||
// 每次返回几乎相同结果却反复重调,是 prompt 64 万的直接元凶。此处先查会话内是否已对
|
||||
// 同参只读工具成功执行过,命中则直接回填缓存结果跳过真执行,断 LLM 失忆死循环。
|
||||
// 安全边界见 find_cached_readonly_result 文档(仅白名单只读工具 + 仅 completed 成功结果)。
|
||||
//
|
||||
// BUG-260624-03/P0 重构:find_cached_readonly_result 内部短 lock + 锁外 DB 查,本段不持锁。
|
||||
// find_cached_readonly_result 内部短 lock + 锁外 DB 查,本段不持锁。
|
||||
let mut low_risk_uncached: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::with_capacity(low_risk.len());
|
||||
for (draft, args, risk_level) in low_risk {
|
||||
let cached = find_cached_readonly_result(session_arc, conv_id, &audit_repo, &draft.name, &args).await;
|
||||
@@ -589,7 +616,7 @@ pub(crate) async fn process_tool_calls(
|
||||
Ok(c) => ("completed", c),
|
||||
Err(c) => ("failed", c),
|
||||
};
|
||||
// F-260619-04 P2(方案 B):create_idea source 消息级溯源补全(仅 source 空 + 有 message_id)。
|
||||
// source 消息级溯源补全(仅 source 空 + 有 message_id)。
|
||||
// 注:低风险路径目前 create_idea 不会进(Medium→pending),此处为防御/未来若调级别覆盖。
|
||||
// 仅 Ok(completed) 时补(失败 result 无 idea_id 意义);args 从 draft.args 反解(JSON 原样)。
|
||||
if status == "completed" {
|
||||
@@ -599,7 +626,18 @@ pub(crate) async fn process_tool_calls(
|
||||
// 短 lock 段:写 per_conv.messages(纯写,无 await)
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
|
||||
// T2: 大工具结果进 namespace,主队列只留引用
|
||||
// 失败包结构化 envelope {"status":"failed","error":...}:让断路器按 status 判
|
||||
// (不解析内容文本,理论可靠)。emit 给前端的 result 与 audit 仍用原 err_msg 纯文本
|
||||
// (前端卡片/审计可读,与断路器信号解耦)。
|
||||
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));
|
||||
}
|
||||
// F-#97 审计留痕:low_risk 向量中 risk_level != Low 即 mode 放行(Med 仅 medium/all、High 仅 all 才进)。
|
||||
// decided_by 区分接管来源,审计表可追溯 all 模式执行了多少高危命令(治 securityReview blocker:
|
||||
@@ -613,7 +651,7 @@ pub(crate) async fn process_tool_calls(
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段3a:单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
|
||||
// 单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
|
||||
// 短 lock 段:读 pending_approvals 统计(纯读,无 await)
|
||||
let (path_count, risk_count, pending_approvals_len) = {
|
||||
let session = session_arc.lock().await;
|
||||
@@ -636,7 +674,7 @@ mod tests {
|
||||
|
||||
/// grep 走单路径授权申请路径(NeedsAuth),非 search_files 盲拒(Denied)。
|
||||
///
|
||||
/// F-260621:grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发
|
||||
/// grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发
|
||||
/// AiDirAuthRequired 申请(对齐 read_file),不像 search_files 被硬拒。
|
||||
/// 锁定此差异:grep 与 read_file 同款授权弹窗语义。
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! F-260619-03 Phase B/C: 路径授权预校验(文件工具路径提取 + 白/黑名单判定)。
|
||||
//! 路径授权预校验(文件工具路径提取 + 白/黑名单判定)。
|
||||
//!
|
||||
//! 第六批从 audit/mod.rs 抽离,行为零变更。包含:
|
||||
//! - `extract_file_tool_paths`:从工具参数中提取文件路径
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
use super::PathAuthRequest;
|
||||
|
||||
/// F-260619-03 Phase B: 提取文件工具的路径参数(用于路径授权预校验)。
|
||||
/// 提取文件工具的路径参数(用于路径授权预校验)。
|
||||
///
|
||||
/// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。
|
||||
/// - 单路径工具(read_file/write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep)
|
||||
@@ -37,7 +37,7 @@ pub(crate) fn extract_file_tool_paths(tool_name: &str, args: &serde_json::Value)
|
||||
}
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B/C: 路径授权预校验结果。
|
||||
/// 路径授权预校验结果。
|
||||
pub(crate) enum FileToolAuthOutcome {
|
||||
/// 全部已授权 → 走原 Low/Med/High 流程
|
||||
Authorized,
|
||||
@@ -47,7 +47,7 @@ pub(crate) enum FileToolAuthOutcome {
|
||||
Denied(String),
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B/C: 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。
|
||||
/// 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。
|
||||
///
|
||||
/// - 路径任一命中黑名单 → `Denied(reason)`:硬拒(工具返 Err tool_result,不挂起)
|
||||
/// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`:
|
||||
|
||||
@@ -36,7 +36,7 @@ pub(super) async fn resolve_project_label(db: &Arc<Database>, id: &str) -> Strin
|
||||
|
||||
/// 查任务可读标签:id → "「任务标题」(id=x)",对齐 resolve_project_label 三臂语义。
|
||||
///
|
||||
/// UX-260618-14:advance_task 审批卡的 id 是 task_id,原 build_approval_reason 把 "id"
|
||||
/// advance_task 审批卡的 id 是 task_id,原 build_approval_reason 把 "id"
|
||||
/// 统一走 resolve_project_label(查 projects 表),误把任务 id 当项目 id 解析,永远落到
|
||||
/// "项目已不存在"。本方法改查 tasks 表,与 resolve_project_label 同 Ok(None)/Err 分流。
|
||||
pub(super) async fn resolve_task_label(db: &Arc<Database>, id: &str) -> String {
|
||||
|
||||
@@ -44,7 +44,7 @@ pub(crate) async fn audit_tool_call(
|
||||
.insert(AiToolExecutionRecord {
|
||||
id: new_id(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
// F-260619-04 P1 消息级溯源:message_id 由调用方(process_tool_calls)从
|
||||
// P1 消息级溯源:message_id 由调用方(process_tool_calls)从
|
||||
// ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的
|
||||
// assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。
|
||||
// None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::risk_from_str;
|
||||
/// status=pending 的行(持久化真相源),此处读回重建内存态,使重启后待审批不丢。
|
||||
/// 前端经 ai_pending_tool_calls 查询 + switchConversation 恢复 toolCard 的 pending_approval 态。
|
||||
///
|
||||
/// **F-260616-09 B 批8 多 conv 适配(设计 §3 batch8 + §5.2)**:DB pending 审批按
|
||||
/// **多 conv 适配**:DB pending 审批按
|
||||
/// `conversation_id` 分配到各 conv 的 per_conv state——对每个含 pending 审批的 conv
|
||||
/// **惰性建 `PerConvState`**(不依赖 `active_conversation_id` 单值),使后续 ai_approve →
|
||||
/// try_continue_agent_loop 的 `conv_read(conv_id)` 命中各自 per_conv(各归各,不串)。
|
||||
@@ -45,7 +45,20 @@ pub async fn restore_pending_approvals(state: &AppState) {
|
||||
// 故对每条 pending 都调一次 conv() 是幂等的(后续命中 entry().or_insert_with 跳过新建)。
|
||||
let mut convs_restored: HashSet<String> = HashSet::new();
|
||||
for rec in pending {
|
||||
let args: serde_json::Value = serde_json::from_str(&rec.arguments).unwrap_or_default();
|
||||
// 损坏 arguments 不还原(语义保留:数据异常不恢复)——
|
||||
// unwrap_or_default() 会吞错误置空 {},污染 pending 还原成无参数 tool_call(语义错);
|
||||
// 改为解析失败 warn! + continue 跳过该条,与下方 risk_level 解析失败的损坏过滤语义对齐。
|
||||
let args: serde_json::Value = match serde_json::from_str(&rec.arguments) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"跳过 pending 审批恢复(tool_call_id={}):arguments 解析失败(不还原以免污染无参数语义): {}",
|
||||
rec.tool_call_id,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// 过滤 risk_level 解析失败的损坏记录(语义保留:数据异常不恢复)·不再绑定 risk(PendingApproval.risk_level 已删)
|
||||
if risk_from_str(&rec.risk_level).is_none() {
|
||||
continue;
|
||||
@@ -69,19 +82,19 @@ pub async fn restore_pending_approvals(state: &AppState) {
|
||||
arguments: args,
|
||||
conversation_id: rec.conversation_id,
|
||||
recovered: true,
|
||||
// 阶段3a 单真相源合并:恢复的审批一律 kind=Risk(diff=None,与原顶层 diff 字段同语义)。
|
||||
// 单真相源合并:恢复的审批一律 kind=Risk(diff=None,与原顶层 diff 字段同语义)。
|
||||
//
|
||||
// **path 审批不恢复的决策(语义保留)**:路径授权挂起是会话级状态,重启后 session
|
||||
// 重建,无法恢复挂起语义;且 path 的"always"决策已写入持久白名单(Settings KV),
|
||||
// 重启后白名单仍生效(文件工具路径预校验会直接 Authorized,不再挂起)。故 path 审批
|
||||
// 无需恢复 —— 恢复 risk 即可覆盖所有需人工决策的积压。
|
||||
//
|
||||
// AE-2025-03: 重启恢复的审批不重读旧文件——审批可能跨重启,
|
||||
// 重启恢复的审批不重读旧文件——审批可能跨重启,
|
||||
// 期间文件可能已被外部改动,重读生成 diff 反映的不是当初决策时的状态,
|
||||
// 且恢复路径在 session.lock 内做 async IO 复杂度高,预览价值低。
|
||||
// 前端见 diff=None 时回退显新 content。
|
||||
kind: ApprovalKind::Risk { diff: None },
|
||||
// 阶段4:重启恢复的审批 retry_count=0(恢复语义即"待用户首次决策",非重试)。
|
||||
// 重启恢复的审批 retry_count=0(恢复语义即"待用户首次决策",非重试)。
|
||||
// 即便审计表已有 pending 记录,恢复后用户审批执行属首次正常执行,不断路。
|
||||
retry_count: 0,
|
||||
// 恢复审批 created_at=重启时刻(无原挂起时间记录,按当前系统时间计)。
|
||||
@@ -98,12 +111,12 @@ pub async fn restore_pending_approvals(state: &AppState) {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_f09_batch8_restore {
|
||||
mod tests_multi_conv_restore {
|
||||
use super::super::{ApprovalKind, PendingApproval};
|
||||
use super::*;
|
||||
use crate::commands::ai::AiSession;
|
||||
|
||||
/// F-260616-09 B 批8(设计 §3 batch8 + §5.2):验证 restore_pending_approvals 的多 conv 分配不变量。
|
||||
/// 验证 restore_pending_approvals 的多 conv 分配不变量。
|
||||
///
|
||||
/// restore_pending_approvals 受限于 AppState(需 DB),无法直接单测。但其核心分配逻辑
|
||||
/// 「对每个 conversation_id=Some 的恢复审批,惰性建/复用对应 conv 的 PerConvState」依赖
|
||||
@@ -129,7 +142,7 @@ mod tests_f09_batch8_restore {
|
||||
let _ = session.conv(cid); // 惰性建
|
||||
}
|
||||
}
|
||||
// 阶段3a 单真相源合并:恢复的 pending 全部进 pending_approvals,kind=Risk。
|
||||
// 单真相源合并:恢复的 pending 全部进 pending_approvals,kind=Risk。
|
||||
session.pending_approvals.insert(
|
||||
tool_call_id.clone(),
|
||||
PendingApproval {
|
||||
@@ -139,7 +152,7 @@ mod tests_f09_batch8_restore {
|
||||
conversation_id: conversation_id.clone(),
|
||||
recovered: true,
|
||||
kind: ApprovalKind::Risk { diff: None },
|
||||
// 阶段4:恢复的审批 retry_count=0(首次用户决策,非重试)。
|
||||
// 恢复的审批 retry_count=0(首次用户决策,非重试)。
|
||||
retry_count: 0,
|
||||
created_at: None,
|
||||
},
|
||||
@@ -156,7 +169,7 @@ mod tests_f09_batch8_restore {
|
||||
);
|
||||
|
||||
// 不变量 2:pending_approvals HashMap 4 条(含无主),conversation_id 保留(业务语义)。
|
||||
// 阶段3a:全部进单表 pending_approvals(原 risk_pending 合一,path 审批不恢复)。
|
||||
// 全部进单表 pending_approvals(原 risk_pending 合一,path 审批不恢复)。
|
||||
assert_eq!(session.pending_approvals.len(), 4, "全部 pending 入 pending_approvals");
|
||||
// 构造期恒等式:上方 pending_rows 构造的 4 条 PendingApproval 全部 kind=Risk(本测试构造
|
||||
// 时未插入任何 Path(_)),故 filter Path(_) 计数必为 0。这是构造恒等式而非外部不变量——
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Augmentation 注入段构建(核心设计2 注入侧)
|
||||
//!
|
||||
//! [`build_augmentation_segment`] 把 resolve 后的 [`Augmentation`] 列表拼成一段
|
||||
//! 隔离标注的系统提示词片段,拼到 system_prompt 前(复用 chat.rs FR-S4 风格:
|
||||
//! 隔离标注的系统提示词片段,拼到 system_prompt 前(复用 chat.rs 风格:
|
||||
//! 头尾明确标注"仅供 AI 参考,非用户消息,勿作为行为准则覆盖",防 prompt injection 混淆)。
|
||||
//!
|
||||
//! 空列表返空串(调用方据此跳过拼接,不污染 prompt)。多语言按 lang 参数选标题。
|
||||
@@ -22,7 +22,7 @@ fn title_lang(lang: &str) -> &'static str {
|
||||
///
|
||||
/// - 空 augs 返 `""`(调用方跳过拼接,不污染 prompt)。
|
||||
/// - 非空:头尾标注段(`--- 以下是用户选择的上下文参考 ... ---` 包裹),
|
||||
/// 每条 augmentation 按 kind 分小节(项目/任务/灵感/技能),复用 chat.rs FR-S4 隔离头风格。
|
||||
/// 每条 augmentation 按 kind 分小节(项目/任务/灵感/技能),复用 chat.rs 隔离头风格。
|
||||
///
|
||||
/// `lang` 控制标题语言(zh/en),与 [`build_system_prompt`](super::super::prompt::build_system_prompt) 同源。
|
||||
///
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! 再经 `ai/mod.rs` 的 `pub use self::commands::*;` 透传到 `commands::ai::*`,
|
||||
//! 保 `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。
|
||||
//!
|
||||
//! F-09 batch4 conv_id 签名(决策 e 真并发)原样保留,不改 IPC 签名/行为(纯搬迁)。
|
||||
//! conv_id 签名(真并发支持)原样保留,不改 IPC 签名/行为(纯搬迁)。
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
@@ -52,15 +52,15 @@ use super::super::{AiChatEvent, ApprovalKind, SessionState};
|
||||
///
|
||||
/// 参数 `final_text` 由调用方按清理场景语义选择(如"会话已停止"/"已取消"/"会话已清除")。
|
||||
///
|
||||
/// F-260616-09 B 批4:per_conv.messages 唯一真相源,删顶层 messages 双写。
|
||||
/// per_conv.messages 唯一真相源,删顶层 messages 双写。
|
||||
/// 终态化策略:每条 pending 审批按其自身 `conversation_id` 终态化到对应 conv 的 per_conv.messages;
|
||||
/// conv_id 入参作 fallback(审批无 conversation_id 的无主审批 R-9 异常数据,终态化到入参 conv,
|
||||
/// 入参也空则跳过——审计仍记,占位不残留内存因 pending 即将被 clear/retain)。
|
||||
pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSession, conv_id: &str, final_text: &str) {
|
||||
// SW-260618-02: 先 clone pending 的 tool_call_id(借用在此结束),再可变借 messages。
|
||||
// 必须整体借 &mut session 在函数体内做 disjoint field borrow —— 调用方若分别传
|
||||
// &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502(2026-06-18 主代修)。
|
||||
// 阶段3a 单真相源合并:两类挂起(path + risk)合一进 pending_approvals,占位都需终态化。
|
||||
// &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502。
|
||||
// 单真相源合并:两类挂起(path + risk)合一进 pending_approvals,占位都需终态化。
|
||||
let entries: Vec<(String, Option<String>)> = session
|
||||
.pending_approvals
|
||||
.values()
|
||||
@@ -249,22 +249,22 @@ pub async fn ai_regenerate(
|
||||
let provider_config = super::super::prompt::get_active_provider(&state).await?;
|
||||
|
||||
// 原子占用 generating + 弹出末尾 AI 回复(保留 user 消息)
|
||||
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
|
||||
// conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
|
||||
// (真并发下后台 conv 也应可重新生成);per_conv 唯一真相源,删顶层双写。
|
||||
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争,不占 session lock)。
|
||||
// ConvState 读侧切无锁 conv_states(零锁竞争,不占 session lock)。
|
||||
if !state.conv_states.can_accept_request(&conversation_id) {
|
||||
return Err("AI 正在生成中,请等待完成".to_string());
|
||||
}
|
||||
{
|
||||
let mut session = state.ai_session.lock().await;
|
||||
let conv = session.conv(&conversation_id);
|
||||
// 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口 ConvState→Generating
|
||||
// generating bool 已退役,生成态由 run_agentic_loop 入口 ConvState→Generating
|
||||
// 迁移设置(此处不再手动赋值)。stop_flag/iteration/model_override 仍是 per_conv 独立字段。
|
||||
conv.stop_flag.store(false, Ordering::SeqCst);
|
||||
conv.agent_language = language.clone();
|
||||
// F-260616-11: 重生成 = 新生命周期起点,iteration 从头计数。
|
||||
// 重生成 = 新生命周期起点,iteration 从头计数。
|
||||
conv.iteration_used = 0;
|
||||
// F-01 阶段6: 记录用户指定模型 override(主对话专用,兜底见 run_agentic_loop)。
|
||||
// 记录用户指定模型 override(主对话专用,兜底见 run_agentic_loop)。
|
||||
conv.model_override = model_override.clone();
|
||||
let popped = conv.messages.pop_last_assistant_round();
|
||||
if !popped {
|
||||
@@ -278,7 +278,7 @@ pub async fn ai_regenerate(
|
||||
.map(|m| matches!(m.role, df_ai::provider::MessageRole::User))
|
||||
.unwrap_or(false);
|
||||
if !last_is_user {
|
||||
// 批3 收口:generating bool 已退役,此处仅 Err 返回(ConvState 仍 Idle,入口迁移
|
||||
// generating bool 已退役,此处仅 Err 返回(ConvState 仍 Idle,入口迁移
|
||||
// 在 run_agentic_loop 内执行,本路径尚未进入 loop,无需手动复位)。
|
||||
return Err("没有可重新生成的回复".to_string());
|
||||
}
|
||||
@@ -1209,11 +1209,18 @@ pub async fn ai_chat_clear_context(
|
||||
return Ok(());
|
||||
}
|
||||
// 对保护区外 active 消息标 archived_segment(messages_mut 直接改 status)。
|
||||
// msg-split-phase1:改既有消息 status,需标记全量重写(append-only 增量路径不覆盖旧行)。
|
||||
// 借用结束后单独调 mark_needs_full_rewrite(借用冲突:循环持 &mut 切片时不能再持 &mut self)。
|
||||
let mut dirtied = false;
|
||||
for t in conv.messages.messages_mut()[..protect_start].iter_mut() {
|
||||
if t.message.is_active() {
|
||||
t.message.status = Some(MessageStatus::ArchivedSegment);
|
||||
dirtied = true;
|
||||
}
|
||||
}
|
||||
if dirtied {
|
||||
conv.messages.mark_needs_full_rewrite();
|
||||
}
|
||||
let __hold1145 = __lock_t1145.elapsed();
|
||||
if __hold1145 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_clear_context:1145 持锁 {:?} (含 lock 等待)", __hold1145);
|
||||
@@ -1358,7 +1365,7 @@ pub async fn ai_chat_compress_context(
|
||||
&conv_id,
|
||||
&llm_concurrency,
|
||||
).await {
|
||||
Ok(s) => s,
|
||||
Ok(s) => s.nl_summary,
|
||||
Err(e) => {
|
||||
// LLM 失败:不阻塞,set_compressing(false),消息状态不变(未调 compress_old_messages)
|
||||
let __lock_t1295 = std::time::Instant::now();
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! 路由(Lite 智力, 无 tool_use, 标准成本上限)兜底 `default_model`。失败返 `Err`,
|
||||
//! 调用方按场景降级(手动压缩报错给用户;自动压缩降级为原裁剪行为)。
|
||||
|
||||
use df_ai::context_helpers::CompressedSummary;
|
||||
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
|
||||
// F-15: 压缩路由 — TaskRequirements(Standard 智力即可,无需工具)。压缩是结构化
|
||||
// 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。
|
||||
@@ -49,7 +50,7 @@ pub(crate) async fn compress_via_llm(
|
||||
lang: &str,
|
||||
conv_id: &str,
|
||||
llm_concurrency: &LlmConcurrency,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<CompressedSummary, String> {
|
||||
if active_msgs.is_empty() {
|
||||
return Err("无可压缩消息(active 消息为空)".to_string());
|
||||
}
|
||||
@@ -97,7 +98,24 @@ pub(crate) async fn compress_via_llm(
|
||||
// LLM 并发限流(压缩属独立调用,纳入双层 Semaphore)
|
||||
// F-09 B 批5: per_conv 改 HashMap<conv_id>,压缩在 loop 内针对本对话,用 conv_id 共享限流槽。
|
||||
let _global_permit = llm_concurrency.acquire_global().await;
|
||||
let _per_conv_permit = llm_concurrency.acquire_per_conv(conv_id).await;
|
||||
// compress-permit-try: per_conv 改非阻塞 try。loop 入口已持 1 个 per_conv permit,
|
||||
// set_per_conv(1) 后 per_conv Semaphore 仅 1 permit 被入口占满,此处阻塞 acquire 将永久等待
|
||||
// → is_compressing 永真 → 卡死。改 try_acquire_per_conv:拿不到(None)提前返 Err 降级
|
||||
// (maybe_auto_compress 走失败兜底分支:关键词摘要 + set_compressing(false),下轮再试),非崩溃。
|
||||
let _per_conv_permit = match llm_concurrency.try_acquire_per_conv(conv_id).await {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
active_count,
|
||||
"[ai] 压缩 per_conv permit 已耗尽(loop 入口占用),降级返回走兜底分支,下轮再试"
|
||||
);
|
||||
return Err(format!(
|
||||
"压缩 per_conv 并发限流槽已耗尽(active {} 条,loop 入口占用;降级走兜底,下轮再试)",
|
||||
active_count
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// F-15 卡死根治(2026-06-21 诊断):provider.complete 原无超时,LLM hang → agentic loop
|
||||
// (agentic/mod.rs:780)await 永不返 → run_agentic_loop 永挂 → generating 卡死
|
||||
@@ -115,7 +133,13 @@ pub(crate) async fn compress_via_llm(
|
||||
if summary.is_empty() {
|
||||
return Err("LLM 压缩返回空摘要".to_string());
|
||||
}
|
||||
Ok(summary)
|
||||
// T3: 解析四段式输出为 JSON 卡片 + NL 摘要
|
||||
// 从现有 ## Intent / ## Decisions / ## Files / ## Constraints 格式解析
|
||||
let json_card = parse_sections_to_json(&summary);
|
||||
Ok(CompressedSummary {
|
||||
json_card,
|
||||
nl_summary: summary,
|
||||
})
|
||||
}
|
||||
|
||||
/// 阶段2(占位配对完整性):检测压缩段是否含未闭合占位 tool_result。
|
||||
@@ -151,6 +175,55 @@ fn compress_segment_has_unclosed_placeholder(msgs: &[ChatMessage]) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// 从四段式 Markdown 摘要中解析出 JSON 卡片
|
||||
/// 段标题: ## Intent / ## Decisions / ## Files / ## Constraints
|
||||
fn parse_sections_to_json(summary: &str) -> String {
|
||||
let mut topics = Vec::new();
|
||||
let mut decisions = Vec::new();
|
||||
let mut key_files = Vec::new();
|
||||
|
||||
let mut current_section: Option<&str> = None;
|
||||
for line in summary.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("## ") {
|
||||
current_section = trimmed.strip_prefix("## ");
|
||||
continue;
|
||||
}
|
||||
let text = trimmed.trim_start_matches("- ").trim();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match current_section {
|
||||
Some("Intent") | Some("意图") => {
|
||||
topics.push(text.to_string());
|
||||
}
|
||||
Some("Decisions") | Some("决策") => {
|
||||
decisions.push(serde_json::json!({"summary": text}));
|
||||
}
|
||||
Some("Files") | Some("文件") => {
|
||||
key_files.push(serde_json::json!({"change": text}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
serde_json::json!({
|
||||
"topics": topics,
|
||||
"decisions": decisions,
|
||||
"key_files": key_files,
|
||||
"parsed_from": "four_section_summary",
|
||||
}).to_string()
|
||||
}
|
||||
|
||||
/// 在文本中提取标记间的内容
|
||||
#[allow(dead_code)]
|
||||
fn extract_between<'a>(text: &'a str, start_marker: &str, end_marker: &str) -> Option<&'a str> {
|
||||
let start = text.find(start_marker)?;
|
||||
let after_start = &text[start + start_marker.len()..];
|
||||
let end = after_start.find(end_marker)?;
|
||||
Some(after_start[..end].trim())
|
||||
}
|
||||
|
||||
/// 清理 LLM 返回的摘要:去首尾空白 / 包裹性 markdown 代码围栏,空则原样返回
|
||||
/// (调用方据空值报错)。保留内部 markdown 结构(## 标题 / 列表),摘要本身就是结构化文本。
|
||||
///
|
||||
|
||||
@@ -205,14 +205,23 @@ async fn save_conversation_inner(
|
||||
// 致 guard.reset/process_tool_calls 等 session lock 竞争超时(aichat 卡死连环:工具卡片不呈现+
|
||||
// 回答完卡住+下条进队列)。truncate 在 clone 副本上操作,锁外执行不污染 session 真相源,行为等价。
|
||||
let __lock_start = std::time::Instant::now();
|
||||
let (mut msgs, provider_id, created_at, pinned_goals) = {
|
||||
// msg-split-phase1:同时读 persisted_msg_count + needs_full_rewrite(增量写路径判定用)。
|
||||
// 读基线与读 messages 在同一 lock 段(快,无 await),保证两者一致性快照。
|
||||
// 借用顺序:先 clone session 顶层字段(immutable borrow),再 conv(conv_id) mutable borrow,
|
||||
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。
|
||||
let (mut msgs, provider_id, created_at, pinned_goals, persisted_count, needs_full_rewrite) = {
|
||||
let mut session = session_arc.lock().await;
|
||||
let __wait = __lock_start.elapsed();
|
||||
let provider_id = session.active_provider_id.clone();
|
||||
let created_at = session.active_conv_created_at.clone();
|
||||
let conv = session.conv(conv_id);
|
||||
let cloned = (
|
||||
session.conv(conv_id).messages.all_messages_clone(),
|
||||
session.active_provider_id.clone(),
|
||||
session.active_conv_created_at.clone(),
|
||||
session.conv(conv_id).pinned_goals.clone(),
|
||||
conv.messages.all_messages_clone(),
|
||||
provider_id,
|
||||
created_at,
|
||||
conv.pinned_goals.clone(),
|
||||
conv.messages.persisted_msg_count(),
|
||||
conv.messages.needs_full_rewrite(),
|
||||
);
|
||||
let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长)
|
||||
cloned
|
||||
@@ -222,6 +231,17 @@ async fn save_conversation_inner(
|
||||
eprintln!("[LOCK-DIAG] save clone 段持锁 {:?} (含等待,>50ms 报告,定位 session lock 长持有者)", __lock_total);
|
||||
}
|
||||
// truncate 在锁外(clone 副本上操作,不影响 session 真相源)
|
||||
// T2: 展开 namespace 引用为原文再落库
|
||||
{
|
||||
let session = session_arc.lock().await;
|
||||
for m in &mut msgs {
|
||||
if df_ai::namespace_store::is_namespace_ref(&m.content) {
|
||||
if let Some(original) = session.namespace_store.read_only(&m.content) {
|
||||
m.content = original.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for m in &mut msgs {
|
||||
// P0-2:tool result 是结构化 JSON(provider 读工具返回原样),
|
||||
// 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL。
|
||||
@@ -252,8 +272,18 @@ async fn save_conversation_inner(
|
||||
// F-260619-03 批次 B:映射 Vec<ChatMessage> → Vec<AiMessageRecord>(带 seq 索引 + conv_id)
|
||||
// 全量重写 ai_messages(单事务 DELETE + INSERT OR IGNORE,原子无中间空窗)。
|
||||
// created_at 用对话级 created_at(老对话 None 时 now 兜底),保证消息创建时间与对话一致。
|
||||
//
|
||||
// msg-split-phase1:增加 append-only 增量写路径(治"50+ 轮对话每轮全量 DELETE+INSERT 重写"):
|
||||
// - 纯追加(常态):needs_full_rewrite=false 且 len > persisted_count → 只 insert_batch
|
||||
// records[persisted_count..len](INSERT OR IGNORE 幂等,新消息 id 稳定不冲突),
|
||||
// 跳过 replace_conversation 的全表 DELETE+重插(省 N 倍写入 + 页链重整)。
|
||||
// - 脏路径(compress/edit/replace/pop/insert_at/clear 触发 needs_full_rewrite=true):
|
||||
// 内存与 DB 可能脱钩(既有行 status/content 变了 / len 缩了 / 中段插了),
|
||||
// 走全量 replace_conversation 收敛(DELETE + INSERT 全部),保证最终一致。
|
||||
// 首次落库(Ok(None))走 insert_batch(空表,直接插全量,无需 DELETE)。
|
||||
let now = now_millis();
|
||||
let msg_created_at = created_at.clone().unwrap_or_else(|| now.clone());
|
||||
let total_len = persist_msgs.len();
|
||||
let records: Vec<df_storage::models::AiMessageRecord> = persist_msgs
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -262,6 +292,10 @@ async fn save_conversation_inner(
|
||||
|
||||
let conv_repo = AiConversationRepo::new(db);
|
||||
let msg_repo = df_storage::crud::AiMessageRepo::new(db);
|
||||
// msg-split-phase1:本轮 save 的消息写入是否成功(成功才推进 persisted_count 基线)。
|
||||
// 失败保基线不变 → 下次 save 仍会尝试写这些消息(INSERT OR IGNORE 幂等 / 全量重写收敛),
|
||||
// 不丢消息。默认 false(保守:出错时不推进)。
|
||||
let mut msgs_written_ok = false;
|
||||
match conv_repo.get_by_id(conv_id).await {
|
||||
Ok(Some(mut rec)) => {
|
||||
// 已落库:更新对话元数据(token/model/updated_at);messages JSON 列**不赋新值**(保留旧值作备份)。
|
||||
@@ -293,9 +327,35 @@ async fn save_conversation_inner(
|
||||
if let Err(e) = conv_repo.update_full(&rec).await {
|
||||
tracing::warn!("更新对话元数据失败 {conv_id}: {e}");
|
||||
}
|
||||
// 消息拆分存储:全量重写 ai_messages
|
||||
if let Err(e) = msg_repo.replace_conversation(conv_id, records).await {
|
||||
tracing::warn!("全量重写 ai_messages 失败 {conv_id}: {e}");
|
||||
// 消息拆分存储(msg-split-phase1 增量优化):
|
||||
if !needs_full_rewrite && total_len == persisted_count {
|
||||
// 无变化捷径:消息条数未变且无 dirty → DB ai_messages 与内存完全一致,
|
||||
// 跳过 ai_messages 写入(省整次 DELETE+INSERT,仅元数据 update_full 已写过)。
|
||||
// 场景:loop 中段多次 save 同一消息集合(如 MidStream 保文前后的状态同步);
|
||||
// 元数据(token/updated_at/pinned_goals/pending_approvals)仍走上方 update_full。
|
||||
msgs_written_ok = true;
|
||||
} else if !needs_full_rewrite && total_len > persisted_count {
|
||||
// append-only 增量:只插 [persisted_count, total_len) 新切片。
|
||||
// records 的 seq 是全量索引(persisted_count 处即下一条新消息的 seq),
|
||||
// 切片 [persisted_count..] 正好是本轮新增消息。
|
||||
let new_records: Vec<df_storage::models::AiMessageRecord> =
|
||||
records[persisted_count.min(total_len)..].to_vec();
|
||||
match msg_repo.insert_batch(new_records).await {
|
||||
Ok(()) => { msgs_written_ok = true; }
|
||||
Err(e) => tracing::warn!(
|
||||
"增量写 ai_messages 失败 {conv_id} (slice [{},{}),回退全量重写下轮收敛): {e}",
|
||||
persisted_count, total_len
|
||||
),
|
||||
}
|
||||
} else {
|
||||
// 脏路径或无新增但 dirty:全量重写(DELETE + INSERT 全部),保证最终一致。
|
||||
// needs_full_rewrite=true 时既有行 status/content 可能变了(如 compress 标
|
||||
// compressed 但消息条数没增),必须重写覆盖旧行。
|
||||
if let Err(e) = msg_repo.replace_conversation(conv_id, records).await {
|
||||
tracing::warn!("全量重写 ai_messages 失败 {conv_id}: {e}");
|
||||
} else {
|
||||
msgs_written_ok = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
@@ -323,14 +383,43 @@ async fn save_conversation_inner(
|
||||
if let Err(e) = conv_repo.insert(rec).await {
|
||||
tracing::warn!("落库对话失败 {conv_id}: {e}");
|
||||
}
|
||||
// 消息拆分存储:首次落库同样全量写 ai_messages
|
||||
// (records 已用 conv_created 作 created_at,与对话记录一致)
|
||||
if let Err(e) = msg_repo.replace_conversation(conv_id, records).await {
|
||||
// 消息拆分存储:首次落库空表,直接 insert_batch 全量(无需 DELETE)。
|
||||
// 原 replace_conversation 也可(DELETE 空表 + INSERT),但 insert_batch 省 DELETE 开销。
|
||||
if let Err(e) = msg_repo.insert_batch(records).await {
|
||||
tracing::warn!("首次写 ai_messages 失败 {conv_id}: {e}");
|
||||
} else {
|
||||
msgs_written_ok = true;
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("读取对话 {conv_id} 失败: {e}"),
|
||||
}
|
||||
|
||||
// msg-split-phase1:写库成功才推进基线(DB 已与内存一致)。
|
||||
// 失败保基线不变:下次 save 会重新尝试写未落库的消息(增量路径重试同 slice,INSERT OR IGNORE
|
||||
// 幂等不冲突;脏路径全量重写收敛)。基线推进必须在锁内与读时一致(本进程内单线程 save 串行)。
|
||||
// 注意:写库期间若有其他操作 push/置 dirty,基线推进可能与最新内存错位——但 advance 用
|
||||
// max 防回退,且 dirty 标志由那次操作自己设(下次 save 仍走全量重写收敛),最终一致。
|
||||
if msgs_written_ok {
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.advance_persisted_count(total_len);
|
||||
}
|
||||
|
||||
// 自动 checkpoint(每 20 轮或总 token > 150k 时创建)
|
||||
{
|
||||
let total_tokens: i64 = persist_msgs.len() as i64;
|
||||
if total_tokens > 0 && total_tokens % 20 == 0 {
|
||||
let snapshot = serde_json::to_string(&persist_msgs).unwrap_or_default();
|
||||
if !snapshot.is_empty() && snapshot.len() < 1_000_000 {
|
||||
let ck_id = format!("ck_{}_{}", conv_id.replace('-', ""), total_tokens);
|
||||
let ck_now = now_millis().to_string();
|
||||
// 参数化绑定(走 AiConversationRepo::insert_checkpoint),替代原 format! 拼 SQL,
|
||||
// 防 snapshot 含引号/特殊字符致注入或损坏。
|
||||
if let Err(e) = conv_repo.insert_checkpoint(&ck_id, conv_id, &snapshot, total_tokens, &ck_now).await {
|
||||
tracing::warn!("checkpoint 写入失败 {conv_id}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -585,8 +585,20 @@ async fn extract_knowledge_from_conversation(
|
||||
.unwrap_or_else(|| "未命名对话".to_string());
|
||||
|
||||
// 消息读取:优先 ai_messages 表(消息拆分存储真相源),表空时 fallback 旧 messages JSON 列(老库兼容)
|
||||
// 注意:DB 查询 Err 不能吞成空 Vec(否则 records.is_empty() 误为真 → 走旧 messages JSON 回退,
|
||||
// 语义错:本应报 DB 故障)。这里显式 match:Ok 正常流程,Err 记 warn 后跳过本轮知识抽取。
|
||||
let msg_repo = AiMessageRepo::new(db);
|
||||
let records = msg_repo.list_by_conversation(conv_id).await.unwrap_or_default();
|
||||
let records = match msg_repo.list_by_conversation(conv_id).await {
|
||||
Ok(records) => records,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
conv_id,
|
||||
"[KNOWLEDGE-EXTRACT] list_by_conversation 失败,跳过本轮知识抽取"
|
||||
);
|
||||
return Ok(0); // DB 故障,不当空数据回退(0 条,不置去重标志,允许下次重试)
|
||||
}
|
||||
};
|
||||
let messages: Vec<ChatMessage> = if !records.is_empty() {
|
||||
records.iter().map(crate::commands::ai::commands::record_to_message).collect()
|
||||
} else {
|
||||
@@ -598,7 +610,19 @@ async fn extract_knowledge_from_conversation(
|
||||
"[KNOWLEDGE-EXTRACT] ai_messages 表为空,回退旧 messages JSON 列(老库兼容)"
|
||||
);
|
||||
}
|
||||
serde_json::from_str(&conv.messages).unwrap_or_default()
|
||||
// 损坏 → match Err 分流:勿 unwrap_or_default 吞成空 Vec(空 Vec 会让 knowledge 抽取基于空上下文,
|
||||
// 误产空知识)。坏数据 warn 后跳过本轮(0 条,不置去重标志,允许下次重试),与 DB 故障同语义。
|
||||
match serde_json::from_str::<Vec<ChatMessage>>(&conv.messages) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
conv_id,
|
||||
"[KNOWLEDGE-EXTRACT] 旧 messages JSON 列解析失败,跳过本轮知识抽取(勿基于空上下文抽取)"
|
||||
);
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
// 过滤 user/assistant,取最后 6 条
|
||||
let recent: Vec<&ChatMessage> = messages
|
||||
|
||||
@@ -44,6 +44,9 @@ pub mod skills;
|
||||
pub mod stream_recv;
|
||||
pub mod title;
|
||||
pub mod tool_registry;
|
||||
// 声明式工具注册集合(tool_registry 拆分第一步 · 试点容器)。
|
||||
// 试点:list_projects 已迁声明式 declare_tool! 宏,其余 47 工具仍在 tool_registry.rs。
|
||||
pub mod tools;
|
||||
|
||||
use agentic::conv_state::{ConvState, ConvStateStore};
|
||||
|
||||
@@ -489,6 +492,26 @@ fn dir_of_path_normalized(path: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 判定 run_command 的命令串是否「破坏性」(rm/del/format/... 等不可逆操作)。
|
||||
///
|
||||
/// 用于会话信任放行门控:破坏性命令**永不**走 session_trust auto 放行,即使同会话
|
||||
/// 已批准过同目录的 run_command,也必须每轮走正常审批(避免「一次批准同目录 →
|
||||
/// rm -rf 任意文件被自动放行」的安全漏洞)。
|
||||
///
|
||||
/// 判定规则:取命令**首 token** 精确匹配,避免前缀法误伤 delta/delve 等以 del 开头的
|
||||
/// 合法命令。黑名单只覆盖「不可逆破坏」类;curl/wget/scp 等下载拷贝类的风险(数据外泄/
|
||||
/// 恶意脚本)由 run_command 的 High risk 审批(用户每轮看完整命令)覆盖,不进黑名单,
|
||||
/// 避免误伤频繁合法下载。
|
||||
/// 局限:不解析 sudo/doas/env 等提权前缀(`sudo rm` 首 token 是 sudo 不命中)——本层是
|
||||
/// 「尽力」补充,主防线仍是 High risk 审批。
|
||||
pub fn is_destructive_command(cmd: &str) -> bool {
|
||||
const DESTRUCTIVE_VERBS: &[&str] = &[
|
||||
"rm", "rmdir", "del", "erase", "format", "remove-item", "dd", "mkfs",
|
||||
];
|
||||
let first = cmd.trim().split_whitespace().next().unwrap_or("").to_lowercase();
|
||||
DESTRUCTIVE_VERBS.iter().any(|v| first == *v)
|
||||
}
|
||||
|
||||
/// run_command / write_file 共用的目录 key 规范化:
|
||||
/// 尝试 canonicalize(去 symlink / .. / 大小写归一),失败回退原字面量 trim。
|
||||
fn normalize_dir_key(dir: &str) -> String {
|
||||
@@ -530,6 +553,8 @@ pub struct AiSession {
|
||||
/// conversation.rs save + title.rs + knowledge_inject.rs + lib.rs L0)读写
|
||||
/// `session.conv(conv_id).*` / `session.conv_read(conv_id)`。顶层单例会话级字段已全部删除。
|
||||
pub per_conv: HashMap<String, PerConvState>,
|
||||
/// T2 工具命名空间存储(运行时缓存,大工具结果不进主队列)
|
||||
pub namespace_store: df_ai::namespace_store::NamespaceStore,
|
||||
}
|
||||
|
||||
impl AiSession {
|
||||
@@ -540,6 +565,7 @@ impl AiSession {
|
||||
active_conv_created_at: None,
|
||||
pending_approvals: HashMap::new(),
|
||||
per_conv: HashMap::new(),
|
||||
namespace_store: df_ai::namespace_store::NamespaceStore::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -853,6 +879,12 @@ pub struct PerConvState {
|
||||
///
|
||||
/// 随会话销毁(per_conv 内存,不落库):会话切换/删除后该标志消失,新会话从 false 开始。
|
||||
pub knowledge_extracted: bool,
|
||||
/// T5 WorkingContext 常驻上下文(结构化目标/决策/步骤/脏标记)
|
||||
pub working_context: df_ai::context_helpers::WorkingContext,
|
||||
/// T4 关联的工作流 ID(由前端/workflow 引擎在启动时设置)
|
||||
pub workflow_id: Option<String>,
|
||||
/// T4 工作流 DAG 摘要(预计算的活跃路径文本,避免运行时加载 DAG)
|
||||
pub workflow_dag_summary: Option<String>,
|
||||
}
|
||||
|
||||
/// 单个目标记录(含状态追踪)。
|
||||
@@ -911,6 +943,9 @@ impl PerConvState {
|
||||
session_trust: HashSet::new(),
|
||||
created_at: None,
|
||||
knowledge_extracted: false,
|
||||
working_context: df_ai::context_helpers::WorkingContext::new(),
|
||||
workflow_id: None,
|
||||
workflow_dag_summary: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ fn env_profile_line() -> String {
|
||||
fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str) {
|
||||
match lang {
|
||||
"en" => (
|
||||
"You are DevFlow's AI assistant. You help users manage projects, tasks, ideas, and workflows.\n\
|
||||
"You are DevFlow's AI assistant. You can manage projects, tasks, ideas, and workflows, as well as analyze code, read/write files, and execute commands.\n\
|
||||
Please respond in English.\n\n\
|
||||
## Capabilities\n\
|
||||
You can perform the following actions via tool calls:\n\
|
||||
@@ -88,7 +88,7 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
|
||||
"\n## Current Tasks\n",
|
||||
),
|
||||
_ => (
|
||||
"你是 DevFlow 的 AI 助手。你帮助用户管理项目、任务、灵感和工作流。\n\
|
||||
"你是 DevFlow 桌面应用的 AI 助手。你可以操作项目/任务/灵感/工作流,也可分析代码、读写文件、执行命令。\n\
|
||||
必须使用简体中文回复,禁止使用繁体中文字符。\n\n\
|
||||
## 当前能力\n\
|
||||
你可以通过工具调用执行以下操作:\n\
|
||||
@@ -192,6 +192,18 @@ pub(crate) async fn build_system_prompt_with_excluded(
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义提示词(设置中配置,追加到系统 prompt 末尾)
|
||||
if let Ok(Some(custom)) = state.settings.get("custom_prompt").await {
|
||||
if !custom.is_empty() {
|
||||
let clean = custom.trim().trim_matches('"');
|
||||
if !clean.is_empty() {
|
||||
prompt.push_str("\n## 自定义指令\n");
|
||||
prompt.push_str(clean);
|
||||
prompt.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prompt
|
||||
}
|
||||
|
||||
|
||||
@@ -446,7 +446,17 @@ async fn route_load_messages(state: &State<'_, AppState>, conversation_id: Strin
|
||||
conv_id = %conversation_id,
|
||||
"[remote_bridge] load_messages ai_messages 表空,回退旧 messages JSON 列(老库未迁移)"
|
||||
);
|
||||
serde_json::from_str(&rec.messages).unwrap_or_default()
|
||||
match serde_json::from_str::<Vec<ChatMessage>>(&rec.messages) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
conv_id = %conversation_id,
|
||||
"[remote_bridge] load_messages 老 messages JSON 解析失败,跳过(推送空列表)"
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ pub fn build_provider_for(
|
||||
record: &AiProviderRecord,
|
||||
) -> Result<Box<dyn df_ai::provider::LlmProvider>, String> {
|
||||
let api_key = df_storage::secret::resolve_provider_secret(record);
|
||||
df_storage::secret::ensure_resolved_key(&record.name, &api_key)?;
|
||||
let api_key = df_storage::secret::ensure_resolved_key(&record.name, &api_key)?;
|
||||
Ok(df_ai::build_provider(
|
||||
&record.provider_type,
|
||||
&record.base_url,
|
||||
|
||||
@@ -291,12 +291,24 @@ type SkillsGuard = RwLockReadGuard<'static, Option<Vec<SkillInfo>>>;
|
||||
/// P1-260617-3:`scan_skills` 同步递归 `fs::read_dir` + `read_to_string`(plugins/marketplaces
|
||||
/// 多层嵌套,Windows 文件多时同步阻塞 tokio runtime)。本函数改 async,慢路径扫盘包
|
||||
/// `spawn_blocking` 隔离(对齐 commands/project.rs detect_stack 模式)。快路径(读锁命中)仍同步无 fs。
|
||||
async fn skills_lock_async() -> SkillsGuard {
|
||||
///
|
||||
/// 锁中毒(P1-260617-3 加固):读写锁 expect 中毒会 panic,技能加载热路径 panic 不可接受。
|
||||
/// 中毒 → tracing::error! 记录 + 返 None(调用方 `skills_cached` 得空 Vec / `read_skill_content_stripped`
|
||||
/// 返 None),不再 panic。中毒通常因持锁 panicking 线程(早期改 *g 时 unwrap)残留,缓存本身可重建,
|
||||
/// 返空后下次 `invalidate_skills` 或进程重启自愈。
|
||||
async fn skills_lock_async() -> Option<SkillsGuard> {
|
||||
// 快路径:读锁命中(无 fs,纯内存)
|
||||
{
|
||||
let g = RwLock::read(&SKILLS).expect("SKILLS poisoned");
|
||||
// 锁中毒不 panic:PoisonError 携 guard 仍可恢复数据,但缓存一致性不保 → 记录后返 None 降级空。
|
||||
let g = match RwLock::read(&SKILLS) {
|
||||
Ok(g) => g,
|
||||
Err(_) => {
|
||||
tracing::error!("SKILLS 读锁中毒,返空技能列表");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if g.is_some() {
|
||||
return g;
|
||||
return Some(g);
|
||||
}
|
||||
}
|
||||
// 慢路径:扫盘(spawn_blocking 隔离同步 fs 递归,防阻塞 tokio runtime)
|
||||
@@ -305,16 +317,28 @@ async fn skills_lock_async() -> SkillsGuard {
|
||||
.map(|res| res.skills)
|
||||
.unwrap_or_default();
|
||||
{
|
||||
let mut g = RwLock::write(&SKILLS).expect("SKILLS poisoned");
|
||||
let mut g = match RwLock::write(&SKILLS) {
|
||||
Ok(g) => g,
|
||||
Err(_) => {
|
||||
tracing::error!("SKILLS 写锁中毒,返空技能列表");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// 另一线程可能已填,二次检查(双检锁)
|
||||
if g.is_none() {
|
||||
*g = Some(scanned);
|
||||
}
|
||||
}
|
||||
// 再取读锁返回(此时必 Some)
|
||||
let g = RwLock::read(&SKILLS).expect("SKILLS poisoned");
|
||||
let g = match RwLock::read(&SKILLS) {
|
||||
Ok(g) => g,
|
||||
Err(_) => {
|
||||
tracing::error!("SKILLS 读锁(慢路径后)中毒,返空技能列表");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
debug_assert!(g.is_some(), "skills_lock 慢路径后必 Some");
|
||||
g
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// 技能扫描结果缓存(进程内;命中即 clone,不重复扫盘)。
|
||||
@@ -326,7 +350,8 @@ async fn skills_lock_async() -> SkillsGuard {
|
||||
/// 隔离同步 fs 防阻塞 tokio runtime(Tauri 单线程 runtime)。快路径(读锁命中)无 fs。
|
||||
pub(crate) async fn skills_cached() -> Vec<SkillInfo> {
|
||||
let g = skills_lock_async().await;
|
||||
g.clone().unwrap_or_default()
|
||||
// 锁中毒 → None → unwrap_or_default() 得空 Vec(对齐 P1-260617-3 中毒降级)。
|
||||
g.and_then(|g| g.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 置缓存为 None,下次 `skills_cached()` 触发重扫。
|
||||
@@ -334,7 +359,15 @@ pub(crate) async fn skills_cached() -> Vec<SkillInfo> {
|
||||
/// `ai_reload_skills` IPC 调用:写锁置 None → 紧接 `skills_cached()` 重扫,
|
||||
/// 实现"改技能不重启即生效"。
|
||||
pub(crate) fn invalidate_skills() {
|
||||
let mut g = RwLock::write(&SKILLS).expect("SKILLS poisoned");
|
||||
// 锁中毒:本就是想置 None 清缓存重建,但中毒时持锁线程已 panic,此处无法恢复一致状态。
|
||||
// 记录错误并提前返回(下次加载由 skills_lock_async 中毒降级返空,进程重启自愈)。
|
||||
let mut g = match RwLock::write(&SKILLS) {
|
||||
Ok(g) => g,
|
||||
Err(_) => {
|
||||
tracing::error!("SKILLS 写锁中毒(invalidate_skills),跳过置 None");
|
||||
return;
|
||||
}
|
||||
};
|
||||
*g = None;
|
||||
}
|
||||
|
||||
@@ -349,8 +382,9 @@ pub(crate) fn invalidate_skills() {
|
||||
/// 否则非 Send 跨 await 点致 future 不 Send(MentionResolver 要求 Send)。guard 用 { } 限作用域。
|
||||
pub(crate) async fn read_skill_content_stripped(name: String) -> Option<String> {
|
||||
// 在作用域内取 path 后立即 drop guard,避免非 Send guard 跨 spawn_blocking await 点
|
||||
// 锁中毒 → skills_lock_async 返 None → ? 早返 None(对齐 P1-260617-3 中毒降级返空 desc)。
|
||||
let path: String = {
|
||||
let g = skills_lock_async().await;
|
||||
let g = skills_lock_async().await?;
|
||||
let skills = g.as_ref()?;
|
||||
let info = skills.iter().find(|s| s.name == name)?;
|
||||
info.path.clone()
|
||||
|
||||
@@ -123,6 +123,31 @@ pub(crate) enum StreamResult {
|
||||
InitFailed { retryable: bool, error: String },
|
||||
}
|
||||
|
||||
/// 将 chunk 携带的 tool_calls delta 累加到累积表。
|
||||
///
|
||||
/// 每个 `ToolCallDelta` 按 `index` 归位到同一个 `ToolCallDraft`:
|
||||
/// - `id` 覆盖(后到的 id 视为权威,匹配 OpenAI/Anthropic 协议行为)
|
||||
/// - `function_name` / `function_arguments` 增量拼接(流式分片到达)
|
||||
///
|
||||
/// 抽取自 stream_llm 的 chunk match arm(原最深 7 层嵌套点),纯状态累加无 emit/return 副作用。
|
||||
fn accumulate_tool_calls(
|
||||
tc_deltas: &[df_ai::provider::ToolCallDelta],
|
||||
tool_calls_acc: &mut HashMap<u32, ToolCallDraft>,
|
||||
) {
|
||||
for tc_delta in tc_deltas {
|
||||
let draft = tool_calls_acc.entry(tc_delta.index).or_default();
|
||||
if let Some(id) = &tc_delta.id {
|
||||
draft.id = id.clone();
|
||||
}
|
||||
if let Some(name) = &tc_delta.function_name {
|
||||
draft.name.push_str(name);
|
||||
}
|
||||
if let Some(args) = &tc_delta.function_arguments {
|
||||
draft.args.push_str(args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式接收 LLM 响应。
|
||||
///
|
||||
/// 三类异常处理(返回 StreamResult 显式区分出口):
|
||||
@@ -339,12 +364,7 @@ pub(crate) async fn stream_llm(
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
if let Some(tc_deltas) = &chunk.tool_calls {
|
||||
for tc_delta in tc_deltas {
|
||||
let draft = tool_calls_acc.entry(tc_delta.index).or_default();
|
||||
if let Some(id) = &tc_delta.id { draft.id = id.clone(); }
|
||||
if let Some(name) = &tc_delta.function_name { draft.name.push_str(name); }
|
||||
if let Some(args) = &tc_delta.function_arguments { draft.args.push_str(args); }
|
||||
}
|
||||
accumulate_tool_calls(tc_deltas, &mut tool_calls_acc);
|
||||
}
|
||||
if let Some(u) = &chunk.usage {
|
||||
final_usage = Some(u.clone());
|
||||
@@ -527,6 +547,7 @@ fn classify_status_or_class(status_or_class: &str) -> bool {
|
||||
// ============================================================
|
||||
// 单测:诊断提取/格式化(纯函数,不发 HTTP、不依赖 app_handle)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,340 @@
|
||||
//! Git 类 AI 工具声明式注册(register_git_tools 6 个迁入)。
|
||||
//!
|
||||
//! 迁自 `tool_registry.rs::register_git_tools`(原 6 个:git_status/git_diff/git_log 只读 +
|
||||
//! git_commit/git_branch/git_merge 写),改用 `declare_tool!` 宏。
|
||||
//!
|
||||
//! 迁移策略(handler 逻辑零变更):
|
||||
//! - handler body 逐字照搬原 `register_git_tools` 内 async move 块(逻辑等价),
|
||||
//! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone();
|
||||
//! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。
|
||||
//! - name/desc/schema/risk 与原手写定义逐字一致。
|
||||
//! - 配套私有 helper(exec_git/run_git_status/run_git_diff/run_git_log)原样搬入本文件,
|
||||
//! 仅 register_git_tools 使用(external module.rs::run_git_status 是同名不同签名的另一函数)。
|
||||
//!
|
||||
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
|
||||
//! 工具名集合稳定(防 rename / 漏注册)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||||
use df_ai::declare_tool;
|
||||
use df_storage::db::Database;
|
||||
|
||||
// ============================================================
|
||||
// Git 私有 helper(原 tool_registry.rs 986-1080 行原样搬入,仅本模块使用)
|
||||
// ============================================================
|
||||
|
||||
/// 在指定目录执行 git 命令(10s 超时,返回 stdout)。失败/超时返回空字符串(非崩溃)。
|
||||
///
|
||||
/// BUG-2026-07-18: 原实现 spawn_blocking 内裸 std::process::Command::output() 无 timeout
|
||||
/// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限,
|
||||
/// spawn_blocking 线程永不返回 → 累计耗尽 tokio blocking 池 → 间接卡死单线程 runtime
|
||||
/// (与 env_snapshot::probe_version 同型病根)。AI 的 git_status/log/diff 工具在会话内高频触发。
|
||||
///
|
||||
/// 改用 tokio::process + tokio::time::timeout(10s) + kill_on_drop:超时 drop 时 child 进程
|
||||
/// 被 kill,不泄漏线程/进程,对齐 shell.rs execute 同源封装。
|
||||
async fn exec_git(working_dir: &str, args: &[&str]) -> String {
|
||||
let mut cmd = tokio::process::Command::new("git");
|
||||
cmd.args(args)
|
||||
.current_dir(working_dir)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()).await {
|
||||
Ok(Ok(out)) => String::from_utf8_lossy(&out.stdout).to_string(),
|
||||
Ok(Err(_)) => String::new(),
|
||||
Err(_elapsed) => String::new(), // 超时:child 被 kill_on_drop 终止
|
||||
}
|
||||
}
|
||||
|
||||
/// git status --porcelain 解析为结构化文件列表。
|
||||
/// 返回 (当前分支, 改动文件列表 [{path, status}])
|
||||
async fn run_git_status(working_dir: &str) -> (String, Vec<serde_json::Value>) {
|
||||
let branch = exec_git(working_dir, &["branch", "--show-current"]).await;
|
||||
let branch = branch.trim().to_string();
|
||||
let raw = exec_git(working_dir, &["status", "--porcelain"]).await;
|
||||
let files: Vec<serde_json::Value> = raw
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|line| {
|
||||
// porcelain 格式:"XY filename",XY 为两位状态码
|
||||
let status = line.chars().take(2).collect::<String>();
|
||||
let path = line.get(3..).unwrap_or("").trim().to_string();
|
||||
// 简化状态码:M/A/D/??/R
|
||||
let simple = if status.starts_with("??") { "??" }
|
||||
else if status.contains('A') { "A" }
|
||||
else if status.contains('D') { "D" }
|
||||
else if status.contains('R') { "R" }
|
||||
else { "M" };
|
||||
serde_json::json!({ "path": path, "status": simple })
|
||||
})
|
||||
.collect();
|
||||
(branch, files)
|
||||
}
|
||||
|
||||
/// git diff 解析为结构化文件列表(每文件统计 + patch 截断)。
|
||||
async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value {
|
||||
let mut args = vec!["diff", "--stat"];
|
||||
if staged { args.push("--cached"); }
|
||||
let stat_raw = exec_git(working_dir, &args).await;
|
||||
|
||||
// 每文件 patch(截断防 token 爆)
|
||||
let mut patch_args = vec!["diff" ];
|
||||
if staged { patch_args.push("--cached"); }
|
||||
let patch_raw = exec_git(working_dir, &patch_args).await;
|
||||
// 截断到 8000 字符(防大体量 diff)
|
||||
let patch_truncated = if patch_raw.len() > 8000 {
|
||||
format!("{}\n... (diff 截断,共 {} 字符)", &patch_raw[..8000], patch_raw.len())
|
||||
} else {
|
||||
patch_raw
|
||||
};
|
||||
|
||||
serde_json::json!({
|
||||
"stat": stat_raw,
|
||||
"patch": patch_truncated,
|
||||
})
|
||||
}
|
||||
|
||||
/// git log 解析为结构化提交列表。
|
||||
async fn run_git_log(working_dir: &str, limit: usize) -> Vec<serde_json::Value> {
|
||||
let format = "%H|%an|%ad|%s";
|
||||
let limit_str = format!("-{}", limit);
|
||||
let raw = exec_git(working_dir, &["log", "--oneline", &format!("--format={}", format), &limit_str, "--date=short"]).await;
|
||||
raw.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.splitn(4, '|').collect();
|
||||
if parts.len() == 4 {
|
||||
Some(serde_json::json!({
|
||||
"hash": parts[0],
|
||||
"author": parts[1],
|
||||
"date": parts[2],
|
||||
"message": parts[3],
|
||||
}))
|
||||
} else { None }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Git AI 工具注册(6 个:status/diff/log 只读 Low + commit/branch 写 Medium + merge High)。
|
||||
///
|
||||
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
|
||||
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
|
||||
/// - risk 与原一致(status/diff/log=Low,commit/branch=Medium,merge=High)
|
||||
/// - handler body 与原 async move 块逐字一致(逻辑零变更)
|
||||
///
|
||||
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
|
||||
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
// ── git_status (Low 只读) ──
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"git_status",
|
||||
"查看工程 Git 工作区状态。参数:module_id(工程 ID)。返回当前分支、改动文件列表(每个文件含路径+状态 M/A/D/??)、改动总数。只读无副作用",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![
|
||||
("module_id", "string", true),
|
||||
]),
|
||||
args => {
|
||||
let module_id = args["module_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
|
||||
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
|
||||
let module = repo.get_by_id(module_id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
|
||||
let (branch, files) = run_git_status(&module.path).await;
|
||||
Ok(serde_json::json!({
|
||||
"branch": branch,
|
||||
"files": files,
|
||||
"total_changes": files.len(),
|
||||
}))
|
||||
}
|
||||
);
|
||||
|
||||
// ── git_diff (Low 只读) ──
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"git_diff",
|
||||
"查看工程未提交的代码改动。参数:module_id(工程 ID)、staged(可选 bool,仅看已暂存改动,默认 false=全部含未暂存)。返回改动统计 + patch 内容(截断防 token 爆)。只读无副作用",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![
|
||||
("module_id", "string", true),
|
||||
("staged", "boolean", false),
|
||||
]),
|
||||
args => {
|
||||
let module_id = args["module_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
|
||||
let staged = args.get("staged").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
|
||||
let module = repo.get_by_id(module_id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
|
||||
let diff = run_git_diff(&module.path, staged).await;
|
||||
Ok(diff)
|
||||
}
|
||||
);
|
||||
|
||||
// ── git_log (Low 只读) ──
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"git_log",
|
||||
"查看工程 Git 提交历史。参数:module_id(工程 ID)、limit(可选 int,最近 N 条提交,默认 20,最大 100)。返回提交列表(每个含哈希/作者/消息/日期)。只读无副作用",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![
|
||||
("module_id", "string", true),
|
||||
("limit", "integer", false),
|
||||
]),
|
||||
args => {
|
||||
let module_id = args["module_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20).min(100) as usize;
|
||||
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
|
||||
let module = repo.get_by_id(module_id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
|
||||
let commits = run_git_log(&module.path, limit).await;
|
||||
Ok(serde_json::json!({ "commits": commits }))
|
||||
}
|
||||
);
|
||||
|
||||
// ── git_commit (Medium 审批) ──
|
||||
// 提交工作区改动。需审批(写操作影响代码仓库)。
|
||||
// 安全边界:不提交敏感文件(.env/.key/.pem,由 .gitignore 兜底 + 此处检查)。
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"git_commit",
|
||||
"提交工程工作区改动到本地仓库。参数:module_id(工程 ID)、message(提交信息,必填)、add_all(可选 bool,是否添加全部改动到暂存区,默认 true)。中等风险,需审批。提交前检查不提交敏感文件(.env/.key/.pem)",
|
||||
RiskLevel::Medium,
|
||||
schema: object_schema(vec![
|
||||
("module_id", "string", true),
|
||||
("message", "string", true),
|
||||
("add_all", "boolean", false),
|
||||
]),
|
||||
args => {
|
||||
let module_id = args["module_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
|
||||
let message = args["message"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 message"))?;
|
||||
let add_all = args.get("add_all").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
if message.trim().is_empty() {
|
||||
anyhow::bail!("提交信息不能为空");
|
||||
}
|
||||
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
|
||||
let module = repo.get_by_id(module_id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
|
||||
// 敏感文件检查:提交前查看待提交文件列表,如有 .env/.key/.pem 则拒绝
|
||||
let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await;
|
||||
let sensitive_patterns = [".env", ".key", ".pem", "id_rsa", ".htpasswd"];
|
||||
for line in status_raw.lines() {
|
||||
let path = line.get(3..).unwrap_or("").trim().to_lowercase();
|
||||
if sensitive_patterns.iter().any(|p| path.contains(p)) {
|
||||
anyhow::bail!("检测到敏感文件在待提交列表中: {}。请手动检查 .gitignore 或移除该文件后重试", line.get(3..).unwrap_or(""));
|
||||
}
|
||||
}
|
||||
// git add
|
||||
if add_all {
|
||||
let _ = exec_git(&module.path, &["add", "-A"]).await;
|
||||
}
|
||||
// git commit
|
||||
let commit_out = exec_git(&module.path, &["commit", "-m", message]).await;
|
||||
// 提交后取最新 commit hash
|
||||
let hash = exec_git(&module.path, &["log", "-1", "--format=%H"]).await.trim().to_string();
|
||||
Ok(serde_json::json!({
|
||||
"committed": !hash.is_empty(),
|
||||
"hash": hash,
|
||||
"message": message,
|
||||
"output": commit_out,
|
||||
}))
|
||||
}
|
||||
);
|
||||
|
||||
// ── git_branch (Medium 审批) ──
|
||||
// 分支管理:列表/创建/切换。需审批(影响工作区分支状态)。
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"git_branch",
|
||||
"工程分支管理。参数:module_id(工程 ID)、action(list/create/switch,默认 list)、name(分支名,create/switch 时必填)。中等风险,需审批。list=列出所有分支+当前分支,create=创建新分支,switch=切换分支",
|
||||
RiskLevel::Medium,
|
||||
schema: object_schema(vec![
|
||||
("module_id", "string", true),
|
||||
("action", "string", false),
|
||||
("name", "string", false),
|
||||
]),
|
||||
args => {
|
||||
let module_id = args["module_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
|
||||
let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("list");
|
||||
let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
|
||||
let module = repo.get_by_id(module_id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
|
||||
match action {
|
||||
"list" => {
|
||||
let raw = exec_git(&module.path, &["branch", "--list"]).await;
|
||||
let current = exec_git(&module.path, &["branch", "--show-current"]).await.trim().to_string();
|
||||
let branches: Vec<String> = raw.lines()
|
||||
.map(|l| l.trim_start_matches("* ").trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
Ok(serde_json::json!({ "current": current, "branches": branches }))
|
||||
}
|
||||
"create" => {
|
||||
if name.is_empty() { anyhow::bail!("create 需要 name 参数"); }
|
||||
let _ = exec_git(&module.path, &["branch", name]).await;
|
||||
Ok(serde_json::json!({ "created": name }))
|
||||
}
|
||||
"switch" => {
|
||||
if name.is_empty() { anyhow::bail!("switch 需要 name 参数"); }
|
||||
let _ = exec_git(&module.path, &["checkout", name]).await;
|
||||
let current = exec_git(&module.path, &["branch", "--show-current"]).await.trim().to_string();
|
||||
Ok(serde_json::json!({ "switched_to": current }))
|
||||
}
|
||||
_ => anyhow::bail!("未知 action: {},合法值: list/create/switch", action),
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ── git_merge (High 审批) ──
|
||||
// 合并分支。高风险(可能产生冲突,影响代码完整性)。
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"git_merge",
|
||||
"合并指定分支到当前分支。参数:module_id(工程 ID)、branch(要合并的分支名,必填)。高风险,需审批。合并冲突时返回冲突文件列表,需用户手动解决",
|
||||
RiskLevel::High,
|
||||
schema: object_schema(vec![
|
||||
("module_id", "string", true),
|
||||
("branch", "string", true),
|
||||
]),
|
||||
args => {
|
||||
let module_id = args["module_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
|
||||
let branch = args["branch"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 branch"))?;
|
||||
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
|
||||
let module = repo.get_by_id(module_id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
|
||||
let merge_out = exec_git(&module.path, &["merge", branch]).await;
|
||||
let has_conflict = merge_out.contains("CONFLICT") || merge_out.contains("Merge conflict");
|
||||
if has_conflict {
|
||||
let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await;
|
||||
let conflicts: Vec<String> = status_raw.lines()
|
||||
.filter(|l| l.starts_with("UU") || l.starts_with("AA") || l.starts_with("DD"))
|
||||
.map(|l| l.get(3..).unwrap_or("").trim().to_string())
|
||||
.collect();
|
||||
Ok(serde_json::json!({
|
||||
"merged": false,
|
||||
"conflicts": conflicts,
|
||||
"message": "合并冲突,需手动解决",
|
||||
}))
|
||||
} else {
|
||||
Ok(serde_json::json!({
|
||||
"merged": true,
|
||||
"branch": branch,
|
||||
"output": merge_out,
|
||||
}))
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! 网络层 AI 工具声明式注册(register_http_tools 1 个:http_request 迁入)。
|
||||
//!
|
||||
//! 迁自 `tool_registry.rs::register_http_tools`(原 1 个 http_request),改用 `declare_tool!` 宏。
|
||||
//!
|
||||
//! 迁移策略(handler 逻辑零变更):
|
||||
//! - handler body 逐字照搬原 async move 块(转调 `crate::commands::ai::http::execute_http_request`),
|
||||
//! 仅闭包包装改由 `declare_tool!` 宏生成。
|
||||
//! - name/desc/schema/risk 与原手写定义逐字一致(headers 是对象 map,object_schema 仅支持扁平
|
||||
//! 标量三元组,故保留原手工拼 serde_json::Map schema 表达式)。
|
||||
//! - 无捕获:原 handler 不 clone db(handler 仅转调 http.rs),用占位 `Arc<()>` 捕获(宏要求 capture 形参)。
|
||||
//!
|
||||
//! 风险:GET=Medium(只读但触发外发)/ 写方法=High(须人工批准),一个工具名两种 risk 不支持(register
|
||||
//! 单一 risk_level),故按最高风险 High 注册(写方法 High 兜底;GET 也走 High 审批更保守)。
|
||||
//!
|
||||
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
|
||||
//! 工具名集合稳定(防 rename / 漏注册)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||
use df_ai::declare_tool;
|
||||
|
||||
/// 注册 http_request 工具到 `$registry`(无 db 捕获,纯 reqwest 调用)。
|
||||
///
|
||||
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
|
||||
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
|
||||
/// - risk=High(写方法兜底,GET 同走审批更保守)
|
||||
/// - handler body 与原 async move 块逐字一致(逻辑零变更)
|
||||
///
|
||||
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
|
||||
pub fn register(registry: &mut AiToolRegistry) {
|
||||
// 无捕获:占位 Arc<()>(原 handler 不持 db,仅转调 http.rs)。
|
||||
let dummy: Arc<()> = Arc::new(());
|
||||
// schema:headers 是对象 map,object_schema 仅支持扁平标量三元组,故手工拼 serde_json::Map。
|
||||
// 提取为 let 绑定:declare_tool! 的 schema: $schema:expr 形参对花括号块表达式解析有歧义,
|
||||
// 先求值到局部变量再传入,语义等价(原手写 register 亦以此 Map 作为 schema 实参)。
|
||||
let schema = {
|
||||
let mut props = serde_json::Map::new();
|
||||
props.insert("method".into(), serde_json::json!({ "type": "string", "description": "HTTP 方法:GET/POST/PUT/PATCH/DELETE(默认 GET)", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] }));
|
||||
props.insert("url".into(), serde_json::json!({ "type": "string", "description": "请求 URL,仅 http/https,拒私网/localhost(SSRF 防护)" }));
|
||||
props.insert("headers".into(), serde_json::json!({ "type": "object", "description": "请求头 map<string,string>,如 {\"Authorization\":\"Bearer xxx\",\"Content-Type\":\"application/json\"}", "additionalProperties": { "type": "string" } }));
|
||||
props.insert("body".into(), serde_json::json!({ "type": "string", "description": "请求体(POST/PUT/PATCH 用),原样发送,Content-Type 须在 headers 显式指定" }));
|
||||
props.insert("timeout_secs".into(), serde_json::json!({ "type": "integer", "description": "超时秒数(默认 30,上限 60)", "minimum": 1, "maximum": 60 }));
|
||||
props.insert("parse".into(), serde_json::json!({ "type": "string", "description": "响应 body 解析:json(pretty 格式化)/text(原样)/auto(按 Content-Type 自动,默认)", "enum": ["json", "text", "auto"] }));
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": props,
|
||||
"required": ["url"],
|
||||
})
|
||||
};
|
||||
declare_tool!(
|
||||
registry,
|
||||
dummy: Arc<()>,
|
||||
"http_request",
|
||||
"发起结构化 HTTP 请求(GET/POST/PUT/PATCH/DELETE),用于查询外部 API。参数:method(默认 GET)、url(http/https)、headers(对象 map)、body(请求体字符串)、timeout_secs(默认 30,上限 60)、parse(json/text/auto,默认 auto)。安全:仅 http/https 协议,拒绝私网/保留 IP(SSRF 防护含 DNS resolve 后校验防重绑定),重定向≤3 跳且每跳重校验。响应 body 截断 50KB。GET 为只读但触发外发网络,POST/PUT/PATCH/DELETE 有副作用,统一按高风险须人工批准",
|
||||
RiskLevel::High,
|
||||
schema: schema,
|
||||
args => {
|
||||
// 转调 http.rs handler(SSRF 防护 + 重定向 + 截断全在那)
|
||||
crate::commands::ai::http::execute_http_request(args).await
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! 灵感类 AI 工具声明式注册(register_idea_tools 2 个:list + create 迁入)。
|
||||
//!
|
||||
//! 迁自 `tool_registry.rs::register_idea_tools`(原 2 个 list_ideas/create_idea),
|
||||
//! 改用 `declare_tool!` 宏。
|
||||
//!
|
||||
//! 迁移策略(handler 逻辑零变更):
|
||||
//! - handler body 逐字照搬原 `register_idea_tools` 内 async move 块(逻辑等价),
|
||||
//! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone();
|
||||
//! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。
|
||||
//! - name/desc/schema/risk 与原手写定义逐字一致。
|
||||
//! - 复用类型/常量:new_id/IdeaStatus/IdeaRecord/now_millis(各 pub(crate) 来源,与原同源) +
|
||||
//! MAX_LIST_RESULTS(super::tool_registry 单真相源)。
|
||||
//!
|
||||
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
|
||||
//! 工具名集合稳定(防 rename / 漏注册)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||||
use df_ai::declare_tool;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::IdeaRecord;
|
||||
use df_types::types::{new_id, IdeaStatus};
|
||||
|
||||
use crate::commands::now_millis;
|
||||
use crate::commands::ai::tool_registry::MAX_LIST_RESULTS;
|
||||
|
||||
/// 注册 2 个灵感类工具(list/create)到 `$registry`。
|
||||
///
|
||||
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
|
||||
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
|
||||
/// - risk 与原一致(list=Low,create=Medium)
|
||||
/// - handler body 与原 async move 块逐字一致(逻辑零变更)
|
||||
///
|
||||
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
|
||||
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"list_ideas",
|
||||
"列出所有灵感,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]),
|
||||
args => {
|
||||
let repo = df_storage::crud::IdeaRepo::new(&db);
|
||||
let items = repo.list_all().await?;
|
||||
let total = items.len();
|
||||
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||||
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
|
||||
let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
|
||||
let has_more = (offset + page_items.len()) < total;
|
||||
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
|
||||
}
|
||||
);
|
||||
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"create_idea",
|
||||
"捕获一个新灵感",
|
||||
RiskLevel::Medium,
|
||||
schema: object_schema(vec![
|
||||
("title", "string", true), ("description", "string", false),
|
||||
("tags", "string", false), ("source", "string", false),
|
||||
// priority:与 commands::idea::CreateIdeaInput 默认值一致(=1,灵感默认普通优先级)
|
||||
("priority", "integer", false),
|
||||
]),
|
||||
args => {
|
||||
let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
|
||||
let repo = df_storage::crud::IdeaRepo::new(&db);
|
||||
let record = IdeaRecord {
|
||||
id: new_id(), title: title.to_string(),
|
||||
description: args["description"].as_str().unwrap_or("").to_string(),
|
||||
// priority 默认 1:灵感默认普通优先级(与 commands::idea::default_priority 及 tasks 表 SQL DEFAULT 1 对齐)
|
||||
status: IdeaStatus::Draft, priority: args["priority"].as_i64().unwrap_or(1) as i32,
|
||||
score: None, tags: args["tags"].as_str().map(|s| s.to_string()),
|
||||
source: args["source"].as_str().map(|s| s.to_string()),
|
||||
promoted_to: None, ai_analysis: None, scores: None,
|
||||
related_ids: None,
|
||||
created_at: now_millis(), updated_at: now_millis(),
|
||||
};
|
||||
let id = record.id.clone();
|
||||
repo.insert(record).await?;
|
||||
Ok(serde_json::json!({ "id": id, "title": title, "status": "draft" }))
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! 试点工具:`list_projects` 声明式注册(tool_registry 拆分第一步 · 迁移试点)
|
||||
//!
|
||||
//! 验证 `declare_tool!` 宏基础设施可行:从原 `register_project_tools` 第 1 个 register 调用
|
||||
//! (tool_registry.rs:524-541)迁移到声明式,行为逐字等价(schema/risk/handler body 同源)。
|
||||
//!
|
||||
//! 迁移策略(并存零回归):
|
||||
//! - 原 `register_project_tools` 内的 list_projects register 调用删除,改调本模块 `register`。
|
||||
//! - 其余 7 个项目工具(update/create/bind/delete/restore/purge/get_count)仍手写在原处,
|
||||
//! 不动(本步只迁 1 个试点,后续批次渐进)。
|
||||
//! - `MAX_LIST_RESULTS` 由 tool_registry.rs 改 `pub(crate)` 暴露,试点复用同一常量(单真相源,
|
||||
//! 避免字面量 50 漂移)。
|
||||
//!
|
||||
//! 等价性验证:基线测试 test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量;
|
||||
//! execute("list_projects", args) 行为不变(list_active 分页 + has_more 语义)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||||
use df_ai::declare_tool;
|
||||
use df_storage::db::Database;
|
||||
|
||||
use crate::commands::ai::tool_registry::MAX_LIST_RESULTS;
|
||||
|
||||
/// 用声明式宏注册 `list_projects` 到 `$registry`。
|
||||
///
|
||||
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
|
||||
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
|
||||
/// - risk = RiskLevel::Low(只读,无副作用)
|
||||
/// - handler body 与原 async move 块逐字一致(list_active 排软删 + offset/limit 分页 +
|
||||
/// has_more 语义 + 默认/上限对齐 MAX_LIST_RESULTS)
|
||||
///
|
||||
/// 唯一差异:闭包包装(`{ let db = db.clone(); Box::new(move |args| { ... Box::pin(async move {...}) }) }`)
|
||||
/// 改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
|
||||
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"list_projects",
|
||||
"列出所有项目,支持 offset/limit 分页。返回 items(项目列表)、total(总量)、has_more(是否有更多页)。默认 limit=50",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]),
|
||||
args => {
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
// list_active 排除回收站(deleted_at),防 LLM 看到已软删项目
|
||||
let items = repo.list_active().await?;
|
||||
let total = items.len();
|
||||
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||||
let limit = args["limit"]
|
||||
.as_u64()
|
||||
.unwrap_or(MAX_LIST_RESULTS as u64)
|
||||
.min(MAX_LIST_RESULTS as u64) as usize;
|
||||
let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
|
||||
let has_more = (offset + page_items.len()) < total;
|
||||
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! 声明式注册工具集合(tool_registry 拆分第一步 · 试点容器)
|
||||
//!
|
||||
//! 容纳迁自 `tool_registry.rs` 手写 register 调用、改用 `declare_tool!` 宏的工具。
|
||||
//! 本步(基础设施 + 1 试点)仅迁 `list_projects`,其余 47 个工具仍在 tool_registry.rs 手写,
|
||||
//! 后续批次渐进迁移(每批 1-3 个,各自独立文件,互不影响)。
|
||||
//!
|
||||
//! 设计:每个工具一个独立文件 + `register(&mut registry, db)` 入口,tool_registry.rs
|
||||
//! 对应 `register_*` 函数改调本模块(原手写 register 调用删除)。这样工具定义与实现同源、
|
||||
//! 文件边界清晰,新增工具只加文件 + 在对应 register_* 接一行,不动 4224 行巨函数主体。
|
||||
|
||||
pub mod list_projects;
|
||||
pub mod project;
|
||||
pub mod task;
|
||||
pub mod task_graph;
|
||||
pub mod git;
|
||||
pub mod http;
|
||||
pub mod workflow;
|
||||
pub mod idea;
|
||||
pub mod trash;
|
||||
pub mod file;
|
||||
@@ -0,0 +1,179 @@
|
||||
//! 项目类 AI 工具声明式注册(register_project_tools 剩余 7 个迁入)。
|
||||
//!
|
||||
//! 第一步试点已迁 `list_projects`(见 tools/list_projects.rs),本文件迁完
|
||||
//! `register_project_tools` 的剩余 7 个:update_project/create_project/bind_directory/
|
||||
//! delete_project/restore_project/purge_project/get_project_count。
|
||||
//!
|
||||
//! 迁移策略(handler 逻辑零变更):
|
||||
//! - handler body 逐字照搬原 `register_project_tools` 内 async move 块(逻辑等价),
|
||||
//! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone();
|
||||
//! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。
|
||||
//! - name/desc/schema/risk 与原手写定义逐字一致。
|
||||
//! - `bind_dir_to_project`(create_project/bind_directory 共用)原为 tool_registry.rs
|
||||
//! 私有 helper,本模块跨 super::tool_registry 调用,故该 helper 已改 `pub(crate)`。
|
||||
//!
|
||||
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
|
||||
//! 工具名集合稳定(防 rename / 漏注册)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||||
use df_ai::declare_tool;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::ProjectRecord;
|
||||
use df_types::types::{new_id, ProjectStatus};
|
||||
|
||||
use crate::commands::now_millis;
|
||||
use crate::commands::ai::tool_registry::bind_dir_to_project;
|
||||
|
||||
/// 注册剩余 7 个项目类工具(update/create/bind/delete/restore/purge/get_count)到 `$registry`。
|
||||
///
|
||||
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
|
||||
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
|
||||
/// - risk 与原一致(update/create/bind=Medium,delete/restore/purge=High,get_count=Low)
|
||||
/// - handler body 与原 async move 块逐字一致(逻辑零变更)
|
||||
///
|
||||
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
|
||||
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
// ── update_project (Medium) ──
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"update_project",
|
||||
"更新项目的指定字段(name/status/description/path/stack),需要提供项目 ID、字段名和新值。绑定代码目录推荐改用 bind_directory",
|
||||
RiskLevel::Medium,
|
||||
schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
let field = args["field"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 field"))?;
|
||||
let value = args["value"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 value"))?;
|
||||
// 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源
|
||||
if !df_storage::crud::is_allowed_column("projects", field) {
|
||||
anyhow::bail!("不允许更新字段 '{}'", field);
|
||||
}
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
repo.update_field(id, field, value).await?;
|
||||
Ok(serde_json::json!({ "id": id, "field": field, "updated": true }))
|
||||
}
|
||||
);
|
||||
|
||||
// ── create_project (Medium) ──
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"create_project",
|
||||
"创建新项目,可选传 path/stack 一步完成创建+绑定目录(无需再调 bind_directory)",
|
||||
RiskLevel::Medium,
|
||||
schema: object_schema(vec![
|
||||
("name", "string", true), ("description", "string", false),
|
||||
("path", "string", false), ("stack", "string", false),
|
||||
]),
|
||||
args => {
|
||||
let name = args["name"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 name 参数"))?;
|
||||
let description = args["description"].as_str().unwrap_or("");
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
let record = ProjectRecord {
|
||||
id: new_id(), name: name.to_string(), description: description.to_string(),
|
||||
status: ProjectStatus::Planning, idea_id: None,
|
||||
path: None, stack: None,
|
||||
created_at: now_millis(), updated_at: now_millis(),
|
||||
};
|
||||
let id = record.id.clone();
|
||||
repo.insert(record).await?;
|
||||
// path 缺省:保持原行为(仅创建,不绑定);有 path 则复用公共绑定闭包(create_project 与 bind_directory 同源)
|
||||
let path_opt = args.get("path").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
|
||||
if let Some(path) = path_opt {
|
||||
let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
|
||||
let stack = bind_dir_to_project(&repo, &id, path, stack_opt).await?;
|
||||
Ok(serde_json::json!({ "id": id, "name": name, "status": "planning", "path": path, "stack": stack, "bound": true }))
|
||||
} else {
|
||||
Ok(serde_json::json!({ "id": id, "name": name, "status": "planning" }))
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ── bind_directory (Medium) ──
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"bind_directory",
|
||||
"为项目绑定代码目录(自动探测技术栈,防重复绑定)。path 应为**项目根路径**(单仓库=仓库根;多工程 monorepo=工作区根,非单个子工程路径;子工程信息走 list_project_modules/update_project_module 维护)。多次调用会覆盖 path 字段,monorepo 场景勿为每个子工程重复绑定。可选 stack 手动指定技术栈(如 'Go / Gin / Vue3'),不传则自动探测。",
|
||||
RiskLevel::Medium,
|
||||
schema: object_schema(vec![("id", "string", true), ("path", "string", true), ("stack", "string", false)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id 参数"))?;
|
||||
let path = args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?;
|
||||
let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
let stack = bind_dir_to_project(&repo, id, path, stack_opt).await?;
|
||||
// F-260619-03: 项目绑定目录加 AI 白名单由 reload_allowed_dirs 自动读 projects.bind_directory
|
||||
// 统一处理(优雅:绑定 = 自动授权,reload 时合并)。bind handler 不直接写 allowed_dirs
|
||||
// (register_project_tools 无 allowed_dirs 参数,且 reload 集中更易维护)。
|
||||
// 绑定后触发 reload:通过 app_handle emit 事件让 state reload,或下次工具调用前 reload 兜底。
|
||||
Ok(serde_json::json!({ "id": id, "path": path, "stack": stack, "bound": true }))
|
||||
}
|
||||
);
|
||||
|
||||
// ── delete_project (High) ──
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"delete_project",
|
||||
"删除项目(移入回收站,可恢复。永久删除用 purge_project)",
|
||||
RiskLevel::High,
|
||||
schema: object_schema(vec![("id", "string", true)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
let deleted = repo.soft_delete(id).await?;
|
||||
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
|
||||
}
|
||||
);
|
||||
|
||||
// ── restore_project (High) ──
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"restore_project",
|
||||
"从回收站恢复已删除项目",
|
||||
RiskLevel::High,
|
||||
schema: object_schema(vec![("id", "string", true)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
let restored = repo.restore(id).await?;
|
||||
Ok(serde_json::json!({ "restored": restored, "id": id }))
|
||||
}
|
||||
);
|
||||
|
||||
// ── purge_project (High) ──
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"purge_project",
|
||||
"永久删除项目及关联数据(不可恢复)",
|
||||
RiskLevel::High,
|
||||
schema: object_schema(vec![("id", "string", true)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
let purged = repo.purge_with_descendants(id).await?;
|
||||
Ok(serde_json::json!({ "purged": purged, "id": id }))
|
||||
}
|
||||
);
|
||||
|
||||
// ── get_project_count (Low risk, F-260616-08) ──
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"get_project_count",
|
||||
"获取项目总数(未删除项目),用于分页策略判断。返回 { total: usize }",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![]),
|
||||
_args => {
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
let items = repo.list_active().await?;
|
||||
Ok(serde_json::json!({ "total": items.len() }))
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! 任务类 AI 工具声明式注册(register_task_tools 6 个迁入)。
|
||||
//!
|
||||
//! 迁自 `tool_registry.rs::register_task_tools`(原 6 个 list/create/update/advance/
|
||||
//! delete/get_count),改用 `declare_tool!` 宏。
|
||||
//!
|
||||
//! 迁移策略(handler 逻辑零变更):
|
||||
//! - handler body 逐字照搬原 `register_task_tools` 内 async move 块(逻辑等价),
|
||||
//! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone();
|
||||
//! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。
|
||||
//! - name/desc/schema/risk 与原手写定义逐字一致。
|
||||
//! - 复用常量/类型:MAX_LIST_RESULTS(super::tool_registry 单真相源)、new_id/TaskStatus/
|
||||
//! TaskRecord/now_millis(各 pub(crate) 来源,与原 register_task_tools 引用同源)。
|
||||
//!
|
||||
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
|
||||
//! 工具名集合稳定(防 rename / 漏注册)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||||
use df_ai::declare_tool;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::TaskRecord;
|
||||
use df_types::types::{new_id, TaskStatus};
|
||||
|
||||
use crate::commands::now_millis;
|
||||
use crate::commands::ai::tool_registry::MAX_LIST_RESULTS;
|
||||
|
||||
/// 注册 6 个任务类工具(list/create/update/advance/delete/get_count)到 `$registry`。
|
||||
///
|
||||
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
|
||||
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
|
||||
/// - risk 与原一致(list/get_count=Low,create/update/advance=Medium,delete=High)
|
||||
/// - handler body 与原 async move 块逐字一致(逻辑零变更)
|
||||
///
|
||||
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
|
||||
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"list_tasks",
|
||||
"列出任务,可按 project_id/status 筛选(status: todo/in_progress/in_review/testing/blocked/done/cancelled),支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![("project_id", "string", false), ("status", "string", false), ("offset", "integer", false), ("limit", "integer", false)]),
|
||||
args => {
|
||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||||
let mut tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
|
||||
repo.query("project_id", pid).await?
|
||||
} else {
|
||||
repo.list_all().await?
|
||||
};
|
||||
// 按状态过滤(可选):todo/in_progress/in_review/testing/blocked/done/cancelled
|
||||
if let Some(status) = args.get("status").and_then(|v| v.as_str()) {
|
||||
tasks.retain(|t| t.status.as_str() == status);
|
||||
}
|
||||
let total = tasks.len();
|
||||
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||||
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
|
||||
let page_items: Vec<_> = tasks.into_iter().skip(offset).take(limit).collect();
|
||||
let has_more = (offset + page_items.len()) < total;
|
||||
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
|
||||
}
|
||||
);
|
||||
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"create_task",
|
||||
"在指定项目下创建新任务,可选传 idea_id 关联灵感(1对1 单向)。知识图谱 Phase 1 扩展三个可选参数:queue(管理维度池 backlog/todo/decision,默认 todo,新建不可直接落 active/done 须经 move_task_queue 流转)、parent_id(父任务 ID,限 1 级嵌套,父任务自身不能有 parent_id,IPC 层校验防孙任务)、content_json(结构化需求规格 JSON 字符串 {background,acceptance_criteria[],scope[],technical_design},须合法 JSON)",
|
||||
RiskLevel::Medium,
|
||||
schema: object_schema(vec![
|
||||
("project_id", "string", true), ("title", "string", true),
|
||||
("description", "string", false), ("priority", "integer", false),
|
||||
("idea_id", "string", false),
|
||||
("queue", "string", false), ("parent_id", "string", false),
|
||||
("content_json", "string", false),
|
||||
]),
|
||||
args => {
|
||||
let project_id = args["project_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?;
|
||||
let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
|
||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||||
|
||||
// ── 知识图谱 Phase 1:queue/parent_id/content_json 校验(对齐 commands::task::create_task IPC)──
|
||||
// queue:白名单 + 空串默认 todo。create_task 时 status 恒 todo,仅 backlog/todo/decision
|
||||
// 合法(active/done 须经 move_task_queue 流转,新建直接落违反一致性约束)。
|
||||
const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"];
|
||||
let queue = match args.get("queue").and_then(|v| v.as_str()) {
|
||||
None | Some("") => "todo".to_string(),
|
||||
Some(q) => {
|
||||
let q = q.trim();
|
||||
if !TASK_QUEUE_VALUES.contains(&q) {
|
||||
anyhow::bail!("非法 queue 值 {:?},合法值: {:?}", q, TASK_QUEUE_VALUES);
|
||||
}
|
||||
// 一致性:create_task 时 status 恒 todo,仅 backlog/todo/decision 合法
|
||||
match q {
|
||||
"backlog" | "todo" | "decision" => q.to_string(),
|
||||
other => anyhow::bail!(
|
||||
"新建任务 queue 不可直接落 {:?}(须经 move_task_queue 流转),当前仅允许 backlog/todo/decision",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// parent_id:1 级嵌套铁律(对标设计 §2.1 D2)。空串/缺省=None;
|
||||
// 非空时校验父任务存在 + 父任务自身无 parent_id(防孙任务)。
|
||||
let parent_id = args
|
||||
.get("parent_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(pid) = &parent_id {
|
||||
let parent = repo
|
||||
.get_by_id(pid)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("父任务 {pid} 不存在,无法创建子任务"))?;
|
||||
if parent.parent_id.is_some() {
|
||||
anyhow::bail!(
|
||||
"违反 1 级嵌套铁律:父任务 {pid} 自身是子任务(parent_id={:?}),不允许在其下创建孙任务",
|
||||
parent.parent_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// content_json:轻量 JSON 合法性校验(非空时须是合法 JSON),结构细节由 AI 消费层负责。
|
||||
let content_json = match args.get("content_json").and_then(|v| v.as_str()) {
|
||||
Some(c) if !c.trim().is_empty() => {
|
||||
serde_json::from_str::<serde_json::Value>(c)
|
||||
.map_err(|e| anyhow::anyhow!("content_json 不是合法 JSON: {e}"))?;
|
||||
Some(c.to_string())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let record = TaskRecord {
|
||||
id: new_id(), project_id: project_id.to_string(), title: title.to_string(),
|
||||
description: args["description"].as_str().unwrap_or("").to_string(),
|
||||
// priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high)
|
||||
status: TaskStatus::Todo, priority: args["priority"].as_i64().unwrap_or(2) as i32,
|
||||
branch_name: None, assignee: None, workflow_def_id: None, base_branch: None,
|
||||
review_rounds: 0,
|
||||
output_json: None,
|
||||
// F-260619-01 可选关联灵感(空字符串/缺省视为不关联)
|
||||
idea_id: args.get("idea_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
|
||||
// 知识图谱 Phase 1 V29 三列:经上方校验的 queue / parent_id / content_json
|
||||
queue,
|
||||
parent_id,
|
||||
content_json,
|
||||
created_at: now_millis(), updated_at: now_millis(),
|
||||
};
|
||||
let id = record.id.clone();
|
||||
repo.insert(record).await?;
|
||||
Ok(serde_json::json!({ "id": id, "title": title, "status": "todo" }))
|
||||
}
|
||||
);
|
||||
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"update_task",
|
||||
"更新任务的指定字段(title/description/priority/assignee 等),需要提供任务 ID、字段名和新值。注意:status 改动须走 advance_task 工具(状态机推进,7 态 todo/in_progress/in_review/testing/blocked/done/cancelled,按 target_status 推进不可跳跃),本工具不接受 status 字段(防绕过状态机)",
|
||||
RiskLevel::Medium,
|
||||
schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
let field = args["field"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 field"))?;
|
||||
let value = args["value"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 value"))?;
|
||||
// F-03 收口(子项3):status 改动必须走 advance_task 工具(状态机推进),
|
||||
// 本工具显式拒绝 status 字段,防 AI 绕过状态机直接改库。
|
||||
// 对齐 Agent B df-storage tasks 白名单移 status——双重防御(schema 拒绝 + 白名单拒绝)。
|
||||
if field == "status" {
|
||||
anyhow::bail!(
|
||||
"status 改动须走 advance_task 工具(状态机推进,7 态不可跳跃),本工具不接受 status 字段"
|
||||
);
|
||||
}
|
||||
// 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源
|
||||
if !df_storage::crud::is_allowed_column("tasks", field) {
|
||||
anyhow::bail!("不允许更新字段 '{}'", field);
|
||||
}
|
||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||||
repo.update_field(id, field, value).await?;
|
||||
// UX-260619-04: 返回 title 供前端卡片友好展示(对齐 advance_task 返 TaskRecord,
|
||||
// 防裸 UUID id 辨识度低)。update_field 返 bool 不含 record,单独 get_by_id 取 title。
|
||||
let title = repo.get_by_id(id).await?.map(|t| t.title).unwrap_or_default();
|
||||
Ok(serde_json::json!({ "id": id, "title": title, "field": field, "updated": true }))
|
||||
}
|
||||
);
|
||||
|
||||
// F-03 收口(子项1):advance_task AI 工具 — 推进任务状态的唯一合法路径。
|
||||
// D-260616-03:推进链业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加)落
|
||||
// df-nodes::task_advance_node::advance_task_atomic,本 handler 直接调它(与 IPC
|
||||
// commands::task::advance_task 同源,thin 转发),无需 AppHandle/State/EventBus。
|
||||
// 风险 Medium:status 变更属状态机推进(非破坏),但改变任务流转语义需人工知情。
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"advance_task",
|
||||
"推进任务状态,必须走状态机(7 态: todo/in_progress/in_review/testing/blocked/done/cancelled),按 target_status 推进,不可跳跃(非法转换会被状态机拦截)。退回转换(in_review→in_progress 等)自动累加 review_rounds。返回推进后的最新 TaskRecord",
|
||||
RiskLevel::Medium,
|
||||
schema: object_schema(vec![("id", "string", true), ("target_status", "string", true)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
let target_status = args["target_status"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?;
|
||||
// 复用 df-nodes 推进链核心逻辑(状态机 + 原子 CAS + review_rounds),
|
||||
// 与 commands::task::advance_task IPC 同源,避免双轨。
|
||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||||
let updated = df_nodes::task_advance_node::advance_task_atomic(
|
||||
&repo, id, target_status,
|
||||
).await?;
|
||||
// 返回推进后的 TaskRecord(含新 status / 累加后的 review_rounds),供 LLM 确认推进结果。
|
||||
Ok(serde_json::to_value(&updated)?)
|
||||
}
|
||||
);
|
||||
|
||||
// FR-D6:delete_task 对齐 commands::task::delete_task(D-260616-02 软删除)
|
||||
// tasks 表 V14 加 deleted_at,delete_task 改调 soft_delete 移入回收站(对标 delete_project)
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"delete_task",
|
||||
"删除任务(移入回收站,可恢复)",
|
||||
RiskLevel::High,
|
||||
schema: object_schema(vec![("id", "string", true)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||||
let deleted = repo.soft_delete(id).await?;
|
||||
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
|
||||
}
|
||||
);
|
||||
|
||||
// ── 探总量工具 (Low risk, F-260616-08) ──
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"get_task_count",
|
||||
"获取任务总数(未删除任务),用于分页策略判断。返回 { total: usize }",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![("project_id", "string", false)]),
|
||||
args => {
|
||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||||
let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
|
||||
repo.query("project_id", pid).await?
|
||||
} else {
|
||||
repo.list_all().await?
|
||||
};
|
||||
Ok(serde_json::json!({ "total": tasks.len() }))
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
//! 知识图谱 / 工程系统类 AI 工具声明式注册(register_task_graph_tools 10 个迁入)。
|
||||
//!
|
||||
//! 迁自 `tool_registry.rs::register_task_graph_tools`(原 10 个:task_link CRUD + 父子树
|
||||
//! + 跨池移动 + content 更新 + 项目事件流 + 项目基础设施配置 + 工程列表),改用 `declare_tool!` 宏。
|
||||
//!
|
||||
//! 迁移策略(handler 逻辑零变更):
|
||||
//! - handler body 逐字照搬原 `register_task_graph_tools` 内 async move 块(逻辑等价),
|
||||
//! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone();
|
||||
//! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。
|
||||
//! - name/desc/schema/risk 与原手写定义逐字一致。
|
||||
//! - 复用类型:new_id/ProjectServiceRecord/now_millis(各 pub(crate) 来源,与原同源)。
|
||||
//!
|
||||
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
|
||||
//! 工具名集合稳定(防 rename / 漏注册)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||||
use df_ai::declare_tool;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::ProjectServiceRecord;
|
||||
use df_types::types::new_id;
|
||||
|
||||
use crate::commands::now_millis;
|
||||
|
||||
/// 注册 10 个知识图谱 / 工程系统类工具到 `$registry`。
|
||||
///
|
||||
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
|
||||
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
|
||||
/// - risk 与原一致(link 写/queue 移动/content 更新/add_service=Medium,只读=Low)
|
||||
/// - handler body 与原 async move 块逐字一致(逻辑零变更)
|
||||
///
|
||||
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
|
||||
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
// ── create_task_link (Medium,设计 §2.2 + §五) ──
|
||||
// 建立任务横向关联。TaskLinkRepo::create_link 内含:
|
||||
// link_type 白名单(depends_on/blocks/relates_to)+ 自环拒绝 + depends_on BFS 循环依赖检测。
|
||||
// handler 仅转发参数 + new_id,业务校验全部下沉 Repo 层(单一真相源,与 IPC create_task_link 同源)。
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"create_task_link",
|
||||
"建立任务横向关联(AI 拓扑排序编排调度基础)。参数:task_id(source 任务,关联发起方)、target_id(target 任务,关联指向方)、link_type(depends_on 依赖/blocks 阻塞/relates_to 弱关联)、remark(可选备注)。约束:link_type 白名单校验;source==target 自环拒绝;depends_on 链 BFS 循环依赖检测(A→B→A 拒绝);跨项目允许。返回 link id",
|
||||
RiskLevel::Medium,
|
||||
schema: object_schema(vec![
|
||||
("task_id", "string", true), ("target_id", "string", true),
|
||||
("link_type", "string", true), ("remark", "string", false),
|
||||
]),
|
||||
args => {
|
||||
let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?;
|
||||
let target_id = args["target_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 target_id"))?;
|
||||
let link_type = args["link_type"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 link_type"))?;
|
||||
let remark = args.get("remark").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
|
||||
let repo = df_storage::crud::TaskLinkRepo::new(&db);
|
||||
let id = new_id();
|
||||
let link_id = repo.create_link(&id, task_id, target_id, link_type, remark).await?;
|
||||
Ok(serde_json::json!({ "id": link_id, "task_id": task_id, "target_id": target_id, "link_type": link_type }))
|
||||
}
|
||||
);
|
||||
|
||||
// ── remove_task_link (Medium,设计 §2.2 + §五) ──
|
||||
// 解除任务关联(按 link id)。返回是否命中(deleted=false=link 不存在)。
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"remove_task_link",
|
||||
"解除任务关联(按 link id)。返回 deleted 布尔(是否命中,link 不存在则 false)",
|
||||
RiskLevel::Medium,
|
||||
schema: object_schema(vec![("id", "string", true)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
let repo = df_storage::crud::TaskLinkRepo::new(&db);
|
||||
let deleted = repo.delete(id).await?;
|
||||
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
|
||||
}
|
||||
);
|
||||
|
||||
// ── list_task_links (Low 只读,设计 §2.2 + §五) ──
|
||||
// 查询任务关联(方向:outgoing 作为 source / incoming 作为 target / both 双向合并去重)。
|
||||
// AI 编排调度用:取某任务的全部依赖/阻塞/关联,做拓扑排序。
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"list_task_links",
|
||||
"查询任务关联(AI 编排调度用)。参数:task_id(目标任务)、direction(查询方向 outgoing=作为 source 查我依赖谁 / incoming=作为 target 查谁依赖我 / both=双向合并去重,默认 both)。返回关联记录列表(按 created_at 升序)",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![("task_id", "string", true), ("direction", "string", false)]),
|
||||
args => {
|
||||
let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?;
|
||||
// 方向默认 both(对齐 commands::task::LinkDirection::default)。空串/未传走 both。
|
||||
let direction = args.get("direction").and_then(|v| v.as_str()).unwrap_or("both").to_lowercase();
|
||||
let repo = df_storage::crud::TaskLinkRepo::new(&db);
|
||||
let links = match direction.as_str() {
|
||||
"outgoing" => repo.get_by_source(task_id).await?,
|
||||
"incoming" => repo.get_by_target(task_id).await?,
|
||||
// both:Outgoing + Incoming 合并后按 id 去重(同一 link 双向查询可能重复)
|
||||
_ => {
|
||||
let mut outgoing = repo.get_by_source(task_id).await?;
|
||||
let incoming = repo.get_by_target(task_id).await?;
|
||||
let mut seen: std::collections::HashSet<String> =
|
||||
outgoing.iter().map(|l| l.id.clone()).collect();
|
||||
for l in incoming {
|
||||
if seen.insert(l.id.clone()) {
|
||||
outgoing.push(l);
|
||||
}
|
||||
}
|
||||
outgoing.sort_by(|a, b| a.created_at.cmp(&b.created_at));
|
||||
outgoing
|
||||
}
|
||||
};
|
||||
let total = links.len();
|
||||
Ok(serde_json::json!({ "items": links, "total": total, "task_id": task_id, "direction": direction }))
|
||||
}
|
||||
);
|
||||
|
||||
// ── get_task_tree (Low 只读,设计 §2.1 + §五) ──
|
||||
// 获取父子任务树(限 1 级嵌套,设计 D2:无孙任务)。返回 parent + 直接 children 列表。
|
||||
// AI 用途:查看需求分解结构、子任务进度聚合。
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"get_task_tree",
|
||||
"获取任务父子树(限 1 级嵌套,设计 D2 无孙任务)。参数 task_id(任务 ID,无论叶子还是父任务)。返回 { parent: 父任务记录, children: 直接子任务列表(parent_id 指向 task_id,按 created_at 升序) }。叶子任务 children 为空。AI 用途:查看需求分解结构、子任务进度聚合",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![("task_id", "string", true)]),
|
||||
args => {
|
||||
let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?;
|
||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||||
let parent = repo
|
||||
.get_by_id(task_id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("任务 {task_id} 不存在"))?;
|
||||
let children = repo.get_children(task_id).await?;
|
||||
let child_count = children.len();
|
||||
Ok(serde_json::json!({ "parent": parent, "children": children, "child_count": child_count }))
|
||||
}
|
||||
);
|
||||
|
||||
// ── move_task_queue (Medium,设计 §2.1 + §五) ──
|
||||
// 跨池移动任务(backlog/todo/decision/active/done),按一致性约束联动 status。
|
||||
// 对齐 commands::task::move_task_queue 语义:
|
||||
// - done → status 强制 done / backlog → status 强制 todo
|
||||
// - active → status 若不在执行中三态则强制 in_progress / todo → status 强制 todo
|
||||
// - decision → status 不变(待决策池保留执行态)
|
||||
// status 写入走专用 set_status_for_aggregation(绕过 status 收口,move_task_queue 是合法非状态机路径)。
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"move_task_queue",
|
||||
"跨池移动任务(管理维度池 backlog/todo/decision/active/done,与 status 执行维度正交)。参数 id(任务 ID)、new_queue(目标池)。一致性约束联动 status:done→status=done / backlog→status=todo / active→status 若非执行中三态强制 in_progress / todo→status=todo / decision→status 不变。返回移动后的最新 TaskRecord",
|
||||
RiskLevel::Medium,
|
||||
schema: object_schema(vec![("id", "string", true), ("new_queue", "string", true)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
let new_queue = args["new_queue"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 new_queue"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
// queue 白名单校验(对标 commands::task::validate_queue)
|
||||
const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"];
|
||||
if !TASK_QUEUE_VALUES.contains(&new_queue.as_str()) {
|
||||
anyhow::bail!("非法 new_queue 值 {:?},合法值: {:?}", new_queue, TASK_QUEUE_VALUES);
|
||||
}
|
||||
|
||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||||
let current = repo
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("任务 {id} 不存在"))?;
|
||||
|
||||
// 一致性约束联动:根据 new_queue 决定 status 是否需调整
|
||||
// (对标 commands::task::move_task_queue 同源逻辑,单一真相源)
|
||||
const ACTIVE_OK_STATUSES: &[&str] = &["in_progress", "in_review", "testing"];
|
||||
let new_status = match new_queue.as_str() {
|
||||
"done" => "done".to_string(),
|
||||
"backlog" => "todo".to_string(),
|
||||
"active" => {
|
||||
if ACTIVE_OK_STATUSES.contains(¤t.status.as_str()) {
|
||||
current.status.as_str().to_string()
|
||||
} else {
|
||||
"in_progress".to_string()
|
||||
}
|
||||
}
|
||||
"todo" => "todo".to_string(),
|
||||
"decision" => current.status.as_str().to_string(),
|
||||
_ => unreachable!("queue 白名单已收口"),
|
||||
};
|
||||
|
||||
// 写 queue(queue 已在 tasks 白名单);与 current 不同才写(避免无谓 updated_at 抖动)
|
||||
if current.queue != new_queue {
|
||||
repo.update_field(id, "queue", &new_queue).await?;
|
||||
}
|
||||
// 写 status(专用 set_status_for_aggregation 绕过 status 收口,合法非状态机路径)
|
||||
if current.status.as_str() != new_status {
|
||||
repo.set_status_for_aggregation(id, &new_status).await?;
|
||||
}
|
||||
|
||||
// 回读最新记录返回
|
||||
let updated = repo
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("任务 {id} 不存在(移动后回读失败)"))?;
|
||||
Ok(serde_json::to_value(&updated)?)
|
||||
}
|
||||
);
|
||||
|
||||
// ── update_content (Medium,设计 §五) ──
|
||||
// 更新 content_json 结构化需求规格({background,acceptance_criteria[],scope[],technical_design})。
|
||||
// 走通用 update_field(content_json 已在 tasks 白名单) + JSON 合法性校验(对齐 create_task 校验)。
|
||||
// AI 从对话中提取信息填充 content_json,人确认后 AI 执行中用 acceptance_criteria 自检。
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"update_content",
|
||||
"更新任务的结构化需求规格 content_json(JSON 字符串 {background,acceptance_criteria[],scope[],technical_design,custom_fields})。参数 id(任务 ID)、content_json(合法 JSON 字符串,空串清空 content_json)。校验:须是合法 JSON,否则拒。AI 用途:从对话提取信息填充需求规格,供后续 acceptance_criteria 自检",
|
||||
RiskLevel::Medium,
|
||||
schema: object_schema(vec![("id", "string", true), ("content_json", "string", true)]),
|
||||
args => {
|
||||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||
let content_json = args["content_json"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 content_json"))?;
|
||||
// JSON 合法性校验(对齐 commands::task::create_task 的 content_json 校验)
|
||||
// 非空串须是合法 JSON;空串视为清空(写 NULL)——但 update_field 收 string,
|
||||
// 用空串写库后会留空字符串而非 NULL(可接受,content_json 为 Option<String> 但
|
||||
// 通用 update_field 走 TEXT 列接受空串)。这里对非空串做校验,空串直接放行清空。
|
||||
if !content_json.trim().is_empty() {
|
||||
serde_json::from_str::<serde_json::Value>(content_json)
|
||||
.map_err(|e| anyhow::anyhow!("content_json 不是合法 JSON: {e}"))?;
|
||||
}
|
||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||||
repo.update_field(id, "content_json", content_json).await?;
|
||||
let title = repo.get_by_id(id).await?.map(|t| t.title).unwrap_or_default();
|
||||
Ok(serde_json::json!({ "id": id, "title": title, "field": "content_json", "updated": true }))
|
||||
}
|
||||
);
|
||||
|
||||
// ── get_project_timeline (Low 只读,设计 §五 + §2.4) ──
|
||||
// 查询项目事件流(project_events 统一事件流)。AI 精准检索基础:
|
||||
// 回答"上周做了什么 / 这个任务为何 blocked / 决策何时做出"。
|
||||
// 参数 project_id(必填) + event_type(可选过滤) + limit(默认 50,上限 200)。
|
||||
// source 标 "ai"(AI 工具路径,区别于 IPC 层的 "human")。
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"get_project_timeline",
|
||||
"查询项目事件流(AI 精准检索基础,回答\"上周做了什么/这个任务为何 blocked/决策何时做出\")。参数 project_id(必填,项目 ID)、event_type(可选,按事件类型精确过滤,如 task_created/task_advanced/idea_promoted/idea_evaluated/decision_made 等,空=不过滤)、limit(可选,返回条数上限,默认 50,内部钳制 ≤ 200)。返回 { items: 事件列表(时间倒序), total, project_id }。事件含 event_type/entity_type/entity_id/from_state/to_state/source/created_at",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![
|
||||
("project_id", "string", true),
|
||||
("event_type", "string", false),
|
||||
("limit", "integer", false),
|
||||
]),
|
||||
args => {
|
||||
let project_id = args["project_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
if project_id.is_empty() {
|
||||
anyhow::bail!("project_id 不能为空");
|
||||
}
|
||||
let event_filter = args.get("event_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
// limit 默认 50(对齐 commands::events::default_limit);负数/异常值兜底 50。
|
||||
let limit = args.get("limit")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as u32)
|
||||
.unwrap_or(50);
|
||||
|
||||
let repo = df_storage::crud::ProjectEventRepo::new(&db);
|
||||
let mut events = repo.get_by_project(&project_id, limit).await?;
|
||||
// 应用层按 event_type 过滤(单用户事件量小,保持 Repo 方法精简)。
|
||||
if let Some(et) = &event_filter {
|
||||
events.retain(|e| e.event_type == *et);
|
||||
}
|
||||
let total = events.len();
|
||||
Ok(serde_json::json!({
|
||||
"items": events,
|
||||
"total": total,
|
||||
"project_id": project_id,
|
||||
}))
|
||||
}
|
||||
);
|
||||
|
||||
// ── add_project_service (Medium,设计 §2.3 + §五 + D10) ──
|
||||
// 添加项目依赖的基础设施配置(数据库/缓存/MQ/API 等)。为 AI 执行任务时提供基础设施上下文:
|
||||
// "这项目用了什么数据库、Redis 在哪、有没有 MQ"。
|
||||
// service_type 白名单 + D10 凭证审查下沉 Repo::insert_validated(单一真相源,与 IPC
|
||||
// add_project_service 同源)。
|
||||
//
|
||||
// ⚠️ D10 安全边界:config_json / endpoint / remark 不含敏感凭证(密码/密钥/Token)。
|
||||
// Repo 层审查 password/secret/token/api_key 子串,命中即拒。凭证走环境变量。
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"add_project_service",
|
||||
"为项目添加基础设施配置(为 AI 执行任务时提供\"用了什么数据库/缓存/MQ/API\"上下文)。参数:project_id(项目 ID)、name(服务名,如 主库/Redis 缓存)、service_type(类型,白名单 mysql/postgresql/sqlite/redis/mongodb/mq/api/other)、environment(环境 development/staging/production,默认 development)、endpoint(可选,连接地址 localhost:3306 或 URL,不含凭证)、config_json(可选,类型相关配置 JSON 字符串,如 {\"pool_size\":10},⚠️不含密码字段,凭证走环境变量)、remark(可选,备注)。返回新增完整记录。约束:service_type 白名单;D10 安全边界 config_json/endpoint/remark 含 password/secret/token/api_key 子串拒绝",
|
||||
RiskLevel::Medium,
|
||||
schema: object_schema(vec![
|
||||
("project_id", "string", true),
|
||||
("name", "string", true),
|
||||
("service_type", "string", true),
|
||||
("environment", "string", false),
|
||||
("endpoint", "string", false),
|
||||
("config_json", "string", false),
|
||||
("remark", "string", false),
|
||||
]),
|
||||
args => {
|
||||
let project_id = args["project_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
let name = args["name"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 name"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
let service_type = args["service_type"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 service_type"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
if project_id.is_empty() {
|
||||
anyhow::bail!("project_id 不能为空");
|
||||
}
|
||||
if name.is_empty() {
|
||||
anyhow::bail!("name 不能为空");
|
||||
}
|
||||
if service_type.is_empty() {
|
||||
anyhow::bail!("service_type 不能为空");
|
||||
}
|
||||
let endpoint = args.get("endpoint")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
let config_json = args.get("config_json")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
let environment = args.get("environment")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("development")
|
||||
.to_string();
|
||||
let remark = args.get("remark")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let record = ProjectServiceRecord {
|
||||
id: new_id(),
|
||||
project_id: project_id.clone(),
|
||||
name,
|
||||
service_type,
|
||||
endpoint,
|
||||
config_json,
|
||||
environment,
|
||||
remark,
|
||||
created_at: now_millis(),
|
||||
updated_at: now_millis(),
|
||||
};
|
||||
let repo = df_storage::crud::ProjectServiceRepo::new(&db);
|
||||
// insert_validated 内含 service_type 白名单 + D10 凭证审查(密码/token/api_key 子串拒绝)。
|
||||
let id = repo.insert_validated(record).await?;
|
||||
let inserted = repo.get_by_id(&id).await?
|
||||
.ok_or_else(|| anyhow::anyhow!("插入后回读失败"))?;
|
||||
Ok(serde_json::to_value(&inserted)?)
|
||||
}
|
||||
);
|
||||
|
||||
// ── list_project_services (Low 只读,设计 §2.3 + §五) ──
|
||||
// 查询项目基础设施配置。AI 执行任务时按需取基础设施上下文:
|
||||
// 部署 production 任务只取 production 服务,开发调试取 development(环境隔离避免误连生产库)。
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"list_project_services",
|
||||
"查询项目基础设施配置(AI 执行任务时取\"用了什么数据库/缓存/MQ/API\"上下文)。参数:project_id(项目 ID)、environment(可选,按环境过滤 development/staging/production,空=返回所有环境)。返回 { items: 配置列表(时间倒序), total, project_id }。环境隔离:production 部署任务取 production 服务,开发调试取 development,避免误连生产库",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![
|
||||
("project_id", "string", true),
|
||||
("environment", "string", false),
|
||||
]),
|
||||
args => {
|
||||
let project_id = args["project_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
if project_id.is_empty() {
|
||||
anyhow::bail!("project_id 不能为空");
|
||||
}
|
||||
let env_filter = args.get("environment")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let repo = df_storage::crud::ProjectServiceRepo::new(&db);
|
||||
let items = if let Some(env) = &env_filter {
|
||||
repo.list_by_project_env(&project_id, env).await?
|
||||
} else {
|
||||
repo.list_by_project(&project_id).await?
|
||||
};
|
||||
let total = items.len();
|
||||
Ok(serde_json::json!({
|
||||
"items": items,
|
||||
"total": total,
|
||||
"project_id": project_id,
|
||||
}))
|
||||
}
|
||||
);
|
||||
|
||||
// ── list_project_modules (Low 只读,工程系统) ──
|
||||
// 查询项目工程列表(AI 取项目结构上下文:有哪些代码仓库、各自目录/技术栈/Git 地址)。
|
||||
// 多工程场景(Monorepo/微服务/前后端分离)AI 据此决定在哪个工程执行 git/构建操作。
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"list_project_modules",
|
||||
"查询项目工程列表(每个工程是独立代码仓库)。参数:project_id(项目 ID)。返回 { modules: 工程列表(含 name/path/git_url/stack), total }。AI 据此了解项目有哪些代码仓库及各自技术栈",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![
|
||||
("project_id", "string", true),
|
||||
]),
|
||||
args => {
|
||||
let project_id = args["project_id"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
if project_id.is_empty() {
|
||||
anyhow::bail!("project_id 不能为空");
|
||||
}
|
||||
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
|
||||
let modules = repo.list_by_project(&project_id).await?;
|
||||
let total = modules.len();
|
||||
Ok(serde_json::json!({
|
||||
"modules": modules,
|
||||
"total": total,
|
||||
"project_id": project_id,
|
||||
}))
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! 回收站类 AI 工具声明式注册(register_trash_tools 1 个:list_trash 迁入)。
|
||||
//!
|
||||
//! 迁自 `tool_registry.rs::register_trash_tools`(原 1 个 list_trash),改用 `declare_tool!` 宏。
|
||||
//!
|
||||
//! 迁移策略(handler 逻辑零变更):
|
||||
//! - handler body 逐字照搬原 `register_trash_tools` 内 async move 块(逻辑等价),
|
||||
//! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone();
|
||||
//! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。
|
||||
//! - name/desc/schema/risk 与原手写定义逐字一致。
|
||||
//! - 复用常量:MAX_LIST_RESULTS(super::tool_registry 单真相源)。
|
||||
//!
|
||||
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
|
||||
//! 工具名集合稳定(防 rename / 漏注册)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||||
use df_ai::declare_tool;
|
||||
use df_storage::db::Database;
|
||||
|
||||
use crate::commands::ai::tool_registry::MAX_LIST_RESULTS;
|
||||
|
||||
/// 注册 list_trash 工具到 `$registry`。
|
||||
///
|
||||
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
|
||||
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
|
||||
/// - risk=Low(只读)
|
||||
/// - handler body 与原 async move 块逐字一致(逻辑零变更)
|
||||
///
|
||||
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
|
||||
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"list_trash",
|
||||
"列出回收站已删除项目,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]),
|
||||
args => {
|
||||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||||
let items = repo.list_deleted().await?;
|
||||
let total = items.len();
|
||||
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||||
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
|
||||
let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
|
||||
let has_more = (offset + page_items.len()) < total;
|
||||
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! 工作流类 AI 工具声明式注册(register_workflow_tools 1 个:run_workflow 迁入)。
|
||||
//!
|
||||
//! 迁自 `tool_registry.rs::register_workflow_tools`(原 1 个 run_workflow),改用 `declare_tool!` 宏。
|
||||
//!
|
||||
//! 迁移策略(handler 逻辑零变更):
|
||||
//! - handler body 逐字照搬原 async move 块(防御兜底返 Err,正常流程不可达),仅闭包包装改由
|
||||
//! `declare_tool!` 宏生成。
|
||||
//! - name/desc/schema/risk 与原手写定义逐字一致。
|
||||
//! - 无捕获:CR-52 删死代码后 handler 不 clone db(防御兜底,run_workflow 真正执行经
|
||||
//! ai_approve → execute_run_workflow_for_tool → workflow.rs::run_workflow_inner),用占位
|
||||
//! `Arc<()>` 捕获(宏要求 capture 形参)。
|
||||
//!
|
||||
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
|
||||
//! 工具名集合稳定(防 rename / 漏注册)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||||
use df_ai::declare_tool;
|
||||
|
||||
/// 注册 run_workflow 工具到 `$registry`(无 db 捕获,防御兜底)。
|
||||
///
|
||||
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
|
||||
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
|
||||
/// - risk=High(触发工作流引擎执行,须人工批准)
|
||||
/// - handler body 与原 async move 块逐字一致(逻辑零变更,仅返 Err 兜底)
|
||||
///
|
||||
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
|
||||
pub fn register(registry: &mut AiToolRegistry) {
|
||||
// 无捕获:占位 Arc<()>(CR-52 后 handler 不持 db,仅返 Err 防御)。
|
||||
let dummy: Arc<()> = Arc::new(());
|
||||
declare_tool!(
|
||||
registry,
|
||||
dummy: Arc<()>,
|
||||
"run_workflow",
|
||||
"按任务 target_status 推进对应工作流(含 AiNode 自审 / HumanNode 核对闸门)。参数 task_id + target_status 同时提供才联动任务推进(完成后按 target_status 推进任务,失败按退回态回滚)。属高风险操作(触发工作流引擎执行),须人工批准。审批通过后由后端直接执行工作流引擎并联动推进任务,返回 execution_id",
|
||||
RiskLevel::High,
|
||||
schema: object_schema(vec![("task_id", "string", true), ("target_status", "string", true)]),
|
||||
args => {
|
||||
let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?;
|
||||
let target_status = args["target_status"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?;
|
||||
// 防御兜底(正常流程不可达):run_workflow 经 ai_approve 分支执行,不经此 handler。
|
||||
// 误入此路径说明调用方异常(非 ai_approve 直接 execute),返回明确错误勿盲目重试
|
||||
// (对齐 run_command 超时标注模式 L700-712),High risk 去重缓存会断 LLM 重试循环。
|
||||
Err(anyhow::anyhow!(
|
||||
"run_workflow 须经人工审批后由后端 ai_approve 分支执行(转调 run_workflow_inner,\
|
||||
持完整 State)。本 handler 经 ai_tools.execute 调用属异常路径(无 AppHandle/State),\
|
||||
勿盲目重试同调用(task_id={}, target_status={});\
|
||||
若需推进任务,重新发起 run_workflow tool_call 走审批流程。",
|
||||
task_id, target_status
|
||||
))
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,53 @@ struct WorkflowEventPayload {
|
||||
event: WorkflowEvent,
|
||||
}
|
||||
|
||||
/// Lagged 兜底:查 DB 终态,命中返回 `(要补发的事件, status)`;未命中返回 None。
|
||||
///
|
||||
/// broadcast `Lagged` 不暴露被丢事件类型,关键终态事件(WorkflowCompleted/Failed)可能已丢,
|
||||
/// forward 循环会永久等不到。达阈值时查 DB 终态补发。三种"未命中"(仍 running / 无记录 /
|
||||
/// 查询失败)统一返回 None 并各自 warn,主循环据此重置 `lagged_total` 继续等。
|
||||
async fn probe_lagged_terminal(
|
||||
db: &df_storage::db::Database,
|
||||
exec_id: &ExecutionId,
|
||||
) -> Option<(WorkflowEvent, String)> {
|
||||
let record = match WorkflowRepo::new(db).get_by_id(exec_id).await {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
execution_id = %exec_id,
|
||||
"Lagged 兜底查询未找到执行记录,继续等待事件"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
execution_id = %exec_id,
|
||||
error = %e,
|
||||
"Lagged 兜底查询 DB 失败,继续等待事件"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let event = match record.status.as_str() {
|
||||
"completed" => WorkflowEvent::WorkflowCompleted {
|
||||
execution_id: exec_id.clone(),
|
||||
total_duration_ms: 0,
|
||||
},
|
||||
"failed" => WorkflowEvent::WorkflowFailed {
|
||||
execution_id: exec_id.clone(),
|
||||
error: "工作流执行失败(Lagged 兜底补发,详情见 DB)".to_string(),
|
||||
failed_node: String::new(),
|
||||
},
|
||||
"cancelled" => WorkflowEvent::WorkflowFailed {
|
||||
execution_id: exec_id.clone(),
|
||||
error: "工作流被取消(Lagged 兜底补发)".to_string(),
|
||||
failed_node: String::new(),
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
Some((event, record.status))
|
||||
}
|
||||
|
||||
/// 触发工作流执行(核心命令)
|
||||
///
|
||||
/// 流程:build_dag 校验 → 写入执行记录(status=running) → 后台异步执行 →
|
||||
@@ -183,63 +230,29 @@ pub async fn run_workflow_inner(
|
||||
if lagged_total < LAGGED_PROBE_THRESHOLD {
|
||||
continue;
|
||||
}
|
||||
// 累计/单次达阈值:查 DB 终态兜底
|
||||
let workflows = WorkflowRepo::new(&forward_db);
|
||||
match workflows.get_by_id(&forward_exec_id).await {
|
||||
Ok(Some(record)) => {
|
||||
let terminal = match record.status.as_str() {
|
||||
"completed" => Some(WorkflowEvent::WorkflowCompleted {
|
||||
execution_id: forward_exec_id.clone(),
|
||||
total_duration_ms: 0,
|
||||
}),
|
||||
"failed" => Some(WorkflowEvent::WorkflowFailed {
|
||||
execution_id: forward_exec_id.clone(),
|
||||
error: "工作流执行失败(Lagged 兜底补发,详情见 DB)"
|
||||
.to_string(),
|
||||
failed_node: String::new(),
|
||||
}),
|
||||
"cancelled" => Some(WorkflowEvent::WorkflowFailed {
|
||||
execution_id: forward_exec_id.clone(),
|
||||
error: "工作流被取消(Lagged 兜底补发)".to_string(),
|
||||
failed_node: String::new(),
|
||||
}),
|
||||
_ => None,
|
||||
// 累计/单次达阈值:查 DB 终态兜底(probe_lagged_terminal 收口三种未命中情况)
|
||||
match probe_lagged_terminal(&forward_db, &forward_exec_id).await {
|
||||
Some((synth_event, status)) => {
|
||||
let payload = WorkflowEventPayload {
|
||||
execution_id: forward_exec_id.clone(),
|
||||
event: synth_event.clone(),
|
||||
};
|
||||
if let Some(synth_event) = terminal {
|
||||
let payload = WorkflowEventPayload {
|
||||
execution_id: forward_exec_id.clone(),
|
||||
event: synth_event.clone(),
|
||||
};
|
||||
if let Err(e) = forward_app.emit("workflow-event", &payload) {
|
||||
tracing::warn!("Lagged 终态兜底事件转发失败: {}", e);
|
||||
}
|
||||
tracing::info!(
|
||||
execution_id = %forward_exec_id,
|
||||
status = %record.status,
|
||||
"Lagged 兜底命中终态,补发 {} 并退出 forward",
|
||||
match synth_event {
|
||||
WorkflowEvent::WorkflowCompleted { .. } => "WorkflowCompleted",
|
||||
_ => "WorkflowFailed",
|
||||
}
|
||||
);
|
||||
break;
|
||||
if let Err(e) = forward_app.emit("workflow-event", &payload) {
|
||||
tracing::warn!("Lagged 终态兜底事件转发失败: {}", e);
|
||||
}
|
||||
// DB 仍非终态(running),重置累计计数继续等待后续事件
|
||||
lagged_total = 0;
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
tracing::info!(
|
||||
execution_id = %forward_exec_id,
|
||||
"Lagged 兜底查询未找到执行记录,继续等待事件"
|
||||
status = %status,
|
||||
"Lagged 兜底命中终态,补发 {} 并退出 forward",
|
||||
match synth_event {
|
||||
WorkflowEvent::WorkflowCompleted { .. } => "WorkflowCompleted",
|
||||
_ => "WorkflowFailed",
|
||||
}
|
||||
);
|
||||
lagged_total = 0;
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
execution_id = %forward_exec_id,
|
||||
error = %e,
|
||||
"Lagged 兜底查询 DB 失败,继续等待事件"
|
||||
);
|
||||
None => {
|
||||
// 未命中(仍 running / 无记录 / 查询失败):重置累计计数继续等待
|
||||
lagged_total = 0;
|
||||
}
|
||||
}
|
||||
@@ -257,7 +270,21 @@ pub async fn run_workflow_inner(
|
||||
// F-260616-06 ②-2: move task_id / target_status 进闭包供完成/失败回调使用
|
||||
let cb_task_id = task_id.clone();
|
||||
let cb_target_status = target_status.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// panic-guard:执行 spawn 闭包最外层包 catch_unwind(AssertUnwindSafe(..))。
|
||||
// panic 时走已存在 failed 分支(update status=failed + emit WorkflowFailed + state_registry.remove),
|
||||
// 防 panic 后终态不发/registry 不清永久卡。主逻辑/调度零改动,仅在 panic 时补发终态。
|
||||
// 注:依赖 panic=unwind(tokio 默认),若构建设 panic=abort 则 catch_unwind 不生效(尽力兜底)。
|
||||
tauri::async_runtime::spawn({
|
||||
// panic 兜底需要这些引用:move 进 catch_unwind 闭包前各取一份 clone 供 panic 分支使用。
|
||||
let panic_exec_id = exec_id.clone();
|
||||
let panic_db = db.clone();
|
||||
let panic_event_bus = event_bus.clone();
|
||||
let panic_registry = state_registry.clone();
|
||||
async move {
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use futures::FutureExt;
|
||||
// 原闭包主体包进 catch_unwind:panic 以 Err(Box<dyn Any>) 返回,Ok 走正常路径。
|
||||
let outcome = AssertUnwindSafe(async move {
|
||||
let mut executor = DagExecutor::new(event_bus.clone(), exec_id.clone());
|
||||
// 注册执行器状态机:StateMachine 内部 Arc<Mutex>,clone 共享底层 HashMap,
|
||||
// cancel_workflow_node IPC 经 execution_id 取此引用 set_cancelled,直达运行中 HumanNode
|
||||
@@ -351,6 +378,46 @@ pub async fn run_workflow_inner(
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}) // 闭 catch_unwind 内 async move
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
// panic 分支:catch_unwind 返回 Err(Box<dyn Any + Send>),走已存在 failed 清理路径。
|
||||
match outcome {
|
||||
Ok(_) => {}
|
||||
Err(panic_payload) => {
|
||||
// 提取 panic 消息(String/&'static str 常见,其他类型用兜底文案)
|
||||
let msg = panic_payload
|
||||
.downcast_ref::<String>()
|
||||
.map(|s| s.clone())
|
||||
.or_else(|| panic_payload.downcast_ref::<&'static str>().map(|s| s.to_string()))
|
||||
.unwrap_or_else(|| "工作流执行 panic".to_string());
|
||||
tracing::error!(
|
||||
execution_id = %panic_exec_id,
|
||||
"工作流执行 panic,走 failed 分支兜底: {}",
|
||||
msg
|
||||
);
|
||||
let workflows = WorkflowRepo::new(&panic_db);
|
||||
if let Err(e) = workflows.update_field(&panic_exec_id, "status", "failed").await {
|
||||
tracing::error!("更新工作流状态失败(panic 兜底): {}", e);
|
||||
}
|
||||
if let Err(e) = workflows
|
||||
.update_field(&panic_exec_id, "completed_at", &now_millis())
|
||||
.await
|
||||
{
|
||||
tracing::error!("更新工作流完成时间失败(panic 兜底): {}", e);
|
||||
}
|
||||
panic_registry.lock().await.remove(&panic_exec_id);
|
||||
panic_event_bus
|
||||
.send(WorkflowEvent::WorkflowFailed {
|
||||
execution_id: panic_exec_id.clone(),
|
||||
error: msg,
|
||||
failed_node: String::new(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(execution_id.to_string())
|
||||
|
||||
+36
-18
@@ -292,20 +292,14 @@ pub fn run() {
|
||||
Err(e) => tracing::warn!("[tunnel] 连接 relay 失败(非阻断,supervisor 将重试): {}", e),
|
||||
}
|
||||
|
||||
// ── supervisor:断线自动重连(根治 device 离线) ──
|
||||
// df-tunnel 无内置 auto-reconnect(tunnel.rs:332 注释留外层 supervisor)。
|
||||
// 桌面端进程重启 / 网络断 / relay 重启 → device 离线 → miniapp 命令 delivered=0
|
||||
// → 点发无响应。supervisor 轮询 is_connected,断开则用保存参数指数退避重连,
|
||||
// 复用同一 on_command 回调(逻辑不变)。task 永驻(supervisor 生命周期 = 应用生命周期)。
|
||||
// supervisor:轮询 is_connected,断开则重连
|
||||
// 连接被拒(relay 未部署)→300s+INFO;其他错误→2s-60s WARN
|
||||
let mut backoff = std::time::Duration::from_secs(2);
|
||||
let mut last_was_refused = false;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
if !state.tunnel.is_connected() {
|
||||
tracing::info!(
|
||||
"[tunnel-supervisor] 检测到断开,尝试重连 url={} device_id={}",
|
||||
relay_url,
|
||||
device_id
|
||||
);
|
||||
tracing::info!("[tunnel-supervisor] 断线重连 url={relay_url} device_id={device_id}");
|
||||
match state
|
||||
.tunnel
|
||||
.connect(&relay_url, &device_id, &token, on_command.clone())
|
||||
@@ -314,24 +308,39 @@ pub fn run() {
|
||||
Ok(()) => {
|
||||
tracing::info!("[tunnel-supervisor] 重连成功,退避重置");
|
||||
backoff = std::time::Duration::from_secs(2);
|
||||
last_was_refused = false;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
backoff_secs = backoff.as_secs(),
|
||||
"[tunnel-supervisor] 重连失败,退避后重试"
|
||||
);
|
||||
tokio::time::sleep(backoff).await;
|
||||
// 指数退避上限 60s,防网络长故障时高频重连打 relay
|
||||
backoff = (backoff * 2).min(std::time::Duration::from_secs(60));
|
||||
let refused = is_connection_refused(&e);
|
||||
if refused {
|
||||
// relay 未运行:INFO + 300s 退避,不刷 WARN
|
||||
if !last_was_refused {
|
||||
tracing::info!("[tunnel-supervisor] relay 未运行({e}),300s 后轻量探测");
|
||||
last_was_refused = true;
|
||||
}
|
||||
// 300s 退避(避免空转刷屏,relay 未运行时一直保持此间隔)
|
||||
tokio::time::sleep(std::time::Duration::from_secs(300)).await;
|
||||
} else {
|
||||
tracing::warn!("[tunnel-supervisor] 重连失败 {e},退避 {backoff_secs}s", backoff_secs = backoff.as_secs());
|
||||
tokio::time::sleep(backoff).await;
|
||||
// 指数退避上限 60s,防网络长故障时高频重连打 relay
|
||||
backoff = (backoff * 2).min(std::time::Duration::from_secs(60));
|
||||
last_was_refused = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if last_was_refused {
|
||||
last_was_refused = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
// 辅助函数 is_connection_refused 在文件尾部
|
||||
// 判断 TunnelError 是否为连接被拒(relay 未运行)。
|
||||
// 匹配 WS connect_async 产生的 IO error:ECONNREFUSED(Win:10061,Unix:111)。
|
||||
// 被拒 → relay 未部署,用长退避避免刷屏;其他错误(超时/DNS/网络瞬断)走指数退避。
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// 项目
|
||||
commands::project::list_projects,
|
||||
@@ -500,3 +509,12 @@ pub fn run() {
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
/// 判断 TunnelError 是否为连接被拒(匹配 ECONNREFUSED 各平台表述)
|
||||
fn is_connection_refused(e: &df_tunnel::TunnelError) -> bool {
|
||||
let msg = e.to_string().to_lowercase();
|
||||
msg.contains("refused")
|
||||
|| msg.contains("10061")
|
||||
|| msg.contains("积极拒绝")
|
||||
|| msg.contains("actively refused")
|
||||
}
|
||||
|
||||
@@ -123,6 +123,34 @@ impl LlmConcurrency {
|
||||
sema.acquire_owned().await.expect("llm per_conv semaphore closed")
|
||||
}
|
||||
|
||||
/// 取单对话内并发 permit 的**非阻塞**版本(对齐 `acquire_per_sub_flow` 语义)。
|
||||
///
|
||||
/// 与 `acquire_per_conv` 同样按 conv_id 取/建 Semaphore(permits=当前 per_conv_permits),
|
||||
/// 区别仅在最后一步用 `try_acquire_owned`(非阻塞):耗尽返 `None`,调用方降级,不阻塞。
|
||||
///
|
||||
/// **用途**:压缩(`compress_via_llm`)在 agentic loop 内被调用,loop 入口已持 1 个 per_conv
|
||||
/// permit;当 `set_per_conv(1)` 后 per_conv Semaphore 仅 1 permit,loop 入口占满,压缩再
|
||||
/// `acquire_per_conv`(阻塞)将永久等待 → `is_compressing` 永真 → 卡死。改用本非阻塞版本:
|
||||
/// 拿不到(None)调用方提前返回降级(下轮再试),非崩溃。
|
||||
///
|
||||
/// - 返 `Some(permit)`:成功获取,permit 随 Drop 自动释放。
|
||||
/// - 返 `None`:本对话并发已耗尽,调用方降级(非错误)。锁持有短(不含 try_acquire)。
|
||||
pub async fn try_acquire_per_conv(
|
||||
&self,
|
||||
conv_id: &str,
|
||||
) -> Option<tokio::sync::OwnedSemaphorePermit> {
|
||||
let sema = {
|
||||
let mut map = self.per_conv.lock().await;
|
||||
let permits = self.per_conv_permits.load(std::sync::atomic::Ordering::SeqCst);
|
||||
map.entry(conv_id.to_string())
|
||||
.or_insert_with(|| Arc::new(Semaphore::new(permits)))
|
||||
.clone()
|
||||
};
|
||||
// try_acquire_owned 非阻塞:Err(NoPermits) 返 None 降级,不阻塞调用方。
|
||||
// 对齐 acquire_global/per_conv 的 expect 前提:Semaphore 不会 close(无 close() 调用)。
|
||||
sema.try_acquire_owned().ok()
|
||||
}
|
||||
|
||||
/// F-09 B 批5:conv 退出清理。loop 结束 + 无 pending 审批时 remove 该 conv 的 Semaphore 条目。
|
||||
/// **时机由调用方判断**(agentic loop 退出点):仅在确信无后续 acquire 时调用,否则误删会致
|
||||
/// 该 conv 下次 acquire 重建 Semaphore(限流计数清零,非致命,但语义偏离)。
|
||||
|
||||
+20
-20
@@ -7,7 +7,7 @@ import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProvid
|
||||
export const aiApi = {
|
||||
/**
|
||||
* 发送消息(非阻塞,通过 ai-chat-event 流式返回);skill 传技能名则注入其 SKILL.md。
|
||||
* F-260614-05 Phase 2c: parts 透传给后端 ai_chat_send → user_parts → provider vision 端点。
|
||||
* parts 透传给后端 ai_chat_send → user_parts → provider vision 端点。
|
||||
* parts 为空/undefined → 后端走 user() 纯文本(向后兼容)。
|
||||
* Input Augmentation: mentionSpans 透传给后端 ai_chat_send 的 mention_spans 参数
|
||||
* (后端 resolve_and_inject 按 kind 投影成 Augmentation 注入 system prompt)。
|
||||
@@ -20,14 +20,14 @@ export const aiApi = {
|
||||
skill: skill || null,
|
||||
modelOverride: modelOverride || null,
|
||||
parts: parts && parts.length > 0 ? parts : null,
|
||||
// F-260616-09 B 批4(决策 e):传 conv_id,操作指定 conv 的 per_conv(不依赖 active 单值)。
|
||||
// 传 conv_id,操作指定 conv 的 per_conv(不依赖 active 单值)。
|
||||
conversationId: conversationId || null,
|
||||
// Input Augmentation: 非空数组才传(undefined/空让 payload 不含该键,后端默认 None)。
|
||||
mentionSpans: mentionSpans && mentionSpans.length > 0 ? mentionSpans : null,
|
||||
})
|
||||
},
|
||||
|
||||
/** 强制发送(B-260616-02: 复位 generating 残留后走 send 同款流程);parts/mentionSpans 透传同 sendMessage。 */
|
||||
/** 强制发送(复位 generating 残留后走 send 同款流程);parts/mentionSpans 透传同 sendMessage。 */
|
||||
forceSend(message: string, language?: string, skill?: string, modelOverride?: string | null, parts?: ContentPart[], conversationId?: string | null, mentionSpans?: MentionSpan[]): Promise<string> {
|
||||
return invoke('ai_chat_force_send', {
|
||||
message,
|
||||
@@ -35,7 +35,7 @@ export const aiApi = {
|
||||
skill: skill || null,
|
||||
modelOverride: modelOverride || null,
|
||||
parts: parts && parts.length > 0 ? parts : null,
|
||||
// F-260616-09 B 批4(决策 e):传 conv_id,强制复位+发送仅作用于目标 conv。
|
||||
// 传 conv_id,强制复位+发送仅作用于目标 conv。
|
||||
conversationId: conversationId || null,
|
||||
// Input Augmentation: 同 sendMessage,非空数组才传(后端 mention_spans 参数)。
|
||||
mentionSpans: mentionSpans && mentionSpans.length > 0 ? mentionSpans : null,
|
||||
@@ -76,7 +76,7 @@ export const aiApi = {
|
||||
},
|
||||
|
||||
/**
|
||||
* F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。
|
||||
* 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。
|
||||
* decision: 'once'(本次单次,执行后清)/ 'session'(当前会话,切会话清)/ 'always'(始终,持久化)/ 'deny'(拒绝)。
|
||||
* 后端 remove pending → 写授权目录(once/session/persistent)→ 执行工具 → try_continue 恢复 loop。
|
||||
*/
|
||||
@@ -85,7 +85,7 @@ export const aiApi = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 续跑 agentic 循环(F-260616-03:达 max_iterations 暂停态点继续)。
|
||||
* 续跑 agentic 循环(达 max_iterations 暂停态点继续)。
|
||||
* 后端 ai_continue_loop:复位 stop_flag → try_continue_agent_loop 重新 spawn
|
||||
* run_agentic_loop(iteration 从 0 重计,再跑 max_iterations 轮)。
|
||||
*/
|
||||
@@ -94,7 +94,7 @@ export const aiApi = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 停止 agentic 循环并走完成流程(F-260616-03:达 max_iterations 暂停态点停止)。
|
||||
* 停止 agentic 循环并走完成流程(达 max_iterations 暂停态点停止)。
|
||||
* 后端 ai_stop_loop:复位 generating + emit AiCompleted(暂停前已 save)。
|
||||
*/
|
||||
stopLoop(conversationId: string): Promise<string> {
|
||||
@@ -102,7 +102,7 @@ export const aiApi = {
|
||||
},
|
||||
|
||||
/** 查询某对话积压的待审批工具(重启后恢复 toolCard pending_approval 态用)。
|
||||
* 阶段4:返 kind 字段(risk/path),前端按 kind 渲染不同决策按钮。 */
|
||||
* 返 kind 字段(risk/path),前端按 kind 渲染不同决策按钮。 */
|
||||
pendingToolCalls(convId: string): Promise<{ tool_call_id: string; conversation_id: string | null; kind: 'risk' | 'path' }[]> {
|
||||
return invoke('ai_pending_tool_calls', { convId })
|
||||
},
|
||||
@@ -114,7 +114,7 @@ export const aiApi = {
|
||||
|
||||
/**
|
||||
* 停止当前生成。
|
||||
* F-260616-09 B 批4(决策 e):传 convId,停止仅作用于目标 conv(不杀其他后台 conv 的 loop)。
|
||||
* 传 convId,停止仅作用于目标 conv(不杀其他后台 conv 的 loop)。
|
||||
* convId 省略时后端 fallback active conv。
|
||||
*/
|
||||
stopChat(convId?: string | null): Promise<void> {
|
||||
@@ -122,14 +122,14 @@ export const aiApi = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 查询后端真实 generating 状态(B-260615-22:发送前 IPC 查后端真值)。
|
||||
* F-260616-09 B 批4(决策 e):传 convId 精确查指定 conv;省略时后端 fallback active conv。
|
||||
* 查询后端真实 generating 状态(发送前 IPC 查后端真值)。
|
||||
* 传 convId 精确查指定 conv;省略时后端 fallback active conv。
|
||||
*/
|
||||
isGenerating(convId?: string | null): Promise<boolean> {
|
||||
return invoke('ai_is_generating', { conversationId: convId || null })
|
||||
},
|
||||
|
||||
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
|
||||
// ── 手动上下文管理(清空 / 压缩) ──
|
||||
// 后端经 ai-chat-event emit 生命周期事件:
|
||||
// ai_context_cleared { conversation_id } — 清空完成
|
||||
// ai_compressing { conversation_id } — 压缩开始(loading)
|
||||
@@ -180,7 +180,7 @@ export const aiApi = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新 provider 负载均衡池配置(F-260614-04c):仅 UPDATE enabled/weight + 重建 per_provider caps。
|
||||
* 更新 provider 负载均衡池配置:仅 UPDATE enabled/weight + 重建 per_provider caps。
|
||||
* 走轻量分支,不经 ai_save_provider 全量 INSERT OR REPLACE(避免空 api_key 触发 R-PD-1 密钥迁移)。
|
||||
* 后端 weight clamp [0,100];落库后立即 reload_provider_caps,下条消息即按新配置 acquire。
|
||||
*/
|
||||
@@ -194,10 +194,10 @@ export const aiApi = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 测试连接并拉取厂商模型列表(F-01 阶段5)。
|
||||
* 后端:DB 取 provider → FR-S1 内存解析 api_key → fetch_and_probe
|
||||
* 测试连接并拉取厂商模型列表。
|
||||
* 后端:DB 取 provider → 内存解析 api_key → fetch_and_probe
|
||||
* (网络拉模型名 + 探测 4 维度)→ 写回 model_configs → 返回。
|
||||
* 返回值 ModelConfig 不含 api_key(FR-S1 闭环)。
|
||||
* 返回值 ModelConfig 不含 api_key。
|
||||
* 需已落库的 providerId(新建态先保存再拉取)。
|
||||
*/
|
||||
fetchModels(providerId: string): Promise<ModelConfig[]> {
|
||||
@@ -205,7 +205,7 @@ export const aiApi = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 单模型探测(F-01 阶段5):纯 CPU 启发式 + 预设表,无网络。
|
||||
* 单模型探测:纯 CPU 启发式 + 预设表,无网络。
|
||||
* 返回填充了 probe_source 的 ModelConfig。用途:手动补模型名后探测能力维度。
|
||||
*/
|
||||
probeModel(modelId: string): Promise<ModelConfig> {
|
||||
@@ -222,7 +222,7 @@ export const aiApi = {
|
||||
return invoke('ai_set_agent_max_iterations', { value })
|
||||
},
|
||||
|
||||
/** 设置流式对话失败自动重试次数(F-260616-07:只重试流前失败,0=不重试,默认3,上限10) */
|
||||
/** 设置流式对话失败自动重试次数(只重试流前失败,0=不重试,默认3,上限10) */
|
||||
setAgentMaxRetries(value: number): Promise<void> {
|
||||
return invoke('ai_set_agent_max_retries', { value })
|
||||
},
|
||||
@@ -243,12 +243,12 @@ export const aiApi = {
|
||||
return invoke('ai_reload_skills')
|
||||
},
|
||||
|
||||
/** F-260619-03 Phase A: 获取 AI 工具文件访问授权目录列表(含 workspace_root) */
|
||||
/** 获取 AI 工具文件访问授权目录列表(含 workspace_root) */
|
||||
getAllowedDirs(): Promise<string[]> {
|
||||
return invoke<string[]>('ai_get_allowed_dirs')
|
||||
},
|
||||
|
||||
/** F-260619-03 Phase A: 设置 AI 工具授权目录(持久化 + 同步内存白名单),返回规范化后的列表 */
|
||||
/** 设置 AI 工具授权目录(持久化 + 同步内存白名单),返回规范化后的列表 */
|
||||
setAllowedDirs(dirs: string[]): Promise<string[]> {
|
||||
return invoke<string[]>('ai_set_allowed_dirs', { dirs })
|
||||
},
|
||||
|
||||
@@ -102,7 +102,7 @@ import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { useProjectStore } from '../../stores/project'
|
||||
import { getConvState } from '../../composables/ai/useAiEvents'
|
||||
import { getConvState, textIdle } from '../../composables/ai/useAiEvents'
|
||||
import SkillMention from './SkillMention.vue'
|
||||
import ImageInput from './ImageInput.vue'
|
||||
import MentionPopover from './MentionPopover.vue'
|
||||
@@ -226,26 +226,17 @@ async function onDrop(e: DragEvent): Promise<void> {
|
||||
/** 发送可用:有文本或图片或已选技能(纯技能调用允许空文本) */
|
||||
const canSend = computed(() => inputText.value.trim().length > 0 || pendingImages.value.length > 0 || !!pendingSkill.value)
|
||||
|
||||
// L2 状态机停止按钮三态(批2 1c):从 conv_state 派生,旧 streaming/generating bool 双轨过渡兜底。
|
||||
//
|
||||
// 三态映射(对齐后端 ConvState 语义):
|
||||
// - 'stopping':ConvState=stopping,停止中 → 显"停止中" disabled(防重复点);
|
||||
// - 'stop': ConvState=generating(含审批挂起/压缩派生等活跃态)→ 显停止按钮可点;
|
||||
// - 'retry': ConvState=error,停止失败可重试 → 显重试按钮(再发 stop 信号);
|
||||
// - 'idle': 空闲(或 conv_state 未收到回退旧 bool=false)→ 显发送按钮。
|
||||
//
|
||||
// 兜底:conv_state 未追踪过(getConvState 返回 null,CONV_STATE_ENABLED=off 或老后端)时,
|
||||
// 回退旧 store.state.streaming 判断(generating→显停止,否则 idle)。不强制全替旧 bool,防回归。
|
||||
// 注:旧 streaming 是全局单值(非 per-conv),仅当前会话生成时为 true;F-09 多会话并发下 conv_state
|
||||
// 更精确,但回退路径用 streaming 对单会话场景语义等价。
|
||||
// 按钮状态:streaming=true 且 textIdle=false(最近 300ms 有新 delta) → 'stop'(红);
|
||||
// 否则 → 'idle'(白)。textIdle 是 reactive ref,在 AiTextDelta 中每 delta 复位 false,
|
||||
// 300ms 无新 delta 后 setTimeout 置 true。不与 streaming 绑定——文本显示完后 300ms 按钮白,
|
||||
// 不等 AiCompleted,streaming 持续 true 也不影响。
|
||||
const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
|
||||
const convId = store.state.activeConversationId
|
||||
const cs = getConvState(convId)
|
||||
if (cs === 'stopping') return 'stopping'
|
||||
if (cs === 'error') return 'retry'
|
||||
if (cs === 'generating' || cs === 'compressed') return 'stop'
|
||||
// conv_state 未收到(idle 或 null)→ 回退旧 streaming bool
|
||||
return store.state.streaming ? 'stop' : 'idle'
|
||||
const textActive = store.state.streaming && !textIdle.value
|
||||
return textActive ? 'stop' : 'idle'
|
||||
})
|
||||
|
||||
// ── 技能 `/` 联想 ──
|
||||
|
||||
@@ -38,6 +38,16 @@
|
||||
<option :value="3600000">{{ $t('settings.approvalTimeout60m') }}</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
<!-- 自定义提示词:注入到 AI 系统 prompt 末尾,用户可自定义 AI 行为偏好 -->
|
||||
<SettingRow ref="rowCustomPrompt" :label="$t('settings.labelCustomPrompt')" :desc="$t('settings.descCustomPrompt')">
|
||||
<textarea
|
||||
v-model="customPrompt"
|
||||
class="custom-prompt-input"
|
||||
:placeholder="$t('settings.placeholderCustomPrompt')"
|
||||
rows="4"
|
||||
@change="onCustomPromptChange"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<!-- all 二次确认弹层(独立,不与 Settings 壳层共用:壳层按钮写死「删除」不适合「确认接管」) -->
|
||||
@@ -108,9 +118,11 @@ const settings = reactive({
|
||||
// 审批超时(ms):0=不限时,缺省 900000(15min);仅固定合法选项,脏值回退默认
|
||||
approvalTimeout: clampApprovalTimeout(appSettings.get<number>('df-approval-timeout', 900000)),
|
||||
})
|
||||
const customPrompt = ref(appSettings.get<string>('custom_prompt', ''))
|
||||
|
||||
const rowAutoExecute = useTemplateRef<InstanceType<typeof SettingRow>>('rowAutoExecute')
|
||||
const rowLogLevel = useTemplateRef<InstanceType<typeof SettingRow>>('rowLogLevel')
|
||||
const rowCustomPrompt = useTemplateRef<InstanceType<typeof SettingRow>>('rowCustomPrompt')
|
||||
const rowApprovalTimeout = useTemplateRef<InstanceType<typeof SettingRow>>('rowApprovalTimeout')
|
||||
|
||||
// 上一次确认生效的档位:all 二次确认取消时恢复此值(非写死 low),避免 medium 用户误选 all
|
||||
@@ -152,6 +164,11 @@ function markSaved() {
|
||||
}
|
||||
|
||||
// 下一笔发起的审批即生效(aiShared.startApprovalTimer 每次实时读),已在跑的计时器沿用旧值。
|
||||
function onCustomPromptChange() {
|
||||
appSettings.set('custom_prompt', customPrompt.value)
|
||||
rowCustomPrompt.value?.markSaved()
|
||||
}
|
||||
|
||||
function onApprovalTimeoutChange() {
|
||||
appSettings.set('df-approval-timeout', settings.approvalTimeout)
|
||||
rowApprovalTimeout.value?.markSaved()
|
||||
@@ -192,6 +209,23 @@ function onApprovalTimeoutChange() {
|
||||
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.btn-danger { background: var(--df-danger); color: #fff; }
|
||||
.btn-danger:hover { filter: brightness(1.1); }
|
||||
.custom-prompt-input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.custom-prompt-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--df-primary);
|
||||
}
|
||||
.confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; }
|
||||
.confirm-enter-from, .confirm-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
||||
@@ -45,6 +45,24 @@ let _startPromise: Promise<void> | null = null
|
||||
// 上一次 AiTextDelta 的内容(单窗口内 LLM 重复 delta 防御用,见 handleStreamingEvent)
|
||||
let _lastDelta = ''
|
||||
|
||||
// 文本空闲信号(reactive ref):streaming=true 且 300ms 无新 delta 时=true。
|
||||
// 按钮据此显「发送」(idle)而非「停止」。不动 streaming(streaming 是渲染态,
|
||||
// 控制 MessageList 流式 block 显隐,不能在文本中途翻转,否则闪掉)。
|
||||
export const textIdle = ref(true)
|
||||
let _textIdleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function resetTextIdleTimer(): void {
|
||||
if (_textIdleTimer) clearTimeout(_textIdleTimer)
|
||||
textIdle.value = false // 有新 delta → 文本活跃
|
||||
_textIdleTimer = setTimeout(() => {
|
||||
textIdle.value = true // 300ms 无新 delta → 文本空闲
|
||||
_textIdleTimer = null
|
||||
}, 300)
|
||||
}
|
||||
function clearTextIdleTimer(): void {
|
||||
if (_textIdleTimer) { clearTimeout(_textIdleTimer); _textIdleTimer = null }
|
||||
textIdle.value = true // 整轮结束/新轮开始 → 默认空闲
|
||||
}
|
||||
|
||||
const appSettings = useAppSettingsStore()
|
||||
|
||||
// B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。
|
||||
@@ -298,6 +316,8 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
|
||||
}
|
||||
_lastDelta = event.delta
|
||||
state.currentText += event.delta
|
||||
// 重置文本空闲定时器:每次 delta 重置 300ms。无新 delta 到 300ms → textIdle=true → 按钮白。
|
||||
resetTextIdleTimer()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -305,6 +325,10 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
|
||||
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
|
||||
flushCurrentText()
|
||||
state.currentText = ''
|
||||
// 文本已刷新完毕 → 清空闲定时器(按钮白,等下一轮 deltas 来再红)
|
||||
// 不设 streaming=false:streaming 管渲染,多轮间需持续 true 让 MessageList 渲染后续 deltas。
|
||||
// 按钮白由 textIdle 独立控制,无需翻转 streaming。
|
||||
clearTextIdleTimer()
|
||||
state.completedTools = 0 // 新轮重置工具计数器
|
||||
state.messages.push({
|
||||
id: `ai-${nextMsgId()}` as MessageId,
|
||||
@@ -603,6 +627,7 @@ function handleUserMessageEvent(event: AiChatEvent): boolean {
|
||||
function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired') {
|
||||
clearStreamWatchdog(convId || undefined)
|
||||
clearAllToolSlowTimers() // B-260616-12: 整轮/错误/求助结束清全部工具慢执行计时器与已提示集合
|
||||
clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留)
|
||||
flushCurrentText()
|
||||
state.currentText = ''
|
||||
setStreaming(false, { convId: convId || null, reason })
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { ContentPart, AiMessage, MentionSpan, MessageId } from '@/api/types
|
||||
import { t } from '@/i18n/i18n-helpers'
|
||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||
import { setStreaming } from './streamingGuard'
|
||||
import { startListener } from './useAiEvents'
|
||||
import { startListener, flushCurrentText } from './useAiEvents'
|
||||
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates } from './aiShared'
|
||||
|
||||
/// 待发送队列上限(超过抛错提示用户)
|
||||
@@ -71,6 +71,10 @@ const modelOverride = ref<string | null>(null)
|
||||
* undefined/空数组 → 本地 user 消息 mentionSpans 置 undefined(纯文本气泡零回归)+ 后端不传。
|
||||
*/
|
||||
async function doSend(text: string, skill?: string, force = false, parts?: ContentPart[], spans?: MentionSpan[]) {
|
||||
// 防御:先 flush 残留 currentText(正常情况已是空,flushCurrentText 直接 return;
|
||||
// 异常时序下可防止上一轮文本被 state.currentText='' 误清)
|
||||
flushCurrentText()
|
||||
|
||||
const userMsgId = `user-${nextMsgId()}`
|
||||
state.messages.push({
|
||||
id: userMsgId as MessageId,
|
||||
@@ -291,16 +295,16 @@ export function drainQueue(convId?: string | null) {
|
||||
* 发送消息 — 三级降级(B-260616-02 L2 发送韧性):
|
||||
*
|
||||
* L0 normal: 后端 idle → 直接 doSend()
|
||||
* L1 queued: 后端 busy → 入队等 AiCompleted 续发(≤30s 正常等待)
|
||||
* L1 queued: 后端 busy → 入队等 AiCompleted 续发
|
||||
* L2 force: 排队>30s → 返回特殊标记,由调用方(handleSend)弹 confirm;
|
||||
* 用户确认后重新进 sendMessage(forceMode=true)走 forceSend IPC
|
||||
*
|
||||
* forceMode=true 时跳过入队,直接走 ai_chat_force_send。
|
||||
*
|
||||
* Input Augmentation: spans 随消息透传(L0 直接 doSend / L2 force doSend);
|
||||
* L1 入队时 queue item 当前不挂 spans(队列续发属异步重试,mention 区间在首次发送时
|
||||
* 已与文本对齐,排队等待后用户可能改输入,续发用旧 spans 语义模糊;续发走无 mention 路径,
|
||||
* 与现有 parts 入队续发的设计取舍一致——队列为韧性保内容,非保全部上下文元数据)。
|
||||
* 注:入队条件仅用 backendGenerating(IPC 查后端真实态),不再检查 state.streaming。
|
||||
* streaming 是渲染态(文本是否在屏幕上输出),不能作为「后端能否接收消息」的代理。
|
||||
* 文本显示完但后端在落库时,streaming 仍 true——此时按钮已白(基于 delta 时间戳),
|
||||
* 用户点击发送,若后端还在忙则入队短暂等待(~1s),AiCompleted 后自动续发。
|
||||
*/
|
||||
async function sendMessage(text: string, skill?: string, forceMode = false, parts?: ContentPart[], spans?: MentionSpan[]) {
|
||||
if (!text.trim() && !(parts && parts.length)) return
|
||||
@@ -320,14 +324,12 @@ async function sendMessage(text: string, skill?: string, forceMode = false, part
|
||||
backendGenerating = false
|
||||
}
|
||||
|
||||
// ── L1:后端 busy → 入队 ──
|
||||
if (backendGenerating || state.streaming) {
|
||||
// ── L1:后端 busy → 入队(消息已在本地显示,待后端空闲后自动发送)──
|
||||
// 仅用 backendGenerating,不用 state.streaming。streaming 是渲染态,不是后端忙的代理。
|
||||
if (backendGenerating) {
|
||||
if (state.queue.length >= QUEUE_LIMIT) {
|
||||
throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT }))
|
||||
}
|
||||
setStreaming(true, { convId: state.activeConversationId, reason: 'L1-enqueue' })
|
||||
// F-260614-05 Phase 2b: 入队项挂 parts(供 drainQueue/续发时本地 user 消息渲染图)
|
||||
// 同时挂 conversationId,供 drainQueue 按会话精准续发(防跨会话串话)
|
||||
state.queue.push({
|
||||
text: text.trim(),
|
||||
skill: skill || undefined,
|
||||
|
||||
@@ -137,6 +137,10 @@ export default {
|
||||
labelShowTokenUsage: 'Show token usage',
|
||||
descShowTokenUsage: 'Show token consumption of each reply in conversations',
|
||||
|
||||
labelCustomPrompt: 'Custom prompt',
|
||||
descCustomPrompt: 'Appended to the AI system prompt to customize AI behavior',
|
||||
placeholderCustomPrompt: 'e.g.: You specialize in Rust development questions...',
|
||||
|
||||
labelStreamingMd: 'Streaming Markdown rendering',
|
||||
descStreamingMd: 'Render code blocks/lists/headings during streaming (unclosed code blocks degrade to plain text to avoid flicker); when off, only plain text is shown during streaming and full formatting applies after generation completes',
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user