84 Commits
Author SHA1 Message Date
徐涛 eb7d23de3d docs: 添加模板编译器 UTF-8 编码修复方案与需求文档
- 新增 PDD 方案(编号 32),覆盖 6 处逐字节强转修复、测试设计与版本升级计划
- 新增 PRD 需求(编号 2),定位模板编译阶段中文乱码根因并明确验收标准
- 添加 .codegraph 目录忽略规则,避免本地数据文件入库
2026-08-03 11:21:50 +08:00
徐涛 7a215272a9 fix(prompt): 修复模板编译器 UTF-8 编码乱码
- 按字符边界推进模板解析索引
- 补充中文、emoji、块语法与 ASCII 回归测试
- 将版本升级至 0.3.7
2026-08-03 11:16:16 +08:00
徐涛 25238fc357 fix(llm): 为 Usage 字段添加反序列化容错,修复缺失子字段导致的响应解析失败
- Usage 结构体加 struct-level #[serde(default)]:子字段缺失默认 0/None

- OpenaiResponseBody.usage 与 OpenaiChatResponse.usage 加 field-level:整 key 缺失默认 Usage::default()

- 覆盖 Responses API 与 Chat Completions 两条 blocking 路径

- 补 9 个反序列化测试覆盖缺失/空/完整/roundtrip 边界

- 升级版本号至 0.3.6
2026-07-27 09:46:52 +08:00
徐涛 b7d0a7335f docs: 添加 Usage 字段反序列化容错方案文档
为 Usage 结构体及其内嵌字段添加 `#[serde(default)]` 反序列化容错,
使 blocking 调用在火山平台返回不完整 usage 时不再崩溃。

- 新增 PDD:分析问题根因、设计变更方案、验证标准与回滚方案
- 新增 PRD:梳理需求范围、边界假设与验收标准
2026-07-27 09:34:11 +08:00
徐涛 c5afa4b31e docs(agents): 同步文档规范与 design 目录说明至全局版本
- 替换方案规范为 design/pdd/ + design/prd/ 编号体系
- 移除 docs/roadmap.md 进度同步规则(全局未要求)
- 新增 design/ 子目录职责、读写权限与兜底规则
2026-07-23 05:54:58 +08:00
徐涛 28ca43ccb2 chore(docs): 将设计文档从 docs 移至 design 目录
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
2026-07-23 05:45:53 +08:00
徐涛 528a17f5fa docs(roadmap): 更新 v0.4.0 规划状态,归档已完成 Phase 2026-07-21 22:44:01 +08:00
徐涛 d48286a942 docs(v0.4.0): 新增 v0.4.0 版本路线图规划文档
涵盖多 Agent 编排、Human-in-the-loop、语义压缩与自动校正等 Phase A-E 共 5 个增量的实施计划、依赖关系与里程碑定义
2026-07-21 22:43:55 +08:00
徐涛 eeae943727 fix(core): 升级 Cargo.toml 版本号到 v0.3.5
CI / test (chat,provider-openai) (push) Has been cancelled
CI / test (chat,provider-openai,provider-openai-response) (push) Has been cancelled
CI / test (chat,provider-openai,tools-mcp) (push) Has been cancelled
CI / test (full) (push) Has been cancelled
CI / test (light) (push) Has been cancelled
CI / test (multi,provider-openai) (push) Has been cancelled
CI / test (multi,provider-openai,tools-mcp) (push) Has been cancelled
CI / clippy (push) Has been cancelled
CI / fmt (push) Has been cancelled
CI / examples (push) Has been cancelled
2026-07-20 16:47:38 +08:00
徐涛 40e4b3d8fe fix(llm): 修复 OpenaiResponseProvider builtin_tools 注入逻辑缺陷
修复当 tools_defs 为空时 builtin_tools 完全不生效的问题:
- 修复 convert_request 分支错位,解耦 tools_defs 与 builtin_tools 处理
- 扩展 ResponseTool 枚举,添加 Builtin(Value) 变体 + 自定义 Serialize/Deserialize
- 改进错误处理:match + warn! 替代 unwrap_or_else 兜底
- 添加 6 个测试用例覆盖纯 builtin / 混用 / 回归 / wire format / roundtrip 场景
- 补充方案文档 docs/30-builtin-tools-injection-fix.md
2026-07-20 16:45:47 +08:00
徐涛 8ea01d373e fix(core): 升级 Cargo.toml 版本号到 v0.3.4
CI / test (multi,provider-openai) (push) Has been cancelled
CI / test (chat,provider-openai) (push) Has been cancelled
CI / test (chat,provider-openai,provider-openai-response) (push) Has been cancelled
CI / test (chat,provider-openai,tools-mcp) (push) Has been cancelled
CI / test (full) (push) Has been cancelled
CI / test (light) (push) Has been cancelled
CI / test (multi,provider-openai,tools-mcp) (push) Has been cancelled
CI / clippy (push) Has been cancelled
CI / fmt (push) Has been cancelled
CI / examples (push) Has been cancelled
版本号 PATCH 升级(0.3.3 → 0.3.4),向后兼容,包含三 Provider 自定义 HTTP 请求头支持(feat 77321db + 安全修复 76bbeed + doc 清理 5e475e1)。
2026-07-20 15:11:18 +08:00
徐涛 5e475e1303 docs(llm): 清理 build_request_builder 重复 doc comment
- anthropic.rs:删除 build_request_builder doc comment 中重复的 4 行(Round 1 安全性修复时追加内容未清理原段落)
- openai_response.rs:在 build_request_builder doc comment 补充「构造 HTTP POST 请求 builder(含认证头与额外请求头)」描述句,与另两 provider 对齐
2026-07-20 15:09:21 +08:00
徐涛 76bbeed596 fix(llm): 自定义头注入安全性修复 + 测试覆盖补全
三 Provider 自定义 HTTP 头机制的审查后修复:

- 安全性:build_request_builder 改用 HeaderName::from_bytes / HeaderValue::from_str
  安全转换;非法 header 名/值(如控制字符)静默跳过 + warn,避免 reqwest panic。

- doc comment:with_extra_headers() 的"注入"措辞与"替换"语义不符
  (self.extra_headers = headers),改为"设置 Provider 级别固定头,替换已有的"。

- 测试覆盖:补全方案验证标准缺失的 *_extra_headers_from_constructor 单元测试
  (三 provider 各 2 个,共 6 新增),用 RequestBuilder::build() 直检 headers。
2026-07-20 14:58:32 +08:00
徐涛 77321db8f6 feat(llm): 三 provider 统一支持自定义 HTTP 请求头
在 GenericOpenaiProvider / OpenaiResponseProvider / AnthropicProvider 中新增双层自定义 HTTP 头机制:

- Provider 级固定头:extra_headers 字段,构造时通过 with_extra_headers() 链式注入
- 请求级临时头:extra.custom_headers,通过 set_extra 透传,#[serde(skip)] 隔离 JSON body

AnthropicProvider 前置提取 build_request_builder 统一方法,使三 provider 的请求构造模式对齐。

头融合顺序(一致):认证头 → Provider 级头 → 请求级头,后注入覆盖前注入。

新增 14 个测试覆盖提取 / 序列化隔离 / 类型降级 / 透传 / 覆盖优先级 / 认证头可覆盖等维度。
2026-07-20 14:47:38 +08:00
徐涛 939dcf0f9a fix(core): 对齐 Cargo.toml 版本号到 v0.3.3
CI / test (chat,provider-openai) (push) Has been cancelled
CI / test (chat,provider-openai,provider-openai-response) (push) Has been cancelled
CI / test (chat,provider-openai,tools-mcp) (push) Has been cancelled
CI / test (full) (push) Has been cancelled
CI / test (light) (push) Has been cancelled
CI / test (multi,provider-openai,tools-mcp) (push) Has been cancelled
CI / clippy (push) Has been cancelled
CI / fmt (push) Has been cancelled
CI / examples (push) Has been cancelled
CI / test (multi,provider-openai) (push) Has been cancelled
v0.3.3 tag 已发布但 Cargo.toml version 仍停留在 0.3.2,
导致 cargo publish 与用户引用的版本不一致。补齐后重新
打 tag v0.3.3 到本次提交。

- Cargo.toml version: 0.3.2 → 0.3.3
- git tag v0.3.3 重新指向新 commit
2026-07-20 10:07:14 +08:00
徐涛 b895616dd0 feat(llm): 实现 OpenAI Response API Provider
CI / test (chat,provider-openai) (push) Has been cancelled
CI / test (chat,provider-openai,provider-openai-response) (push) Has been cancelled
CI / test (chat,provider-openai,tools-mcp) (push) Has been cancelled
CI / test (full) (push) Has been cancelled
CI / test (light) (push) Has been cancelled
CI / test (multi,provider-openai,tools-mcp) (push) Has been cancelled
CI / clippy (push) Has been cancelled
CI / fmt (push) Has been cancelled
CI / examples (push) Has been cancelled
CI / test (multi,provider-openai) (push) Has been cancelled
- 新增独立 OpenaiResponseProvider(POST /responses 协议),独立 feature provider-openai-response
- 覆盖文本对话/流式/Vision/Function Calling/多轮接续/结构化输出/内置工具逃生舱
- 内置工具(web_search/file_search)通过 extra 逃生舱透传
- 工厂注册 ProviderType::OpenaiResponse + src/llm 模块门控追加
- 新增 example response_api_demo + CI 矩阵新增组合 + README/roadmap 同步
- 测试覆盖:13 单元 + 15 wiremock(流式 + 非流式 + 错误路径)
- 文档:docs/28-phase28-openai-response-api-provider.md
2026-07-20 09:05:04 +08:00
徐涛 f6cf583cd7 chore(core): 发布 v0.3.2 版本
CI / test (chat,provider-openai) (push) Has been cancelled
CI / test (chat,provider-openai,tools-mcp) (push) Has been cancelled
CI / test (full) (push) Has been cancelled
CI / test (light) (push) Has been cancelled
CI / test (multi,provider-openai) (push) Has been cancelled
CI / test (multi,provider-openai,tools-mcp) (push) Has been cancelled
CI / clippy (push) Has been cancelled
CI / fmt (push) Has been cancelled
CI / examples (push) Has been cancelled
- Cargo.toml version 从 0.3.0 升至 0.3.2,与 v0.3.2 tag 对齐
2026-07-19 13:07:31 +08:00
徐涛 0cfd401579 docs(roadmap): 同步 v0.3.2 roadmap 与实际实施状态
- Step 3/4 标记 ,补充验证结果(427/416/363/369/401/407 passed)
- 功能清单 imply 列与 Cargo.toml 对齐(llm +futures-util,tools +tokio,memory +llm/tokio/time)
- Phase 24/25 状态从"部分交付"改为"已交付"(Phase 26 已验证)
- Cargo.toml [features] 草案与实际实现一致
- CI 测试矩阵草案替换为已实施的 ci.yml
- 依赖 optional 化对照表修正(tokio 启用者 +tools/memory,futures-util +llm,time +memory)
2026-07-19 09:20:29 +08:00
徐涛 5baa170508 docs: 更新 README feature 表 + 升级指南 + 示例注释 + roadmap 同步
- README 添加 feature 组合表 + 模块级 features 清单 + 升级指南
- 18 个 example 顶部添加 Required features 注释
- roadmap.md 和 roadmap-v0.3.2.md 同步 Phase 26-27 完成状态
- cargo fmt 全量格式化(修复预存格式问题,CI format job 可通过)
2026-07-19 08:18:04 +08:00
徐涛 bc4eac72e1 chore(ci): 创建 GitHub Actions CI 测试矩阵
- 6 个 feature 组合并行测试(full/light/chat/chat+mcp/multi/multi+mcp)
- clippy --all-features 零警告检查
- cargo fmt --check 格式检查(stable toolchain)
- examples 编译验证(cargo test --features full)
- RUSTFLAGS=-D warnings 强制零警告
- 每个 job 设置 timeout-minutes 兜底
2026-07-19 08:10:53 +08:00
徐涛 61e6d219dd chore(examples): 为 18 个 example 添加 required-features 声明
- 18 个 example 各自声明最小 feature 集合
- llm feature 补充 imply futures-util(修复 cycle.rs 隐式依赖)
- prompt_composer/custom_tool 需额外 llm(response_v2.rs 依赖 LlmError)
2026-07-19 08:09:57 +08:00
徐涛 932a06f512 refactor(core): 完成 v0.3.2 Cargo features 拆分基础设施
- 定义 16 个 features + 4 个快捷组合,default = ["full"] 保持向后兼容
- 12 个重型依赖 optional 化(tokio/reqwest/rusqlite 等)
- 全模块 #[cfg(feature)] 门控注入(llm/tools/memory/agent/engine)
- 将 LlmProvider trait 及关联类型移出 provider 模块归属 llm(ADR-1)
- 为 session.rs bundle() 方法添加 engine feature 门控
- 更新 12 个内部文件 + 4 个示例文件的 import 路径
- 向后兼容:provider.rs 保留 pub use 重导出老路径
2026-07-19 07:58:04 +08:00
徐涛 249fba8aaf docs(roadmap): 增补 v0.3.2 实施节奏 4-Step 计划及执行说明 2026-07-18 09:25:08 +08:00
徐涛 703151e363 docs(roadmap): 增补 v0.3.2 Cargo features 拆分路线图
- 16 个 feature(10 模块级 + 5 provider + 1 工具)+ 4 个快捷组合
- agent 不再 imply tools-mcp,MCP 作为可选依赖由用户显式启用
- tokio features 拆细为 rt/sync/time/macros/process/io-util
- 8 个 Phase 实施计划(Phase 20-27)+ 7 种 CI 矩阵组合
- default = ["full"] 保持向后兼容,非破坏性变更
2026-07-18 08:16:38 +08:00
徐涛 385560a1dd docs: 增补 README 介绍内容并对齐至 v0.3 实际实现
- 版本号 0.2 → 0.3
- 示例数量 10 → 18,追加 8 个示例条目
- 模块表新增 engine / document,扩展 llm / memory / agent 描述
- 架构图新增 Engine 层、Document 模块、Memory 子模块展开
- 依赖关系图新增 Engine / Document 节点,修正依赖规则
- 环境变量 PROVIDER 移除未实现的 openai-response,新增 ollama
- 修正示例行数描述与参考项目段措辞
2026-07-17 16:53:43 +08:00
徐涛 28d6a1c166 chore(release): 发布 v0.3.0
- 更新 crate 版本为 0.3.0
2026-07-17 16:11:26 +08:00
徐涛 6676322666 fix(core): 修复 v0.3.0 审查发现的 6 项警告问题
- InMemoryGraph 的 10 处 lock().unwrap() 改为错误传播,避免 Mutex poison 时 panic
- SubTaskResult 添加 Serialize/Deserialize,支持结果序列化
- dispatch_all 移除多余的 task.clone()
- truncate_total_chars 补充字节切片安全性注释
- SessionManager::children 添加 ponytail 注释标记 O(N) 扫描现状
- SessionMemoryEntry.created_at 添加 i64 类型说明注释
2026-07-17 15:59:02 +08:00
徐涛 9d73f525d0 fix(memory): search_graph 补充 min_score 过滤
GraphOnly 模式下低分实体不再被返回,与 search_knowledge_store 行为一致
2026-07-17 14:58:44 +08:00
徐涛 7e72e102a2 feat(memory): 实现知识图谱与双通道检索
- 新增 KnowledgeGraph trait + InMemoryGraph(BFS 图遍历 + 标签管理)
- 扩展 MemoryRetriever 为双通道检索(Hybrid/KnowledgeOnly/GraphOnly)
- 统一 RetrievalResult 为 RetrievalItem enum 变体
- GraphRelation 使用复合键 + composite_key() 派生 id
- 旧 ScoredItem 标注 #[deprecated]
- 新增 knowledge_graph_demo 示例
- 全量测试 427 passed,clippy/doc 0 警告
2026-07-17 14:37:49 +08:00
徐涛 5bb349d177 docs(roadmap): 将 roadmap.md 拆分为按版本归档的多文件
- 替换 docs/roadmap.md 为简短索引入口,列出 4 个子文件导航
- 新增 docs/roadmap-v0.1.0.md(Phase 0–4c + v0.1 Release)
- 新增 docs/roadmap-v0.2.0.md(Phase 5–12 + M1-M8 + v0.2.0-rc.1)
- 新增 docs/roadmap-v0.3.0.md(Phase 13–19 + M9-M15)
- 新增 docs/roadmap-unsorted.md(全局愿景/模块完整性/v0.4+ 展望/风险/下一步/已完成列表)
- 修复 docs/24-phase18-agent-switch-and-dispatch.md 中 2 处具体行号引用改为指向新文件

拆分原则:每个版本文件含目标/Phase/里程碑/依赖图四要素;
跨版本视图、阶段总回顾、未来展望集中于 unsorted
2026-07-17 11:20:12 +08:00
徐涛 46de111965 feat(engine): 实现 Agent 角色热切换与子代理调度
新增 SessionManager::switch_agent / dispatch / dispatch_all / dispatch_stream
四个核心方法,补齐多 Agent 基础系统原语。

交付物:
- switch_agent 运行时替换 Arc<dyn Agent>,保留上下文并更新 SessionMeta
- dispatch 单任务派发:create_child → inherit_memory → submit_turn
- dispatch_all 并行派发:Semaphore 并发控制 + 部分成功语义
- dispatch_stream 流式派发:unbounded_channel + spawn task 消息重建 + finalize_turn
- DispatchConfig / SubTaskResult / SubTaskStreamEvent 公开类型
- 4 个端到端示例(agent_switch_demo / sub_agent_dispatch_demo /
  bridge_keys_demo / dispatch_stream_demo)

辅助变更:
- EngineError 新增 DispatchFailed 变体
- CostTracker 实现 From<Usage> 转换
- save_session_meta / load_session_meta 改 pub(crate) 供 switch.rs 使用

测试: +17 个内联测试(4 switch + 5 dispatch + 4 dispatch_all +
4 dispatch_stream),全量 391 passed / 0 failed,
clippy 0 警告,doc 0 warning。
2026-07-15 11:15:58 +08:00
徐涛 cb922b03de docs(roadmap): 标记 Phase 17 完成 + M13 里程碑达成 + 更新依赖图
Phase 17 全部交付物已完成(2026-07-15):
- 状态从  改为 
- 增补实际增量(5 新增文件 + 5 修改 + 1 示例 = 1848 行净增)
- 列出 21 个新内联测试(Checkpointer 6 + SessionManager 15)
- 列举关键设计决策落地(SessionSnapshot 独立 / to_snapshot+from_snapshot+restore_memory
  三段式 / EngineError::Memory 透传 / RwLock 锁契约 / ckpt_id 纳秒+计数器 / 孤儿策略等)
- 记录实施后两轮审查门禁 PASS(第一轮 PM+SA 修复 6 🔴,第二轮 SA 修复 2 🔴,
  实施后 PM+SA+Code Reviewer 联合修复 8 个 🟡)

M13 里程碑达成  2026-07-15(依赖图节点从 pending 改为 done)

Phase 17 rc.1 完成 → 下一步 Phase 18(Agent Switch + SubAgent Dispatch)
- 「下一步行动」重定向到 Phase 18
- 「里程碑追踪」更新为 M12+M13 已达成,剩余 M14-M15
- 已完成阶段列表追加 Phase 17 完成项(详细列出 11 个 SessionManager API +
  5 个 Checkpointer API + 关键实现细节 + 审查修复记录)
2026-07-15 09:00:02 +08:00
徐涛 34eec9f546 feat(engine): 实现 Agent 执行引擎(SessionManager + Checkpointer + SessionSnapshot)
Phase 17 主体交付:解决 v0.2 中 session 在变量里、无父子关系、无 checkpoint、
不可序列化的空白。

新增模块 src/engine/(5 文件,约 1300 行纯实现 + 21 个内联测试):

- error.rs(46 行)—— EngineError 枚举(6 变体)
  - SessionNotFound / SessionAlreadyExists / CheckpointNotFound
  - Memory(#[from] MemoryError) 透传(与 AgentError 风格一致)
  - Serialization / Agent(#[from] AgentError)

- snapshot.rs(36 行)—— SessionSnapshot + SessionMemoryEntry
  - 独立 struct 避开 Arc<dyn Agent> 不可序列化限制
  - SessionMemoryEntry 保留 value/metadata/created_at 完整信息
  - 所有字段 #[serde(default)] 宽松反序列化保证前向兼容

- checkpointer.rs(377 行)—— Time-travel 检查点管理器
  - checkpoint() / rollback_load() / list_checkpoints() / delete_all() / latest_snapshot()
  - 存储 key:ckpt:{session_id}:{ckpt_id}
  - ckpt_id 纳秒+计数器(无外部依赖,ponytail)
  - CkptMeta.created_at_nanos 字段确保同秒内精确降序
  - 6 个内联测试覆盖 roundtrip / 不存在 ckpt / 降序排序 / delete_all 幂等 /
    latest_snapshot / 跨 session 隔离

- session_manager.rs(906 行)—— SessionManager 会话树管理
  - 内部 RwLock<HashMap> + Arc<tokio::sync::Mutex<AgentSession>> 双重锁
  - create / create_child / get / recover / replace / children / parent /
    destroy / submit_turn / submit_turn_stream / finalize_turn_stream 共 11 个公开方法
  - SessionManagerConfig.auto_checkpoint 默认 true(同步写入 +
    tracing::error! 失败不阻断主流程,不提供强持久化保证)
  - 孤儿策略:destroy 不递归删除子 session;父被销毁后 parent() 返回 None
  - create_child 限制:父 session 必须先 get/recover 到内存(bundle 不可序列化)
  - 15 个内联测试覆盖 CRUD / recover / replace / 树形 / 孤儿 / auto_checkpoint 开关 /
    序列化兼容性 / 幂等 / 10 并发创建

- mod.rs(20 行)—— 统一 pub use 重导出 EngineError / SessionManager /
  SessionManagerConfig / Checkpointer / CkptMeta / SessionSnapshot /
  SessionMemoryEntry

AgentSession 扩展(src/agent/session.rs,+152 行):
- to_snapshot() pub async —— 从 MemoryStore 拍平 session_memory 全量数据
- from_snapshot() pub fn Result —— 纯同步构造器(pending_memory_restore 暂存)
- restore_memory() pub async &mut self —— 写回持久层并清空 pending
- has_pending_memory_restore() —— 查询 pending 状态
- pub(crate) fn bundle() —— accessor 供 SessionManager::create_child 继承

SessionMemory 扩展(src/agent/session_memory.rs,+57 行):
- list_entries() —— 返回 Vec<(key, value, metadata, created_at_unix_secs)>
- set_with_meta() —— 保留 metadata/created_at 写入(供 restore_memory 完整恢复)

src/lib.rs —— pub mod engine 声明

examples/engine_demo.rs(+251 行):
端到端演示 create → submit_turn → checkpoint → list_checkpoints →
rollback_load → from_snapshot → restore_memory → replace → destroy 完整链路,
含 rollback 一致性 assert(turn_index 和 cost 恢复到 checkpoint 时刻)。

零新外部依赖(serde_json 已有)。全量 353 → 374(+21 新测试)。
clippy 0 警告,doc 0 warning,example exit 0。

Phase 17: Agent 执行引擎 — Step 2-7(合并提交)
2026-07-15 08:59:46 +08:00
徐涛 1d51dcdfe0 feat(core): 为 CostTracker/ContextSlot/MergeStrategy 加 Clone+Serialize+Deserialize
Phase 17 前置 derive 改动:
- CostTracker 加 Clone + Serialize + Deserialize(3 行)—— 用于 SessionSnapshot
  序列化 cost_so_far 字段
- ContextSlot 加 Serialize + Deserialize(1 行)—— 用于 SessionSnapshot
  序列化 slots HashMap
- MergeStrategy 加 Serialize + Deserialize(1 行)—— 与 ContextSlot 一致

零破坏性:派生仅为新增能力,不修改现有行为。ContextSlot 的 save()
路径独立序列化 messages/meta/config,不走整体 serde,两组路径可共存。

Phase 17: Agent 执行引擎 — Step 1
2026-07-15 08:58:43 +08:00
徐涛 fbbf8bf6e5 docs(roadmap): 同步 Phase 17 方案审查后的交付物与里程碑定义 2026-07-15 06:52:05 +08:00
徐涛 cc1c68b69d feat(agent): 实现摘要自动生成(SummaryConfig + 内联检查点)
SummaryConfig 6 字段配置(trigger_token_ratio / max_context_tokens /
summary_prompt / debounce_turns / summary_model / max_tool_result_chars),
AgentBuilder 链式 summary_config();AgentSession 在 submit_turn /
finalize_turn 的 OnTurnEnd 之后内联检查点:水位 + 防抖(首次不受
约束)→ 独立 LlmCycle 调 submit_messages 生成摘要 → 写入
FocusedConfig.summary_override + slot.save() + SessionMemory 全局快照。

should_summarize 接收 current_turn 参数避免流式路径 turn_index 偏差;
format_messages_as_text 简洁版格式化含 30K 整体截断保留最新;空消息
守卫直接返回空串。所有错误静默 tracing::error!,成功路径
tracing::info!。

src/agent/summary.rs 新增 ~240 行;agent/session.rs +428 行(双路径
检查点 + 关联函数 + 测试 + 公开 API)。零新外部依赖。全量 335 → 353
测试(+18 新测试),clippy 0 警告,doc 0 warning。两轮审查 PASS——
第一轮 PM/SA 修复 11 项,第二轮 Code Reviewer 修复 9 项(含 🔴
generate_summary 空消息 bug + 🟡 5 项 + 💭 2 项)。方案文档 docs/
22-phase16-summary-auto-generation.md(471 行);roadmap.md 标记
Phase 16 完成 + M12 里程碑达成。
2026-07-10 06:43:06 +08:00
徐涛 209932e3b5 feat(docs): 标记 Phase 15 完成并更新 roadmap 状态 2026-07-09 22:46:29 +08:00
徐涛 32d886f870 feat(memory): 新增 VectorStore 抽象与 RagPipeline 持久化管线
- 新增 src/memory/vector_store.rs(约 660 行):

  - VectorStore trait:批量 add / search / remove + add_one 默认实现

  - InMemoryVectorStore:Mutex<HashMap> + 余弦全量扫描(锁内克隆、锁外计算)

  - PersistentVectorStore:MemoryStore 包装,JSON blob 持久化,先持久化后内存

  - RagPipeline:split → embed → store 组合器(具体 struct,非 trait)

- 22 个内联测试覆盖 InMemory(10)/ Persistent(6)/ RagPipeline(4)/ 性能基准(2)

- 性能断言:search 10K 条 <100ms,PersistentVectorStore::new 加载 <500ms

- 标记 VectorRetriever / InMemoryVectorRetriever 为 #[deprecated(since="0.3.0")]

- memory.rs 追加 VectorStore 等 4 个类型的 re-export

- document_demo 从手动 VectorRetriever 循环迁移到 RagPipeline 两行调用

- 零新增外部依赖
2026-07-09 16:51:29 +08:00
徐涛 b04427e83f docs(roadmap): 标记 Phase 14 完成 + M10 里程碑达成
Phase 14 全部交付物已完成(commit d4c4d8f):
- Document 类型 + RecursiveCharacterSplitter 分割器
- Embedding trait + MockEmbedding
- 19 Document 测试 + 6 Embedding 测试
- 全量测试 286 → 313,clippy 0 警告,零新外部依赖

Roadmap 同步 6 处:
- 顶部最后更新日期 + 当前状态(Phase 14 完成,Phase 15-19 待实施)
- §Phase 14 章节新增实际新增段落(commit hash + 文件清单 + 设计决策 + 测试覆盖)
- v0.3.0 依赖图 P14 节点 pending → done
- M10 里程碑  2026-07-09
- 下一步行动从 Phase 14 启动改为 Phase 15 启动
- 已完成/进行中阶段列表追加 Phase 14 完成条目
2026-07-09 13:27:36 +08:00
徐涛 d4c4d8fa3c feat(document): 实现 Document 类型与 RecursiveCharacterSplitter 分割器
新增 Phase 14 核心模块,为 RAG 管线提供 split → embed 阶段的底层支撑。

新增内容:
- Document 类型(id/content/metadata/mime_type 四字段 + new/from_raw 构造器)
- RecursiveCharacterSplitter(两阶段算法:按 separator 优先级递归分割 + 贪心合并 overlap 滑动窗口)
- Embedding trait(异步向量化抽象,复用 LlmError)+ MockEmbedding(sin-hash 零依赖伪随机实现)
- 19 个 Document 单元测试 + 6 个 Embedding 单元测试
- document_demo 示例(Document → Splitter → MockEmbedding → InMemoryVectorRetriever 端到端演示)

模块注册:
- src/lib.rs: pub mod document + pub use Document
- src/llm.rs: pub mod embedding

设计文档:docs/20-phase14-document-and-embedding.md(1417 行,含背景/调研/方案对比/实施计划)

零新外部依赖,所有长度比较以 Unicode 字符为单位(chars_len),CJK 文本行为正确。
2026-07-09 13:08:35 +08:00
徐涛 4686063ca8 docs: 标记 Phase 13 完成 + 追加 v0.3.0 CHANGELOG 条目
- CHANGELOG.md: 追加 v0.3.0 (unreleased) 条目
  - Breaking Changes: 类型路径变更(request.rs/response.rs → provider/openai.rs,
    公共 ToolChoice re-export 路径不变)+ ChatResponse / LegacyStreamEvent 删除
  - Added: ContextSlot::fork / merge + MergeStrategy 枚举(#[non_exhaustive])
  - Changed: 3 个旧 types 文件删除 + 所有 wire-format 类型迁入 openai.rs
  - Fixed: Phase 9 实施审查修复(PreRequest hook + 死代码清理 + 2 个集成测试)
  - Migration Guide: v0.2.0-rc.1 → v0.3.0 路径迁移示例
  - 修复 M9 里程碑验收 #11(CHANGELOG 条目)和审查结论 CONDITIONAL PASS 条件清单 #1
- docs/roadmap.md:
  - 顶部最后更新日期 → 2026-07-08(Phase 13 完成 + M9 里程碑达成)
  - 末尾"v0.3.0 规划完成"状态行从"Phase 13-19 待逐步实施"更新为"Phase 13 完成,
    Phase 14-19 待实施(Document → 向量存储 → 摘要 → 引擎 → 调度 → 知识图谱)"
2026-07-09 06:19:43 +08:00
徐涛 c36668071e fix(agent): Phase 9 实施审查修复
- cycle.rs: run_tool_loop 实现 PreRequest hook(之前 `let _ = hook_executor.as_ref()` 是空操作,
  导致 hook-based logging/monitoring 在流式工具循环中失效;现在与 submit_with_tools 行为对齐,
  含 should_block 检查,阻断时通过 StreamEvent::Error 事件化)
- session.rs: 删除 submit_turn_stream 末尾的 `let _ = hook_executor;` 死代码(Arc 引用生命周期
  由 Arc 自动管理)
- session.rs: 新增 2 个集成测试覆盖方案 §4 Step 5:
  - submit_turn_stream_end_to_end:mock provider → 消费流 → finalize_turn 后 cost_so_far
    正确更新(10/5 tokens)+ turn_index=1 + default slot 包含 user/assistant 消息
  - submit_turn_stream_triggers_turn_hooks:OnTurnStart 在 submit_turn_stream 返回流前
    触发(计数=1)+ OnTurnEnd 在 finalize_turn 前不触发(计数=0)+ finalize_turn 后触发(计数=1)
- docs/16-phase9-streaming-experience.md: 标注 finalize_turn Phase 10 签名变更(new_messages_from_cycle
  + Result 返回),Step 5 测试实现位置
- 测试 288 passed / 0 failed(基线 286 + 2 新增),clippy 0 警告,doc 0 warning
2026-07-08 23:19:24 +08:00
徐涛 d4f27b5865 refactor(types): 删除旧类型文件和 ChatResponse
- 删除 src/llm/types/old_stream.rs(45 行,LegacyStreamEvent 内部死代码)
- types/mod.rs 删除 pub mod old_stream; 与 ChatResponse 结构体定义
- 前置验证 A 通过:parse_chunk_stream / map_legacy_to_ir / LegacyToIrEventStream /
  ChunkToLegacyEventStream / LegacyStreamEvent 零外部调用方
- 前置验证 B 通过:cargo doc --no-deps 中无 ChatResponse 引用
- cycle.rs:88 的 #[allow(deprecated)] 保留(LlmCycle impl 中无 ChatResponse 引用,
  与 ChatResponse 无关;按方案文档"若不相关则无需改动"原则保留原状)
- stream.rs 已在 Step 13.2 commit 中同步简化为 module doc + pub use
- 同步更新 docs/roadmap.md:Phase 13 状态 ,M9 里程碑 + 完成日期,
  Mermaid 依赖图 P13 pending→done,"下一步"指向 Phase 14
- Breaking Change:agcore::llm::types::ChatResponse 已删除
  (v0.1.0 起标记 #[deprecated],请改用 MessageResponse)
2026-07-08 22:59:31 +08:00
徐涛 1c0e1e0ed1 refactor(types): response.rs 类型移入 provider/openai.rs
- 删除 types/response.rs(177 行)
- 所有 OpenAI wire-format 响应类型迁入 provider/openai.rs,可见性 pub(crate):
  TokenLogprob / TopLogprob / Logprobs / URLCitation / Annotation / OpenaiAudio /
  Choice / OpenaiChatResponse / Delta / ChunkChoice / OpenaiChatChunk
- From<OpenaiChatMessage> for Delta 与 From<OpenaiChatResponse> for OpenaiChatChunk
  同步迁入 openai.rs
- types/mod.rs 删除 pub mod response; 与对应 re-export
- convert_response 同步降级为 pub(crate) 以匹配 OpenaiChatResponse 可见性
- stream.rs: OpenaiChatChunk import 路径改为 crate::llm::provider::openai
- stream.rs 同步简化为 module doc + pub use 重导出(合并 Step 13.3 的清理动作,
  避免遗留 dead_code 警告来回)
- mod.rs: ChatResponse 的两个 From impl 同步删除(impl 内引用的 OpenaiChatResponse
  / OpenaiChatChunk / Delta / ChunkChoice 已不在 types 模块),结构体保留到 Step 13.3
- 公共 re-export 路径 agcore::llm::types::OpenaiChatResponse/Chunk 等已删除
  (Breaking Change,见 CHANGELOG)
2026-07-08 22:58:18 +08:00
徐涛 760de46623 refactor(types): request.rs 类型移入 provider/openai.rs
- 删除 types/request.rs(187 行)
- 所有 OpenAI wire-format 类型迁入 provider/openai.rs,可见性 pub(crate):
  StreamOptions / OpenaiTool / AudioParam / PredictionContent / UserLocation /
  Approximate / WebSearchOptions / OpenaiChatRequest
- types/mod.rs 删除 pub mod request; 与对应 re-export
- convert_request 同步降级为 pub(crate) 以匹配 OpenaiChatRequest 可见性
- 公共 re-export 路径 agcore::llm::types::OpenaiChatRequest 等已删除(Breaking Change,见 CHANGELOG)
2026-07-08 22:55:17 +08:00
徐涛 f8df6a9421 refactor(types): ToolChoice 移入 tool.rs
- ToolChoice 枚举与 serde impl 从 types/request.rs 迁入 types/tool.rs
- mod.rs re-export 从 request::ToolChoice 改为 tool::ToolChoice(公共 agcore::llm::types::ToolChoice 路径保持不变)
- request_v2.rs import 路径更新为 crate::llm::types::tool::ToolChoice
- OpenaiChatRequest 字段类型引用更新为 super::tool::ToolChoice(Step 13.1 删除 request.rs 后此临时 import 同步消除)
2026-07-08 22:53:46 +08:00
徐涛 802518b5fe feat(agent): 实现 ContextSlot fork/merge
- 新增 MergeStrategy 枚举(#[non_exhaustive],为 Phase 16 Summarize 预留)
- 新增 ContextSlot::fork() 从父 slot 派生独立子 slot(Full / Focused 策略)
- 新增 ContextSlot::merge() 将子 slot 消息合入父 slot(Append / Replace 策略)
- 防御性检查:禁止 self-merge、跨 session merge、合并到 Readonly slot
- 重构 AgentSession::derive_slot 复用 fork() 消除重复代码
- 新增 9 个内联测试覆盖 fork/merge happy path 与 error path
- agent.rs 追加 MergeStrategy re-export(agcore::agent::MergeStrategy 路径可用)
2026-07-08 22:52:38 +08:00
徐涛 993118f661 docs(roadmap): 将 v0.3+ 展望更新为 v0.3.0 详细规划
新增 Phase 13-19 共 7 个增量交付阶段,覆盖技术债清理、Document 系统、向量
存储持久化、摘要自动生成、Agent 执行引擎、Agent Switch 与 SubAgent 调度、
知识图谱及双通道检索。同步更新里程碑、依赖关系图及风险说明
2026-07-06 23:10:18 +08:00
徐涛 4348e4bf3e docs: 添加 LangChain & LangGraph 功能调研笔记 2026-07-06 21:55:03 +08:00
徐涛 0dc91faa43 docs(roadmap): 标记 Phase 11 测试与检索补强已完成 2026-07-06 15:30:52 +08:00
徐涛 b4e5c7d651 docs(phase11): 记录 Phase 11 方案文档与实施偏差 2026-07-06 14:53:00 +08:00
徐涛 71abe881ed feat(core): 完成 Phase 11 测试与检索补强
- 新增 VectorRetriever trait 与 InMemoryVectorRetriever 引用实现
- 补充 Provider roundtrip wiremock 测试与 MemoryStore 并发测试共 23 个
- 修复 openai 429 retry-after header 解析(与 anthropic 对齐)
2026-07-06 14:52:49 +08:00
徐涛 2af92cd554 docs(roadmap): 标记 Phase 10 ContextSlot 上下文管理已完成
- 顶部状态行更新:Phase 0-10 全部完成,11 个离线示例,254 测试
- Phase 10 状态:Step 10.1/10.2/10.3 全部标记 
- 详细实际新增:agent/context.rs (~430 行) + agent/error.rs 3 个变体 +
  agent/session.rs 改造(slots 字段 + 5 个管理方法 + submit_turn/finalize_turn
  增量追加写回) + context_slot_demo 示例 + 43 个新测试
- finalize_turn 签名变更记录(new_messages_from_cycle 参数 + Result 返回)
- 依赖关系图 P10 节点标 
- 里程碑 M6 →  2026-07-07
- 下一步行动:Phase 10 → Phase 11(测试与检索补强)
- 已完成阶段列表追加 Phase 10 完整说明
- 最后更新日期:2026-07-06 → 2026-07-07
2026-07-06 09:53:44 +08:00
徐涛 635942248b feat(core): 新增 Phase 10 ContextSlot 多上下文分区管理
- 新增 ContextSlot 类型(Full / Focused / Readonly 三种模式,
  New / Derived / Static 三种来源),支持 JSON blob 批次持久化
- AgentSession 新增 slots 字段与 5 个管理方法
  (create_slot / switch_slot / list_slots / derive_slot / delete_slot),
  自动创建 "default" slot
- submit_turn / finalize_turn 改造为基于当前 slot 的增量追加写回,
  确保 Focused 模式"读时过滤"语义不丢失数据
- finalize_turn 签名变更(新增 new_messages_from_cycle 参数,
  返回 Result<(), AgentError>),向后兼容列于 docs/17
- 新增 3 个 AgentError 变体(SlotReadonly / SlotNotFound / SlotAlreadyExists)
- 新增分支对话示例 context_slot_demo(法律咨询→两个派生方向→切换→隔离验证)
- 新增 43 个测试覆盖持久化、Focused 过滤、Readonly 阻断、delete 保护、
  派生逻辑、流式 finalize_turn、key 注入防护等场景
- 方案文档:docs/17-phase10-contextslot.md(含 §5 推荐方案、§6 实施建议、
  §9 实施计划,经过 4 轮方案/计划/实施审查 + 1 轮非阻塞建议修复)
2026-07-06 09:46:04 +08:00
徐涛 fe51961202 docs(roadmap): 标记 Phase 9 流式体验增强已完成
- 顶部「当前状态」「最后更新」同步到 Phase 0-9 完成
- Phase 9 Step 9.1 标记完成并新增「实际新增」段落
- Mermaid 依赖图 P9 节点 class 从 p1 切换为 done
- 里程碑 M5 标记  2026-07-06
- 下一步行动与已完成列表同步更新(指向 Phase 10)
2026-07-06 05:43:47 +08:00
徐涛 212cfcc916 feat(core): 新增 Phase 9 流式体验增强 submit_turn_stream
- 新增 StreamEvent::ToolExecutionStarted/Completed 变体(apply_to 元事件)
- 新增 LlmCycle::submit_with_tools_stream + run_tool_loop(spawn + mpsc 状态机)
- 新增 AgentSession::submit_turn_stream + finalize_turn(手动同步状态)
- CycleConfig 加 Clone derive

- 新增 9 个单元测试 + 2 个集成测试(211 passed)
- clippy 0 警告,存量 0 回归

docs: 追加 Phase 9 实施方案(docs/16-phase9-streaming-experience.md)
2026-07-05 23:37:37 +08:00
徐涛 88d00ac927 docs(roadmap): 补充 Phase 8 修复 commit 85b92ae 与最终行数
Phase 8 章节实际新增小节从 6 commits 改为 7 commits(含 docs(roadmap)
自身 + 实施后修复 commit 85b92ae),并同步行数:

- quick_start 57 → 60 行(追加 trailing newline + 错误处理改进)
- end_to_end 237 → 246 行(除零修复 + drop 注释 + EchoTool 一致化)
- 新增 docs(roadmap) commit 项(标记 Phase 8 + M4 里程碑)
- 新增 fix(examples) commit 项(6 项审查问题:1 🔴 + 2 🟡 + 3 💭)

'已完成 / 进行中阶段' 列表同步补充 Phase 8 行末的三方审查修复标注。
2026-07-05 21:10:24 +08:00
徐涛 358e971094 feat(core): 新增 Phase 8 MVP 集成出口方案文档 2026-07-05 21:04:31 +08:00
徐涛 85b92ae9d4 fix(examples): 修复 Phase 8 端到端示例审查发现的 6 项问题
按 PM/SA/Code Reviewer 三方审计报告修复:

🔴 阻塞修复:
- CalcTool 除零 panic: `a / b` 改 `a.checked_div(b).ok_or_else(...)`,
  b=0 时返回 ToolError::InvalidArguments 而非 panic

🟡 警告修复:
- end_to_end.rs 持久化验证注释与实际不符: 显式 drop(backend) 让注释
  描述与 Arc 释放顺序一致
- quick_start EchoTool 参数验证: 用 args.get("text").and_then().ok_or_else()
  替换 as_str().unwrap_or("") 静默降级, 缺失/类型错误时返回显式错误

💭 一致性修复:
- end_to_end.rs EchoTool 与 quick_start 一致化 (format!("收到: {text}"))
- quick_start mock 响应文本 "已通过 echo 回传" → "EchoTool 已收到并完成回传"
- quick_start 断言改为检查 "收到", 与方案字面要求一致
- quick_start 末尾追加 POSIX trailing newline

验收: cargo test 200/0 + clippy 0 警告 + doc 0 warning + 10 示例 exit 0
2026-07-05 21:00:36 +08:00
徐涛 57b2fbaaed docs(roadmap): 标记 Phase 8 MVP 集成出口已完成,v0.2.0-rc.1 标签
Phase 8 全部 Step 完成(8.1 API 稳定性扫尾 + 8.2 quick_start + 8.3
end_to_end),更新 roadmap 同步:

- 顶部「当前状态」补充 v0.2.0-rc.1 标签信息
- Phase 8 章节 3 个 Step 全标 ;新增「实际新增」6 commits 小节
- 依赖关系图 P8 节点 class 从 mvp 改为 done
- M4 里程碑标  2026-07-05
- 「下一步行动」从 Phase 8 改为 Phase 9 + v0.2.0 正式版规划
- 「已完成 / 进行中阶段」列表追加  Phase 8
2026-07-05 20:06:43 +08:00
徐涛 2c8e31919d feat(examples): 新增 end_to_end 端到端集成示例
展示真实场景集成: 3 工具 (EchoTool + CalcTool + NoteTool) +
3 轮对话 (计算 → 记笔记 → 回忆) + SqliteStore 持久化跨连接验证。

关键设计:
- Provider 自动检测: AG_LLM_* 环境变量齐全时用真实 LLM (from_env),
  否则降级 MockProvider + 9 条预设响应 (含真实工具调用序列)
- NoteTool 展示 MemoryStore trait 解耦: 直接持有 Arc<dyn MemoryStore>,
  绕过 AgentSession 封装 (key 前缀 "note:" + list 过滤)
- CalcTool ponytail 方案: 手写 'a op b' 解析, 不引入 rhai 依赖
- 持久化验证: drop(bundle) + drop(session) → backend Arc 引用归零 →
  SqliteStore Connection 自动 close → 重开连接读取数据存活

规模: 237 行 (含完整注释); clippy 0 警告; cargo run exit 0。
2026-07-05 20:04:44 +08:00
徐涛 c6651c9b75 feat(examples): 新增 quick_start 最小可运行示例
展示 Agent / BaseTool / AgentBuilder / AgentSession 四层抽象的
最小可行集成:MockProvider + EchoTool + submit_turn("你好"),
EchoTool 真正被 LLM 调用并产出 "收到" 字样,零外部配置。

规模: 57 行(含注释),clippy 0 警告 + cargo run exit 0。
2026-07-05 20:01:13 +08:00
徐涛 e636e16820 test(core): 验证 Phase 8 Step 8.1 commit 1-3 零回归
cargo test --all-targets: 200 passed / 0 failed
cargo clippy --all-targets -- -D warnings: 0 警告
cargo doc --no-deps: 0 warning

Step 8.1 四个增量 commit 全部交付,零 deprecated warning。
2026-07-05 19:50:57 +08:00
徐涛 1c89d23ba2 docs: v0.2.0-rc.1 CHANGELOG + 版本号 + README 示例列表
- CHANGELOG.md 新增 [0.2.0-rc.1] 条目(Added/Changed/Non-exhaustive/
  Deprecated/Fixed/Migration Guide 六节)
- Cargo.toml version: 0.1.0 → 0.2.0-rc.1
- README 示例列表: 7 → 10(新增 quick_start / end_to_end /
  simple_visit),依赖版本 0.1 → 0.2

验收: 人工 review + git diff 确认版本号与示例数对齐。
2026-07-05 19:50:10 +08:00
徐涛 5b4343a051 refactor(agent): StepStatus::Completed 切换至 MessageResponse
StepStatus::Completed 字段类型从废弃的 ChatResponse 切换为 IR 层
MessageResponse,同时清理 task_agent_demo.rs 的 3 处废弃类型引用:

- ChatResponse → MessageResponse (字段映射见 §3.1.2)
- OpenaiChatMessage::assistant_text(t) → Message::assistant(t)
- FinishReason::Stop → StopReason::Stop (Option 包裹同步移除)

移除 src/agent/task.rs 的两处 #[allow(deprecated)] 与
examples/task_agent_demo.rs 顶部 #![allow(deprecated)],零
deprecated warning。

验收: cargo build + clippy -D warnings + test --all-targets 全绿;
cargo run --example task_agent_demo exit 0。
2026-07-05 19:48:59 +08:00
徐涛 6e1182e64c feat(core): 14 个公开枚举标记 #[non_exhaustive]
为 v0.2.0-rc.1 API 稳定性收尾。覆盖:
- P0 核心 IR: Message / ContentBlock / ContentBlockType / StreamEvent / HookEvent
- P0 Error: AgentError / LlmError / ToolError / MemoryError / PromptError
- P1 其他: MemoryStrategy / StepStatus / ToolChoice / ResponseFormat

明确不加的: 内部 wire-format (OpenaiChatMessage 等) /
语义已收敛 (Role/ServiceTier 等) / 使用面窄 (Permission 等)。

示例侧的 3 处 Message exhaustive match (prompt_composer /
conversation_memory_demo) 补全 `_` 通配分支,零行为变化。

验收: cargo build + clippy -D warnings + test --all-targets 全绿 (200 passed)
2026-07-05 19:47:03 +08:00
徐涛 b8f4fe0fe3 docs(AGENTS.md): 添加进度同步规范,明确 roadmap 更新要求 2026-07-05 17:40:22 +08:00
徐涛 7574f9c24c docs(roadmap): 标记 Phase 7 SqliteStore 已完成
更新当前状态描述、Phase 7 交付物详情、里程碑 M3 状态、依赖性图谱及下一步行动
2026-07-05 17:25:32 +08:00
徐涛 c82af60f81 feat(memory): 实现 SqliteStore 持久化
- 新增 src/memory/store/sqlite_store.rs(SqliteStore + 9 个内联测试)
- 基于 rusqlite 0.32(bundled),使用 Arc<Mutex<Connection>> + spawn_blocking
- WAL 模式 + synchronous=NORMAL + busy_timeout=5s + PRAGMA user_version
  schema 版本管理
- created_at 归一化为 UTC 的 RFC 3339 TEXT,字典序等价时间序
- 错误精细映射:SqliteFailure/InvalidQuery → InvalidInput;
  FromSqlConversionFailure → Serialization;其他 → Storage
- 9 个测试覆盖 CRUD、upsert、prefix/since/offset+limit 过滤、
  10 写者 × 10 次并发、持久化 round-trip、trait-box 互换兼容性

依赖:
- rusqlite = { version = "0.32", features = ["bundled"] }
- time 增补 features: parsing, formatting, macros
- dev-dependencies: tempfile = "3"

测试:199 → 200 pass(191 原有 + 9 新增)
2026-07-05 17:13:28 +08:00
徐涛 c8a91f6eaf refactor(memory): 将 store.rs 拆分为模块目录,仅结构搬移
- 将 src/memory/store.rs 单体文件拆分为模块根 + store/ 目录结构
- InMemoryStore(struct + impl + Default + 6 个内联测试)整体提取到
  src/memory/store/in_memory.rs
- store.rs 保留 MemoryStore trait 与 EvictionPolicy/EvictionConfig
- 外部消费者导入路径 crate::memory::store::MemoryStore 不变
2026-07-05 17:09:45 +08:00
徐涛 13edacd775 feat(memory): 添加 SqliteStore 持久化方案文档 2026-07-05 16:54:29 +08:00
徐涛 821cea8e60 feat(docs): 更新 roadmap,标记 Phase 6 交付完成
Phase 6 ToolDef IR 的 5 个子步骤全部标注已完成,新增实际变更摘要,更新里程碑状态及下一步行动计划
2026-07-05 10:49:50 +08:00
徐涛 517ef7db32 docs: 添加 Phase 6 ToolDefinition IR 正式化实施方案 2026-07-05 10:46:46 +08:00
徐涛 4cf5918b9c refactor(llm): 移除 ToolDefinition 别名与遗留 deprecation 抑制点
完成 ToolDef IR 切换的最后清理:移除 ToolDefinition 别名与
OpenaiToolDefinition 的公共 re-export;各调用点(cycle/registry/mcp/agent)
直接使用 ToolDef 并清理对应的 #[allow(deprecated)] 抑制点;新增
roundtrip 测试验证 ToolDef 序列化兼容性。
2026-07-05 10:32:35 +08:00
徐涛 9da9b83167 feat(llm): 切换至 ToolDef IR 并适配 OpenAI Provider
将 MessageRequest.tools 切换为 Provider 无关的 ToolDef IR;
ToolDefinition 别名切换指向 ToolDef,registry/mcp 构造去掉
strict 字段;OpenAI 适配层通过 From 转换将 ToolDef 转为
OpenaiToolDefinition wire format。Anthropic 适配层字段名一致
无需改动,openai_compat/ollama 委托 GenericOpenaiProvider 无影响。
2026-07-05 10:14:32 +08:00
徐涛 b1875192fd feat(llm): 引入 ToolDef IR 类型
新增 Provider 无关的工具定义中间表示 ToolDef,配套实现双向 From 转换;
旧 OpenaiToolDefinition 标记 doc(hidden)。本次仅新增类型,不影响现有
代码路径。
2026-07-05 10:06:52 +08:00
徐涛 d3067e2f53 docs(roadmap): 更新 Phase 5 完成状态和下一阶段计划 2026-07-05 09:04:44 +08:00
徐涛 5648b1d217 style(tools, llm): 统一导入顺序与代码格式 2026-07-05 08:19:13 +08:00
徐涛 98dfe6c1ed feat(llm): 完成 Phase 5 热身准备(Ollama / non_exhaustive / ProviderConfig)
Phase 5 三个 Step 全部落地:

Step 5.2 — Ollama Provider
- 新增 OllamaProvider newtype 包装(默认 localhost:11434/v1,零 API key)
- ProviderType 新增 Ollama 变体与 FromStr 解析

Step 5.3 — #[non_exhaustive] 前置标记
- ProviderType / StopReason / FinishReason / EvictionPolicy 加 #[non_exhaustive]
- 编译期兼容护栏,避免下游 silent break

Step 5.1 — ProviderConfig 扩展
- 加 timeout_secs / max_retries 字段、Default、from_env(prefix)
- create_provider 各分支通过 pub(crate) from_parts 一次性构造并注入 timeout
  (同时避开 Anthropic 的 default_headers 与双重 client 构造)
- map_reqwest_error 改为方法读取 self.timeout_secs(移除硬编码 120s)
- AnthropicProvider::with_timeout 同值短路,with_client 标 #[deprecated]
- DeepSeek / Qwen 加公开 with_client,new_with_client 走代理
- 7 个新测试:5 个 from_env 单元测试 + 3 个 timeout 传导 wiremock
  (OpenAI Chat / DeepSeek / Anthropic)
- Cargo.toml 加 temp-env dev-dep
2026-07-05 08:12:40 +08:00
徐涛 9e476e79bb merge: 合并 release/v0.1 全部修改(4 个 commit 含 v0.2 路线图与 Phase 5 实施方案) 2026-07-05 07:32:23 +08:00
徐涛 3bd135ec98 docs(roadmap): 添加 Phase 5 warmup 实施方案
明确 Step 5.2 Ollama Provider、Step 5.3 non_exhaustive 前置标记、
Step 5.1 ProviderConfig 扩展三个独立 Step 的执行顺序与交付物
2026-07-05 07:32:19 +08:00
徐涛 76f3235ed7 docs(roadmap): 更新 v0.2 规划为 8 个增量 Phase 并细化实施步骤 2026-07-04 10:32:24 +08:00
徐涛 6315f2d008 docs: 添加 opencode 子代理调度分发与合并机制调研笔记 2026-07-04 10:19:54 +08:00
徐涛 fba78f5f33 docs(roadmap): 更新路线图为 v0.2 生产就绪规划
同步 v0.1.0 发布状态,将 v0.2+ 扩展项重
组为 12 项基础功能和 ContextSlot 上下文管
理,明确 v0.3+ 展望及边界范围
2026-07-04 08:11:09 +08:00
152 changed files with 35313 additions and 2001 deletions
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+62
View File
@@ -0,0 +1,62 @@
name: CI
on: [push, pull_request]
env:
RUSTFLAGS: "-D warnings"
jobs:
test-matrix:
name: test (${{ matrix.features }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
features:
- "full"
- "light"
- "chat,provider-openai"
- "chat,provider-openai,tools-mcp"
- "multi,provider-openai"
- "multi,provider-openai,tools-mcp"
- "chat,provider-openai,provider-openai-response"
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo test --no-default-features --features "${{ matrix.features }}" --lib
clippy:
name: clippy
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo clippy --all-features --lib -- -D warnings
format:
name: fmt
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
- run: cargo fmt --check
examples:
name: examples
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo test --features "full"
+56 -9
View File
@@ -186,17 +186,64 @@ pub use vector_store::VectorStore;
## 文档规范
### 方案规范 (docs/)
### 文档编号规范(design/pdd/ + design/prd/
**编号规则**:创建新方案前必须先通过 shell 命令确认当前实际最大编号(Unix: `ls docs/` / Windows: `dir docs\`),禁止使用上下文中缓存的编号,如遇冲突自动递增
`design/pdd/`(方案文档)和 `design/prd/`(需求文档)使用相同的命名格式,但**各自独立编号**:
**方案文档结构**6 项):
1. **背景与目标** - 问题描述、预期目标
2. **需求分析** - 功能需求、非功能需求
3. **方案设计** - 架构设计、模块划分、接口定义
4. **实现计划** - 任务拆解、优先级、时间估算
5. **风险评估** - 潜在风险、缓解措施
6. **验收标准** - 可验证的完成条件
```
<序号>-<简短描述>.md
```
规则:
- 序号使用数字,从 1 开始递增。**创建前必须通过 shell 命令确认目标目录当前实际最大编号再加 1:**
```bash
# 查 design/pdd/ 的最大编号
ls design/pdd/ 2>/dev/null | grep -E '^\d+-' | sort -t- -k1 -n | tail -1 | cut -d- -f1
# 查 design/prd/ 的最大编号
ls design/prd/ 2>/dev/null | grep -E '^\d+-' | sort -t- -k1 -n | tail -1 | cut -d- -f1
# 无输出则从 1 开始
```
禁止使用上下文中缓存的编号。
- 描述:中文,简短概括主题
- 两个目录各自独立编号——`design/pdd/` 已有 `3-` 时,`design/prd/` 的新文件仍从当前最大号 +1 开始,互不影响
方案文档(`design/pdd/`)应包含:
- 背景与目标
- 需求推演概要(需求拆解、边界识别、关键假设的简要推演)
- 当前问题分析
- 架构决策记录(重大技术选型、架构变更的决策过程与理由)
- 设计方案(含架构图/流程图)
- 实施步骤
- 验证标准
- 回滚方案(如适用)
示例:
- `design/pdd/1-ui-components重构方案.md`
- `design/prd/1-用户认证需求.md`
- `design/pdd/2-数据库迁移方案.md`
---
### 设计目录(design/
项目根下的 `design/` 目录集中管理所有设计相关的文件,供人类和 agent 共同读写。
| 子目录 | 内容 | 谁写 | 谁读 |
|--------|------|------|------|
| `design/pdd/` | 方案设计文档(PDD)→ 架构方案、设计决策、转换方案 | proposal→writer pipeline | Think 参考、Build 实现、Vet 审查 |
| `design/prd/` | 需求文档(PRD)→ 功能需求、用户故事、验收标准 | 人写 | Think 分析、Proposal 写方案时参考 |
| `design/prototype/` | **OD 导出的原型 HTML** → 视觉稿、交互原型、页面 layout | OD 桌面版导出 | Think 分析结构、Build 对照实现 |
| `design/notes/` | 笔记记录 → 零散想法、会议纪要、调研速记 | 人写 | 各 agent 参考 |
| `design/roadmap/` | 路线图 → 里程碑规划、版本计划、优先级列表 | 人写 | Proposal 排期参考 |
| `design/DESIGN.md` | 设计系统(品牌规范)→ 色板、字体、间距、语气 | OD 导出 / 人维护 | Think 提取 token、Build 同步到 `src/` |
| `design/tokens.css` | 设计 Token CSS → 从 DESIGN.md 提取的 CSS 变量 | 人同步 / agent 同步 | 所有 Svelte 组件引用 |
**访问规则:**
- 读:所有 agent 默认可读(`read_file` 不需要额外权限)
- 写:writer agent 可通过 `"design/**": allow` 写入 `design/` 下任意子目录
- 注意:`prototype/` 由 OD 桌面版导出,agent 只读不写;`DESIGN.md` 和 `tokens.css` 建议手动维护或 agent 写入时确认后再改
**兜底规则:** 文档类型不在上表时(如教程、接口文档、临时记录),或目标目录不存在时 → **向用户提问确认路径**。不允许自行推断存放位置。
---
+124
View File
@@ -2,6 +2,130 @@
本项目所有重要变更均记录于此文件。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
## [0.3.0] - 未发布
v0.3.0 首个增量 Phase。技术债清理 + ContextSlot fork/merge + Phase 9 审查修复。
### Breaking Changes
**类型路径变更(0.3.0):**
- `agcore::llm::types::request::ToolChoice``agcore::llm::types::tool::ToolChoice`(公共 re-export 路径 `agcore::llm::types::ToolChoice` 保持不变)
- `agcore::llm::types::request::StreamOptions``agcore::llm::provider::openai::StreamOptions`
- `agcore::llm::types::request::OpenaiChatRequest``agcore::llm::provider::openai::OpenaiChatRequest`
- `agcore::llm::types::response::OpenaiChatResponse``agcore::llm::provider::openai::OpenaiChatResponse`
- `agcore::llm::types::response::OpenaiChatChunk``agcore::llm::provider::openai::OpenaiChatChunk`
- 其余 `request.rs`/`response.rs` 中的 wire-format 类型(`OpenaiTool``AudioParam``Choice``Delta``ChunkChoice``Annotation``Logprobs``TokenLogprob``URLCitation` 等)同步移入 `agcore::llm::provider::openai` 模块,可见性 `pub(crate)`
**类型删除:**
- `agcore::llm::types::ChatResponse` 已删除(自 v0.1.0 标记 `#[deprecated]`,请改用 `MessageResponse`
- `agcore::llm::types::old_stream::LegacyStreamEvent` 已删除(内部死代码)
**模块签名变化:**
- `LlmCycle::convert_request` / `convert_response``pub` 降级为 `pub(crate)`(因依赖的 `OpenaiChatRequest` / `OpenaiChatResponse``pub(crate)`
### Added
**Phase 13 — ContextSlot fork/merge**
- `ContextSlot::fork(child_id, strategy)` — 从父槽派生独立子槽(数据层操作,不持久化;调用方需自行 `save()`
- `ContextSlot::merge(child, strategy)` — 将子槽消息合并回父槽(`Append` 追加 / `Replace` 替换两种策略)
- `MergeStrategy` 枚举(`#[non_exhaustive]`Phase 16 可扩展 `Summarize`
- `MergeStrategy` 防御性检查:禁止 self-merge / 跨 session merge / 合并到 Readonly slot
- `agcore::agent::MergeStrategy` 公共 re-export 路径可用
- `AgentSession::derive_slot` 重构复用 `fork()` 消除重复代码(行为不变)
**Phase 9 实施审查修复(2026-07-08**
- 2 个集成测试覆盖方案 §4 Step 5:`submit_turn_stream_end_to_end` + `submit_turn_stream_triggers_turn_hooks`
### Changed
**Phase 13 — 技术债清理**
- 3 个旧 types 文件删除(`src/llm/types/request.rs` 187 行 + `response.rs` 177 行 + `old_stream.rs` 45 行)
- 所有 OpenAI wire-format 类型迁入 `provider/openai.rs`,可见性 `pub(crate)`
- `src/llm/stream.rs` 简化为 module doc + `pub use` 重导出(保持 `use crate::llm::stream::StreamEvent` 路径兼容,零下游破坏)
- `ToolChoice``request.rs` 迁入 `tool.rs`serde impl 原样搬入)
**Phase 9 实施审查修复**
- `LlmCycle::run_tool_loop` 实现 `PreRequest` hook(之前 `let _ = hook_executor.as_ref()` 是空操作,导致 hook-based logging/monitoring 在流式工具循环中失效;现在与 `submit_with_tools` 行为对齐,含 `should_block` 检查,阻断时通过 `StreamEvent::Error` 事件化)
### Fixed
**Phase 9 实施审查修复**
- `AgentSession::submit_turn_stream` 末尾 `let _ = hook_executor;` 死代码移除(Arc 引用生命周期由 Arc 自动管理)
### Migration Guide (v0.2.0-rc.1 → v0.3.0)
```rust
// ❌ v0.2.0-rc.1 — 已删除
use agcore::llm::types::ChatResponse;
use agcore::llm::types::request::OpenaiChatRequest;
// ✅ v0.3.0 — 替代路径
use agcore::llm::types::MessageResponse; // ChatResponse → MessageResponse
// OpenAI wire-format 类型为内部使用,不再公共 re-export
// 如需自定义 Provider,请直接 import agcore::llm::provider::openai::*(当前 pub(crate)
```
## [0.2.0-rc.1] - 2026-07-05
v0.2.0 候选发布。Phase 5-7 三大 P0 全部交付完成,API 稳定性扫尾,新增 2 个面向新用户的集成示例。
### Added
**Phase 5 — 热身准备**
- `ProviderConfig::from_env(prefix)`:从 `{prefix}_BASE_URL` / `{prefix}_API_KEY` / `{prefix}_MODEL` / `{prefix}_TIMEOUT_SECS` / `{prefix}_MAX_RETRIES` 环境变量构造配置
- `ProviderConfig::timeout_secs` / `max_retries` 字段(默认 30 / 3
- `OllamaProvider`:本地推理 ProviderOpenAI-compatible`http://localhost:11434/v1` 默认端点)
- `ProviderType::Ollama` 变体 + `FromStr` 解析
**Phase 6 — ToolDef IR 正式化**
- `ToolDef` 结构体(name / description / parameters)替代已废弃的 `OpenaiToolDefinition`
- `MessageRequest.tools` 切换为 `Vec<ToolDef>`
- `OpenaiToolDefinition` 降级为 `#[doc(hidden)]`,仅供 OpenAI 适配层内部消费
**Phase 7 — SqliteStore 持久化**
- `SqliteStore``MemoryStore` 的 SQLite 后端实现,基于 `rusqlite 0.32` bundled
- WAL 模式 + `synchronous=NORMAL` + `busy_timeout=5s` 兼顾崩溃安全与吞吐
- `Arc<Mutex<Connection>>` + `spawn_blocking` 卸载 IO10×10 并发写入无 race
- `MemoryStore::save / get / delete / list` CRUD + prefix / since / offset+limit 过滤
- 进程重启数据不丢的 round-trip 验证
**Phase 8 — MVP 集成出口**
- `examples/quick_start`:30 行最小可运行示例(MockProvider + EchoTool + submit_turn
- `examples/end_to_end`3 工具 + 3 轮对话 + SqliteStore 持久化跨连接验证
### Changed
- **API 稳定性护栏**:14 个公开枚举标记 `#[non_exhaustive]`,覆盖 P0 核心 IR`Message` / `ContentBlock` / `ContentBlockType` / `StreamEvent` / `HookEvent`)、P0 Error`AgentError` / `LlmError` / `ToolError` / `MemoryError` / `PromptError`)、P1 其他(`MemoryStrategy` / `StepStatus` / `ToolChoice` / `ResponseFormat`
- **`StepStatus::Completed` 字段类型**:从废弃的 `ChatResponse` 切换为 IR 层 `MessageResponse`(同时清理 `task_agent_demo.rs``ChatResponse` / `OpenaiChatMessage` / `FinishReason` 三处废弃类型引用)
### Non-exhaustive 清单
为防止未来新增变体时下游 exhaustive match 静默失效,14 个枚举追加 `#[non_exhaustive]`
| 优先级 | 枚举 |
|--------|------|
| P0 核心 IR | `Message`, `ContentBlock`, `ContentBlockType`, `StreamEvent`, `HookEvent` |
| P0 Error | `AgentError`, `LlmError`, `ToolError`, `MemoryError`, `PromptError` |
| P1 其他 | `MemoryStrategy`, `StepStatus`, `ToolChoice`, `ResponseFormat` |
明确不加:内部 wire-format`OpenaiChatMessage` 等)/ 语义已收敛(`Role` / `ServiceTier` / `Modality` / `ImageDetail` / `AudioFormat` / `StopSequence`/ 使用面窄(`Permission` / `McpTransport` 等)。
### Deprecated
(继承自 0.1.0,无新增)`ChatResponse` / `ToolDefinition` 保持 `#[deprecated]` 标记。
### Fixed
- 修复 `StepStatus::Completed(ChatResponse)` 字段类型与 IR 体系不一致问题(已完成迁移)
### Migration Guide (v0.1 → v0.2.0-rc.1)
1. **枚举 match**14 个 `#[non_exhaustive]` 枚举在 crate 外必须使用 `_ =>` 通配分支
2. **`StepStatus::Completed`**:字段类型从 `ChatResponse` 切换为 `MessageResponse`,需做字段映射(参考 `docs/15-phase8-mvp-integration.md` §3.1.2
3. **`ToolDefinition``ToolDef`**Phase 6 已彻底替换 `#[deprecated]` 别名,需全局重命名
---
## [0.1.0] - 2026-07-04
首个公开版本。涵盖 Phase 0-4c 的全部核心能力、Provider IR 重构、LlmCycle 简化,以及面向用户的 7 个离线示例。
+142 -12
View File
@@ -1,26 +1,156 @@
[package]
name = "agcore"
version = "0.1.0"
version = "0.3.7"
edition = "2024"
[features]
default = ["full"]
# === 模块级 features ===
document = []
llm-types = []
prompt = ["llm-types"]
llm = ["llm-types", "tokio", "async-stream", "futures-core", "futures-util", "tokio-stream"]
tools = ["llm-types", "futures", "tokio-util", "tokio"]
tools-mcp = ["tools", "reqwest"]
# memory 模块依赖 llmconversation/vector_store 使用 compact/embedding)、tokioknowledge.rs 使用 Mutex)、timetypes.rs 使用 OffsetDateTime
memory = ["document", "llm", "tokio", "time"]
memory-sqlite = ["memory", "rusqlite", "time"]
agent = ["llm", "tools", "memory", "futures-util"]
engine = ["agent"]
# === Provider features ===
# Provider features — openai/anthropic/openai-response 额外依赖 bytes(流式解析)和 futures-utilStream 组合)
provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
# OpenAI Response APIPOST /responses)—— 与 Chat Completions 协议独立,独立 feature
provider-openai-response = ["llm", "reqwest", "bytes", "futures-util"]
# deepseek/qwen 使用 openai_compat 适配层,不需要 bytes 和 futures-util
provider-deepseek = ["llm", "reqwest"]
provider-qwen = ["llm", "reqwest"]
provider-ollama = ["llm", "reqwest"]
# === 工具 features ===
tracing-init = ["tracing-subscriber"]
# === 快捷组合 ===
full = [
"document", "llm-types", "prompt", "llm",
"tools", "tools-mcp",
"memory", "memory-sqlite",
"agent", "engine",
"provider-openai", "provider-anthropic", "provider-openai-response",
"provider-deepseek", "provider-qwen", "provider-ollama",
"tracing-init",
]
light = ["llm", "provider-openai", "tools", "tools-mcp", "memory", "agent", "engine", "prompt", "document"]
chat = ["agent", "provider-openai"]
multi = ["engine", "provider-openai"]
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "stream"] }
# 始终编译的轻量依赖(5 个,不参与门控)
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
async-trait = "0.1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tokio-stream = "0.1"
futures = "0.3"
futures-util = "0.3"
futures-core = "0.3"
bytes = "1"
async-stream = "0.3"
tokio-util = { version = "0.7", features = ["rt"] }
time = { version = "0.3", features = ["serde"] }
# 12 个重型依赖(全部 optional
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process", "io-util"], optional = true }
reqwest = { version = "0.12", features = ["json", "stream"], optional = true }
rusqlite = { version = "0.32", features = ["bundled"], optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
tokio-stream = { version = "0.1", optional = true }
futures = { version = "0.3", optional = true }
futures-util = { version = "0.3", optional = true }
futures-core = { version = "0.3", optional = true }
bytes = { version = "1", optional = true }
async-stream = { version = "0.3", optional = true }
tokio-util = { version = "0.7", features = ["rt"], optional = true }
time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"], optional = true }
[dev-dependencies]
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
dotenvy = "0.15.7"
wiremock = "0.6"
temp-env = "0.3"
tempfile = "3"
# === Examples required-features ===
# 每个 example 声明最小 feature 集合,`cargo test --features "full"` 时全部编译;
# 其他组合下不兼容的 example 自动跳过。
[[example]]
name = "prompt_composer"
required-features = ["prompt", "llm"]
[[example]]
name = "custom_tool"
required-features = ["tools", "llm"]
[[example]]
name = "conversation_memory_demo"
required-features = ["memory"]
[[example]]
name = "knowledge_graph_demo"
required-features = ["memory"]
[[example]]
name = "knowledge_search_demo"
required-features = ["memory"]
[[example]]
name = "agent_session_demo"
required-features = ["agent"]
[[example]]
name = "task_agent_demo"
required-features = ["agent"]
[[example]]
name = "context_slot_demo"
required-features = ["agent"]
[[example]]
name = "quick_start"
required-features = ["agent"]
[[example]]
name = "simple_visit"
required-features = ["llm", "provider-openai", "tracing-init"]
[[example]]
name = "streaming_events_demo"
required-features = ["llm", "provider-openai"]
[[example]]
name = "agent_switch_demo"
required-features = ["engine"]
[[example]]
name = "bridge_keys_demo"
required-features = ["engine"]
[[example]]
name = "dispatch_stream_demo"
required-features = ["engine"]
[[example]]
name = "engine_demo"
required-features = ["engine"]
[[example]]
name = "sub_agent_dispatch_demo"
required-features = ["engine"]
[[example]]
name = "document_demo"
required-features = ["memory", "tracing-init"]
[[example]]
name = "end_to_end"
required-features = ["agent", "memory-sqlite", "provider-openai"]
[[example]]
name = "response_api_demo"
required-features = ["llm", "provider-openai-response"]
+137 -22
View File
@@ -26,7 +26,7 @@ AG Core 不是 Agent 产品,而是 Agent 的**底层依赖库**:上层应用
```toml
[dependencies]
agcore = "0.1"
agcore = "0.3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```
@@ -38,7 +38,7 @@ use std::sync::Arc;
use agcore::agent::{Agent, AgentBuilder, AgentSession};
use agcore::llm::hooks::HookExecutor;
use agcore::llm::mock::MockProvider;
use agcore::llm::provider::LlmProvider;
use agcore::llm::LlmProvider;
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
use agcore::llm::types::Usage;
@@ -110,10 +110,12 @@ let provider = create_provider(
).expect("创建 Provider 失败");
```
更多端到端示例见 [`examples/`](./examples/) 目录(共 7 个,全部可 `cargo run --example <name>`):
更多端到端示例见 [`examples/`](./examples/) 目录(全部可 `cargo run --example <name>`):
| 示例 | 说明 |
|------|------|
| `quick_start` | **最短可运行示例**MockProvider + EchoTool + submit_turn,新用户 5 分钟上手 |
| `end_to_end` | **完整集成示例**3 工具 + 3 轮对话 + SqliteStore 持久化跨连接验证 |
| `agent_session_demo` | Agent + 会话 + SessionMemory 完整链路(MockProvider 离线) |
| `custom_tool` | 自定义工具注册、单次 / 并行调用、权限检查 |
| `prompt_composer` | 提示词模板与组合器(纯离线) |
@@ -121,16 +123,112 @@ let provider = create_provider(
| `conversation_memory_demo` | 对话记忆滑动窗口与隔离 |
| `knowledge_search_demo` | 知识页面关键词检索 |
| `streaming_events_demo` | LLM 流式响应事件消费(含错误路径) |
| `simple_visit` | 真实 LLM 调用(OpenAI / Anthropic,设置 `OPENAI_*` / `ANTHROPIC_*` 环境变量) |
| `document_demo` | 文档分割:RecursiveCharacterSplitter 将长文本切分为可嵌入片段 |
| `knowledge_graph_demo` | 知识图谱:实体-关系 CRUD、BFS 遍历、关键词/标签检索 |
| `context_slot_demo` | 多上下文分区:ContextSlot 分区管理、FocusedConfig 聚焦策略 |
| `sub_agent_dispatch_demo` | 子任务分发:SubTask 异步执行与结果汇聚 |
| `dispatch_stream_demo` | 分发流式输出:SubTaskStreamEvent 实时消费 |
| `engine_demo` | Agent 执行引擎:SessionManager 会话树 + Checkpointer 快照恢复 |
| `bridge_keys_demo` | 桥接键:Agent 间上下文键值透传 |
| `agent_switch_demo` | Agent 热切换:会话中动态切换 Agent 角色 |
| `response_api_demo` | OpenAI Response API`POST /responses`)真实调用 |
## Feature 组合
AG Core 通过 Cargo features 让下游按需选择模块,跳过不需要的编译单元和重型依赖。`default = ["full"]` 保持向后兼容——不指定 features 时行为与 v0.3.0 一致。
### 快捷组合
| 组合 | 场景 | 包含的 features |
|------|------|----------------|
| `full`(default | 全栈使用,兼容 v0.3.0 | 全部 16 个 feature |
| `light` | 生产常用,跳过 Anthropic/DeepSeek/Qwen/Ollama | llm + provider-openai + tools + tools-mcp + memory + agent + engine + prompt + document |
| `chat` | 纯对话(跳过 SQLite 和 MCP | agent + provider-openai |
| `multi` | 多 Agent 复合(chat + subagent + switch + checkpointer | engine + provider-openai |
### Cargo.toml 配置示例
```toml
# 默认全栈(兼容 v0.3.0
[dependencies]
agcore = "0.3"
# 纯对话场景:跳过 SQLite 和 MCP,编译更快
[dependencies]
agcore = { version = "0.3", default-features = false, features = ["chat", "provider-openai"] }
# 生产常用:OpenAI + 工具 + 记忆 + Agent
[dependencies]
agcore = { version = "0.3", default-features = false, features = ["light"] }
# 多 Agent 复合 + MCP 工具
[dependencies]
agcore = { version = "0.3", default-features = false, features = ["multi", "provider-openai", "tools-mcp"] }
```
### 模块级 features
如需更细粒度控制,可单独启用模块级 features:
| Feature | 覆盖内容 | imply |
|---------|---------|-------|
| `document` | Document + RecursiveCharacterSplitter | — |
| `llm-types` | Message / ToolDef / Usage 等 IR 类型 | — |
| `prompt` | PromptTemplate + PromptComposer | `llm-types` |
| `llm` | Provider trait + LlmCycle + hooks + compact + embedding + mock | `llm-types` |
| `tools` | BaseTool + ToolRegistry | `llm-types` |
| `tools-mcp` | McpClientStdio/StreamableHttp | `tools` |
| `memory` | MemoryStore + Conversation + VectorStore + KnowledgeGraph + Retriever | `document` + `llm` |
| `memory-sqlite` | SqliteStore | `memory` |
| `agent` | Agent + Builder + Session + ContextSlot + Summary | `llm` + `tools` + `memory` |
| `engine` | SessionManager + Checkpointer + SubAgent + Switch | `agent` |
| `provider-openai` | OpenAI Provider 实现 | `llm` |
| `provider-anthropic` | Anthropic Provider 实现 | `llm` |
| `provider-openai-response` | OpenAI Response API`POST /responses`Provider 实现 | `llm` |
| `provider-deepseek` | DeepSeek Provider 实现 | `llm` |
| `provider-qwen` | Qwen Provider 实现 | `llm` |
| `provider-ollama` | Ollama Provider 实现 | `llm` |
| `tracing-init` | `init_tracing()` 函数 | — |
## 升级指南(v0.3.0 → v0.3.2
### LlmProvider trait 路径变更
v0.3.2 起,`LlmProvider` trait 及其关联类型 `ProviderCapabilities` / `ProviderFeatures` 从 `provider` 模块移至 `llm` 模块根级别,归属 `#[cfg(feature = "llm")]` 而非 `any(provider-*)`。纯 Mock 场景不再需要引入任何 provider feature。
| 旧路径(v0.3.0 | 新路径(v0.3.2 |
|------------------|------------------|
| `agcore::llm::provider::LlmProvider` | `agcore::llm::LlmProvider` |
| `agcore::llm::provider::ProviderCapabilities` | `agcore::llm::ProviderCapabilities` |
| `agcore::llm::provider::ProviderFeatures` | `agcore::llm::ProviderFeatures` |
**向后兼容**`provider` 模块中保留了 `pub use` 重导出,老路径仍可编译。但推荐迁移至新路径,未来版本可能移除重导出。
`ProviderConfig` / `ProviderType` / `create_provider()` 等 provider 创建逻辑仍在 `agcore::llm::provider` 下,无需迁移。
### 迁移步骤
```bash
# 1. 全局替换 use 路径
sed -i 's/agcore::llm::provider::LlmProvider/agcore::llm::LlmProvider/g' src/**/*.rs
sed -i 's/agcore::llm::provider::{LlmProvider/agcore::llm::{LlmProvider/g' src/**/*.rs
# 2. 验证编译
cargo build --features "full"
```
## 核心模块
| 模块 | 一句话说明 |
|------|----------|
| `agcore::llm` | LLM 调用周期(`LlmProvider` trait + `LlmCycle` 重试/用量 + 流式事件 + auto-compaction + Hook + 公开 `MockProvider` |
| `agcore::llm` | LLM 调用周期(`LlmProvider` trait + `LlmCycle` 重试/用量 + 流式工具循环 + auto-compaction + Hook + `Embedding` trait + 公开 `MockProvider` |
| `agcore::prompt` | 提示词工程(`PromptTemplate` 变量插值 + `PromptTemplateRegistry` + `PromptComposer` 多角色消息构造 + `validate_messages` |
| `agcore::tools` | 工具系统(`BaseTool` trait + `ToolRegistry` 注册/调用 + `PermissionChecker` 黑白名单 + MCP stdio 客户端) |
| `agcore::memory` | 记忆系统(`MemoryStore` trait + `InMemoryStore` 默认实现 + `ConversationMemory` 滑动窗口 + `KnowledgeStore` + `MemoryRetriever` |
| `agcore::agent` | Agent 运行时(`Agent` trait 角色定义 + `AgentBuilder` + `RuntimeBundle` 依赖注入 + `AgentSession` 会话 + `SessionMemory` + `Plan`/`Step` 任务编排) |
| `agcore::memory` | 记忆系统(`MemoryStore` trait + `InMemoryStore` / `SqliteStore` + `ConversationMemory` 滑动窗口 + `KnowledgeStore` + `KnowledgeGraph` 图谱 + `VectorStore` 向量 + `RagPipeline` + `MemoryRetriever` 混合检索 |
| `agcore::agent` | Agent 运行时(`Agent` trait + `AgentBuilder` + `RuntimeBundle` + `AgentSession` + 多上下文分区 + 摘要自动生成 + 快照恢复 + `Plan`/`Step` 任务编排) |
| `agcore::engine` | Agent 执行引擎(`SessionManager` 会话树 + `Checkpointer` time-travel 快照 + 子任务分发 + Agent 热切换) |
| `agcore::document` | 文档分割(`Document` 类型 + `RecursiveCharacterSplitter` 递归字符级分割) |
## 架构关系图
@@ -140,9 +238,14 @@ let provider = create_provider(
└───────────────────────────┬─────────────────────────────────┘
│ 使用
┌───────────────────────────▼─────────────────────────────────┐
│ Agent Runtime (agcore::agent)
│ Agent Engine (agcore::engine)
│ SessionManager / Checkpointer / SubTask 分发 / Agent 切换 │
└───────────────────────────┬─────────────────────────────────┘
│ 编排
┌───────────────────────────▼─────────────────────────────────┐
│ Agent Runtime (agcore::agent) │
│ Agent / AgentBuilder / RuntimeBundle / AgentSession / │
SessionMemory / Plan / Step
ContextSlot / SummaryConfig / Plan / Step │
└─────┬───────────────┬───────────────┬───────────────┬───────┘
│ │ │ │
┌─────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
@@ -151,10 +254,15 @@ let provider = create_provider(
│ llm │ │ prompt │ │ tools │ │ memory │
└─────┬─────┘ └─────────────┘ └─────┬───────┘ └──────┬──────┘
│ │ │
──────────────┬────────────────┘ │
┌─────────────────┐
│ Mock Provider │◄──────────────────────┘
│ ┌──────────┐ │ ┌───────────┴──────
│ Document │ Graph / Vector
│ agcore:: │ │ │ RagPipeline
│ │ document │ │ │ Retriever │
│ └──────────┘ │ └──────────────────┘
└──────────────┬────────────────┘
┌─────────────────┐
│ Mock Provider │
│ 公开 API │ 离线测试 / 示例
└─────────────────┘
```
@@ -163,28 +271,35 @@ let provider = create_provider(
```mermaid
graph BT
LLM["<b>llm</b><br/>Provider / Cycle /<br/>Hooks / Stream /<br/>Compact / Mock"]:::core
LLM["<b>llm</b><br/>Provider / Cycle /<br/>Hooks / Stream /<br/>Compact / Embedding /<br/>Mock"]:::core
Prompt["<b>prompt</b><br/>Template / Composer"]:::core
Tool["<b>tools</b><br/>BaseTool / Registry /<br/>Permission / MCP"]:::core
Memory["<b>memory</b><br/>Store / Conversation /<br/>Knowledge / Retriever"]:::core
Agent["<b>agent</b><br/>Agent / Builder /<br/>Session / Plan"]:::core
Tool["<b>tools</b><br/>BaseTool / Registry /<br/>MCP"]:::core
Memory["<b>memory</b><br/>Store / Conversation /<br/>Knowledge / Graph /<br/>VectorStore / RagPipeline /<br/>Retriever"]:::core
Agent["<b>agent</b><br/>Agent / Builder /<br/>Session / ContextSlot /<br/>Summary / Plan"]:::core
Engine["<b>engine</b><br/>SessionManager /<br/>Checkpointer /<br/>SubTask / Switch"]:::phase
Document["<b>document</b><br/>Splitter"]:::phase
Prompt --> LLM
Tool --> LLM
Memory --> LLM
Memory --> Document
Document --> LLM
Agent --> LLM
Agent --> Tool
Agent --> Memory
Engine --> Agent
classDef core fill:#60a5fa,stroke:#2563eb,color:#fff
classDef phase fill:#a78bfa,stroke:#7c3aed,color:#fff
```
**依赖规则**
- `llm` 是叶子,被其他四个模块使用
- `prompt` / `tools` / `memory` 互相不依赖,可独立使用
- `agent` 编译期依赖 `llm` / `tools` / `memory`,与 `prompt` 无直接编译依赖(system prompt 以 `&str` 形式传入)
- 上层应用只应依赖 `agent` + 必要的子模块,不应跨层直接 `use`
- `llm` 被 `agent` / `engine` 使用,同时 `llm::cycle` 依赖 `tools::ToolRegistry`(用于工具调用循环)
- `prompt` / `tools` 互不依赖,可独立使用;`memory` 依赖 `document`(向量存储的分割器)和 `llm`embedding trait
- `agent` 编译期依赖 `llm` / `tools` / `memory`,与 `prompt` / `document` 无直接编译依赖(system prompt 以 `&str` 形式传入,文档分割由应用层处理
- `engine` 编译期依赖 `agent`,提供会话树管理和快照恢复能力
- 上层应用通常依赖 `engine`(完整能力)或 `agent`(轻量场景),不应跨层直接 `use`
## 环境变量
@@ -198,7 +313,7 @@ graph BT
| `ANTHROPIC_API_KEY` | 用 Anthropic 时 | — | Anthropic Claude API key |
| `ANTHROPIC_BASE_URL` | 是 | `https://api.anthropic.com` | Anthropic 兼容端点 base URL |
| `ANTHROPIC_MODEL` | 是 | `claude-3-5-sonnet-latest` | Claude 模型名 |
| `PROVIDER` | 否 | `openai` | Provider 类型:`openai` / `openai-response` / `anthropic` / `deepseek` / `qwen` |
| `PROVIDER` | 否 | `openai` | Provider 类型:`openai` / `anthropic` / `deepseek` / `qwen` / `ollama` |
| `RUST_LOG` | 否 | `agcore=info` | tracing 日志级别(其他 crate 可加 `=debug` |
示例(运行 `examples/simple_visit.rs` 真实调用 OpenAI):
@@ -223,7 +338,7 @@ AG Core 在 Phase 4 设计阶段调研了 4 个 2026 年公开的 AI Agent 项
| [OpenHuman](https://github.com/tinyhumansai/openhuman) | 桌面助手 | Rust + Tauri | 记忆树与 Token 压缩 |
| [OpenHarness](https://github.com/HKUDS/OpenHarness) | Agent Harness 框架 | Python | 显式依赖注入容器 + 三级权限 |
AG Core 不"抄代码",只参考架构模式。当前实现已经采纳
AG Core 借鉴了以下架构模式(不直接复用代码)
- **OpenHarness 风格** —— 显式 `RuntimeBundle` 依赖注入容器
- **Hermes 风格** —— `Agent` trait(角色)与 `AgentSession`(会话)解耦
View File
+183
View File
@@ -0,0 +1,183 @@
# LangChain & LangGraph 功能调研笔记
> 调研时间:2026-07-06
> 两者关系:同一公司(LangChain Inc.)维护的堆栈上下两层,不是竞品
---
## 两者关系
```
┌──────────────────────────────────────────┐
│ LangChain (v1.0 GA) │ ← 高层框架:模型抽象、工具、提示词、600+集成
│ create_agent / LCEL / 组件库 │
├──────────────────────────────────────────┤
│ LangGraph (v1.0 GA) │ ← 底层运行时:有向图执行引擎
│ StateGraph / Checkpointing / HITL │
├──────────────────────────────────────────┤
│ LangSmith (可观测性) │
└──────────────────────────────────────────┘
```
2025年10月22日同时达到 v1.0 GA,官方分工:
> **LangChain** = agent frameworkabstractions and integrations for models, tools, and agent loops.
> **LangGraph** = orchestration runtimedurable execution, streaming, human-in-the-loop, and persistence.
LangChain v1.0 的 `create_agent` 内部已运行在 LangGraph 引擎上。
---
## LangChain v1.0
### 定位
高层应用框架,提供 agent 所需的**组件抽象**和**集成生态**。
### 精简后的核心模块
| 模块 | 功能 |
|------|------|
| `langchain.agents` | `create_agent`, `AgentState`(取代旧 AgentExecutor |
| `langchain.chat_models` | `init_chat_model`, `BaseChatModel`(统一模型初始化) |
| `langchain.tools` | `@tool`, `BaseTool` |
| `langchain.messages` | 消息类型、内容块、`trim_messages` |
| `langchain.embeddings` | `init_embeddings`, `Embeddings` |
旧组件(`LLMChain``ConversationChain` 等)移入 `langchain-classic`
### 七大组件类别
| 类别 | 关键组件 |
|------|----------|
| **Models** | Chat models, LLMs, Embeddings — 统一接口跨 provider 切换 |
| **Tools** | 600+ provider 集成:API、数据库、搜索引擎等 |
| **Agents** | `create_agent`, ReAct agents, Tool-calling agents |
| **Memory** | 消息历史、自定义状态 |
| **Retrievers** | 向量检索器、网络检索器 |
| **Document** | 加载器、分割器、转换器 |
| **Vector Stores** | Chroma, Pinecone, FAISS 等集成 |
### v1.0 关键新特性
**1. Middleware 中间件系统**`create_agent` 的钩子系统:
- `before_model` — 模型调用前注入/修改
- `after_model` — 模型调用后验证/后处理
- `wrap_tool_call` — 拦截工具调用错误
**2. Standard Message Content** — 跨 provider 标准化消息内容格式:
- 推理/思维链、引用、多模态(图片/音视频/文档)
- 工具调用、provider 特有工具(web search, code execution
- 通过 `.content_blocks` 属性访问,向后兼容
**3. `create_agent`** — 取代旧 AgentExecutor,内部运行在 LangGraph 运行时上
### 成熟度
| 维度 | 状态 |
|------|------|
| 版本 | v1.0 GA2025-10 |
| 稳定性 | 稳定,agent 层经重构后已稳定 |
| 生产证明 | Replit, Clay, Rippling, Cloudflare, Workday |
| 支持 | LTS-style support track |
| 适用场景 | RAG、信息提取、单 agent 助手、快速原型 |
---
## LangGraph v1.0
### 定位
底层编排运行时,专为**有状态、长时间运行、多步骤**工作流设计。
### 核心抽象链
```
StateGraph → Nodes (纯 Python 函数) → Edges (路由逻辑)
Shared State (TypedDict / Pydantic)
Checkpointer (每个 super-step 快照)
```
- **StateGraph**: 有状态图,参数化 State 类型
- **Nodes**: 纯函数,`(State) → updates`
- **Edges**: `add_conditional_edges`,支持循环/分支/合并
- **State**: `TypedDict` 或 Pydantic,带 reducer 处理并发更新
- **Reducers**: `add_messages` 等,自动处理追加 vs 覆盖
### 完整功能矩阵
| 功能 | 状态 | 细节 |
|------|------|------|
| **StateGraph** | ✅ 稳定 | 循环图(非 DAG),条件边缘,并行 fan-out |
| **Checkpointing** | ✅ v4.1.1 | SQLite / PostgreSQL / Redis 后端 |
| **Durable Execution** | ✅ 稳定 | 跨失败自动恢复,从精确断点继续 |
| **Human-in-the-loop** | ✅ 一等公民 | `interrupt()` + `Command(resume=...)` |
| **Time-travel 调试** | ✅ 稳定 | 回滚任意 checkpointfork 重放 |
| **流式输出** | ✅ 稳定 | Token 级 + State 级 + Event 级 |
| **多 Agent 编排** | ✅ 稳定 | Supervisor / Swarm / 层级 / Subgraph |
| **Comprehensive Memory** | ✅ 稳定 | 短时工作记忆 + 长时持久记忆 |
| **增量状态存储** | 🧪 DeltaChannel beta (v4.1.0+) | 长消息列表只存 delta |
| **跨进程状态同步** | 🧪 RemoteCheckpointer (v4.1.0+) | 分布式多 agent 架构 |
| **自动 checkpoint 清理** | ✅ keep_latest TTL (v4.0.2) | 避免无限制积累历史 |
| **LangGraph Platform** | ✅ 稳定 | Agent Server:持久化、任务队列、版本管理 |
| **LangGraph Studio** | ✅ 稳定 | 可视化 agent 工作流 |
### 成熟度
| 维度 | 状态 |
|------|------|
| 版本 | v1.0 GA2025-10),checkpointer v4.1.1 (2026-05) |
| 稳定性 | 高,持久化为架构一等公民 |
| 生产证明 | Klarna, Replit, Elastic |
| 支持 | LTS-style support track |
| 适用场景 | 多步骤 agent、多 agent 系统、人工审批、长时间运行任务 |
---
## 功能边界对比
| 维度 | LangChain | LangGraph |
|------|-----------|-----------|
| **层次** | 高层应用框架 | 底层编排运行时 |
| **核心抽象** | `create_agent`, LCEL, 组件库 | `StateGraph`, Nodes, Edges, State |
| **思维模型** | 线性或 DAG 管道 | 节点 + 边缘的循环有向图 |
| **循环/分支** | 受限 | **一等公民**:任意循环、分支、合并 |
| **状态持久化** | 无原生支持 | **一等公民**Checkpointer |
| **Human-in-loop** | 需手动编排 | **一等公民**`interrupt()` + `Command` |
| **Time-travel 调试** | 无 | **一等公民**:回滚 fork 重放 |
| **Durable Execution** | 无 | **一等公民**:跨故障自动恢复 |
| **流式** | Token 级 | Token + State + Event 每节点流式 |
| **多 Agent 编排** | 需手动组合 | **原生**Supervisor/Swarm/Subgraph |
| **模型抽象** | **核心优势** | 复用 LangChain |
| **600+ 集成** | **核心优势** | 可复用 LangChain 集成 |
| **LCEL 线性链** | **有** | 无 |
| **Middleware** | **v1.0 特有** | 无 |
| **学习曲线** | 中等 | 较陡(需图思维) |
| **部署平台** | 无独立平台 | LangGraph Platform + Studio |
---
## 决策路线
```
你的 workflow 需要什么?
├─ 线性、始终相同步骤 → LangChain (LCEL / create_agent)
├─ 需要循环/分支/重试 → LangGraph (StateGraph)
├─ 需要持久化/故障恢复 → LangGraph (Checkpointer)
├─ 需要人工审批 → LangGraph (interrupt())
├─ 需要 time-travel 调试 → LangGraph (checkpoint + fork)
├─ 需要多 agent 协作 → LangGraph (Supervisor/Swarm/Subgraph)
└─ 不确定 → 先用 create_agent,遇到瓶颈下钻到 StateGraph
```
---
## 参考来源
- [LangChain Blog: v1.0 Milestone](https://www.langchain.com/blog/langchain-langgraph-1dot0)
- [LangChain Documentation](https://docs.langchain.com/oss/python/langchain/overview)
- [LangGraph Documentation](https://docs.langchain.com/oss/python/langgraph/overview)
- [LangGraph GitHub](https://github.com/langchain-ai/langgraph)
- [Atlan: LangChain vs LangGraph 2026](https://atlan.com/know/ai-agent/ai-agent-memory/langchain-vs-langgraph/)
- [truefoundry: LangChain vs LangGraph](https://www.truefoundry.com/blog/langchain-vs-langgraph)
@@ -0,0 +1,344 @@
# 笔记:opencode 子代理调度、分发与合并及工作流推进
> 基于 `/Users/midnite/Samples/opencode` 源码调研,2026-07-04
---
## 一、整体架构
```
LLM(主 Agent
├── 调用 Task tooltool call
│ ↓
│ TaskTool.execute() ← packages/opencode/src/tool/task.ts
│ │
│ ├── agent.get() ← 查找 Agent 定义(agent.ts
│ ├── deriveSubagentPermission() ← 权限合并(subagent-permissions.ts
│ ├── sessions.create() ← 创建子 session
│ │
│ ├── [前台] background.wait() + background.waitForPromotion() race
│ │ ↓ 完成
│ │ renderOutput() → XML <task> 标签返回
│ │
│ └── [后台] background.start() → notify() 异步注入结果
└── 会话循环(runLoop) ← prompt.ts
├── 检测 subtask type part → handleSubtask()
├── 检测 compaction → compaction.process()
└── 正常流程 → LLM.stream() → processor.handleEvent()
```
---
## 二、子代理调度(Dispatch
### 2.1 三种触发入口
| 入口 | 触发方式 | 调用链路 |
|------|---------|---------|
| A — LLM 自主 | LLM 调用 `task` tool | 系统提示词中注入了 Task tool 描述 + `describeTask()` 输出子代理列表 → LLM 决策 |
| B — `subtask` part | 消息中有 `type: "subtask"` 的 part | `handleSubtask()` 直接执行 TaskTool,不走 LLM |
| C — `agent` part | 消息中有 `type: "agent"` 的 part | 转为"调用 task tool 带 subagent: XXX"的提示词,引导 LLM |
### 2.2 TaskTool.execute() 完整流程(task.ts
```
execute(params, ctx):
1. background 开关检查(需 experimental flag
2. ctx.ask() 权限询问
3. agent.get(subagent_type) 查找子代理定义
4. task_id 存在 → sessions.get(task_id) 恢复已有子 session
task_id 不存在 → sessions.create() 创建新子 session
5. deriveSubagentSessionPermission() 合并权限
6. 添加默认 deny 规则(todowrite / task
7. 确定 model(继承或子代理自定义)
8. 执行 runTask() → ops.resolvePromptParts() + ops.prompt()
9. 结果格式化为 XML ← renderOutput()
```
### 2.3 关键:子 session 创建(task.ts lines 121-158
```typescript
// 权限继承
const childPermission = deriveSubagentSessionPermission({
parentSessionPermission: parent.permission ?? [],
subagent: next,
})
// 默认 deny 规则
const childToolDenies = [
// 子代理自己的 permission 没允许 todowrite → 默认 deny
...(next.permission.some(r => r.permission === "todowrite") ? []
: [{ permission: "todowrite", pattern: "*", action: "deny" }]),
// 子代理自己的 permission 没允许 task → 默认 deny(防嵌套)
...(next.permission.some(r => r.permission === "task") ? []
: [{ permission: "task", pattern: "*", action: "deny" }]),
// 主 agent 专有工具也不给子代理
...(cfg.experimental?.primary_tools?.map(p => ({ permission: p, ... })) ?? []),
]
```
---
## 三、通信格式:Tool Call / Tool Result
### 3.1 父→子:Task tool 参数
```
{
subagent_type: "explore" | "general" | ...,
description: "简短描述(3-5词)",
prompt: "子代理的完整任务描述",
task_id?: "恢复已有子 session 时使用",
command?: "触发该调用的 CLI 命令(可选)",
background?: true // 后台模式(需 experimental flag
}
```
### 3.2 子→父:XML 包装的纯文本(renderOutput
```xml
<task id="ses_xxxxx" state="completed">
<summary>任务简述</summary>
<task_result>
子 agent 输出的完整文本内容...
</task_result>
</task>
```
错误时:
```xml
<task id="ses_xxxxx" state="error">
<summary>任务失败</summary>
<task_error>
Error: 具体错误信息...
</task_error>
</task>
```
### 3.3 传递给 LLM 的方式
**前台模式**
```
TaskTool.execute() 返回 { output: "<task>...</task>" }
AI SDK 将其转为 tool result,存入数据库 tool part
下一轮 LLM 调用时,tool result 作为消息历史的一部分传入
LLM 看到 XML,自行解析使用
```
**后台模式**
```
TaskTool.execute() 立即返回 <task state="running">...
子 agent 完成后 → background.wait() 触发 → inject()
向父 session 注入合成 text partsynthetic: true
携带 <task state="completed">... 结果
父 LLM 在下一轮循环中看到该消息
```
---
## 四、分发与合并(Distribution & Merge
### 4.1 并行分发
- **无专用分发层**。依赖 LLM 在单条消息中发出多个 tool call
- `task.txt` 引导 LLM*"Launch multiple agents concurrently whenever possible"*
- 底层通过 Effect.ts 的 `Effect.forkIn(scope, { startImmediately: true })` 实现同一消息内多 tool call 并发
- **子 agent 之间完全隔离**,无直接通信
### 4.2 结果合并
**无专用合并逻辑。** 合并完全通过 LLM 的上下文理解完成:
- 前台:tool result 自然进入消息历史,LLM 下一轮读取
- CLI 命令:额外注入 "Summarize the task tool output above and continue with your task." 引导 LLM 总结
- LLM 自主调用:无额外引导,LLM 自行决定如何使用
### 4.3 前台/后台切换机制(task.ts lines 303-333
```typescript
// 前台执行
return yield* Effect.raceFirst(
background.wait({ id: nextSession.id }), // 等完成
background.waitForPromotion(nextSession.id), // 等 promote 到后台
)
```
当用户将前台任务 promote 到后台时,`waitForPromotion` 先返回(标记 `metadata.background = true`),TaskTool 转而返回后台模式的输出。
### 4.4 后台作业引擎(core/background-job.ts
纯内存、非持久化注册表。使用 Effect.ts 的 `SynchronizedRef` 做并发控制。
| 操作 | 行为 |
|------|------|
| `start()` | 创建 jobfork run effect,返回 info |
| `extend()` | 追加顺序执行的 run(通过 `Deferred` 链式等待前一个完成) |
| `wait()` | `Deferred.await(done)`,可选 timeout |
| `waitForPromotion()` | 等待 `promoted` Deferred 或检测 `background` 标记 |
| `promote()` | 标记 `background = true`,触发 `onPromote` callback |
| `cancel()` | 设置 `cancelled`close scope(中断所有子 fork |
---
## 五、工作流推进(Workflow Progression
### 5.1 核心循环(prompt.ts → runLoop
```
runLoop(sessionID):
while true:
1. MessageV2.filterCompactedEffect() 获取消息
2. MessageV2.latest() 取最近 user/assistant/tasks
3. 检查 finish 状态
- 不是 tool-calls 且有 finish → break(退出循环)
4. 取 taskssubtask / compaction 队列)
- subtask → handleSubtask() → continue
- compaction → compaction.process() → continue/break
5. 检查 overflow → 自动创建 compaction task → continue
6. 构建 assistant message
7. SessionProcessor.create() 创建 handle
8. SessionTools.resolve() 解析所有工具
9. 构建 system prompt(环境信息 + skills + MCP + instructions
10. handle.process() — 启动 LLM stream
11. 检查 result:
- "compact" → 返回给外层触发 compaction
- "stop" → break
- "continue" → 继续循环
```
### 5.2 SessionProcessor 事件处理(processor.ts
| Stream 事件 | 处理逻辑 |
|------------|---------|
| `reasoning-start/delta/end` | 创建 reasoning part → 增量追加 → 最终持久化 |
| `tool-input-start/delta/end` | 创建/更新 tool partpending 状态) |
| `tool-call` | 标记 running → 设置 input → **doom loop 检测** |
| `tool-result` | `completeToolCall()` → 持久化结果 + 附件 |
| `tool-error` | `failToolCall()` → 标记错误 |
| `provider-error` | 抛出异常 → 触发重试 |
| `text-start/delta/end` | 流式文本 → `updatePartDelta()` **增量持久化** |
| `step-start` | 创建快照(snapshot |
| `step-finish` | 生成 patch diff → 更新 usage/tokens → **overflow 检测** → 触发 summary |
| `finish` | stream 结束 |
### 5.3 Doom Loop 检测(processor.ts lines 351-377
连续 3 次**完全相同的 tool call**(相同名称 + 相同输入)触发权限询问:
```typescript
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD) // DOOM_LOOP_THRESHOLD = 3
if (recentParts.length === DOOM_LOOP_THRESHOLD &&
recentParts.every(part =>
part.type === "tool" &&
part.tool === value.name &&
part.state.status !== "pending" &&
JSON.stringify(part.state.input) === JSON.stringify(input)
)) {
yield* permission.ask({ permission: "doom_loop", ... })
}
```
### 5.4 Compaction 工作流
两种触发方式:
| 触发条件 | 行为 |
|---------|------|
| step-finish 检测到 `isOverflow()` + `auto: true` | 创建 compaction task → 下一轮循环执行 → 压缩后 continue |
| step-finish 检测到 `isOverflow()` + `auto: false` | 标记 `assistantMessage.error` → idle 等待用户干预 |
Compaction 使用专门的 `compaction` agenthidden, mode=primary, `*=deny`)执行。
压缩后的消息标记 `compacted: true`,后续通过 `MessageV2.filterCompactedEffect()` 过滤。
### 5.5 重试机制(processor.ts lines 658-672
```typescript
Effect.retry(
SessionRetry.policy({
provider: input.model.providerID,
parse, // 错误解析(区分可重试/不可重试)
set: (info) => status.set(sessionID, { type: "retry", ... }),
}),
)
```
遇 provider 错误自动重试,LLM stream 完成后 `Effect.ensuring(cleanup)` 保证资源释放。
---
## 六、六种内置 Agent
| 名称 | Mode | Hidden | 用途 | 核心权限特征 |
|------|------|--------|------|-------------|
| `build` | primary | 否 | 默认 agent,全部工具 | question/plan_enter=allow |
| `plan` | primary | 否 | 计划模式,禁用编辑 | edit=deny(除 plans), task(general)=deny |
| `general` | subagent | 否 | 通用子代理 | todowrite=deny(默认禁止改 todo |
| `explore` | subagent | 否 | 只读代码探索 | `*=deny`,仅 read/grep/glob/bash/webfetch/websearch |
| `compaction` | primary | 是 | 会话压缩(自动) | `*=deny` |
| `title` | primary | 是 | 生成会话标题 | `*=deny`step=1 时异步 fork |
| `summary` | primary | 是 | 生成消息摘要 | `*=deny`(每个 step-finish 时异步 fork |
用户可通过 `config.agent` 自定义 agent(支持 `mode: "all"`),也可通过 `agent.generate` 让 LLM 辅助生成。
---
## 七、权限模型总结
```
父 session permission
├── 仅继承 deny 规则 + external_directory 规则 ← subagent-permissions.ts
│ (父 agent 的 allow 规则不传播到子代理)
├── 子代理自身 permission(来自 agent 定义)
├── 默认 deny
│ - todowrite(除非子代理明确允许)
│ - task(除非子代理明确允许,默认防嵌套)
└── 主 agent 专有工具 deny(来自 config.experimental.primary_tools
```
子代理的 session 权限 = **父 deny + 父 external_directory + 自身 permission - 默认 deny - primary_tools deny**
---
## 八、关键设计决策
| 决策 | 意图 | 效果/局限 |
|------|------|----------|
| 结果以 XML 纯文本嵌入上下文 | 简单、LLM 可直接理解 | LLM 自行解析 XML;大结果可能被截断 |
| 无专用 merge 逻辑 | 简洁,不引入额外抽象 | 依赖 LLM 的理解能力处理返回结果 |
| 默认禁止子代理嵌套 task | 防止无限递归 | 限制了多级分解场景 |
| 同一消息多 tool call 并发 | 利用 LLM 并行能力 | 子 agent 隔离,无法协作 |
| Effect.ts 贯穿全程 | 类型安全、结构化并发 | 学习曲线陡峭 |
| session 作为隔离边界 | 天然权限/消息隔离 | 每个子 session 独立数据库记录,开销较大 |
| 后台引擎纯内存 | 有意识取舍(注释说明) | 进程重启丢失状态 |
---
## 九、参考源码路径
| 文件 | 角色 |
|------|------|
| `packages/opencode/src/tool/task.ts` | Task tool 核心实现(调度入口) |
| `packages/opencode/src/tool/task.txt` | Task tool 的 LLM 使用说明 |
| `packages/opencode/src/agent/agent.ts` | Agent 定义注册中心 |
| `packages/opencode/src/agent/subagent-permissions.ts` | 子代理权限推导 |
| `packages/opencode/src/tool/registry.ts` | 工具注册 + `describeTask()` 列出可用子代理 |
| `packages/opencode/src/session/prompt.ts` | 会话循环 + `handleSubtask()` + 提示词构建 |
| `packages/opencode/src/session/processor.ts` | LLM stream 事件处理器 |
| `packages/opencode/src/session/tools.ts` | Tool ↔ AI SDK 桥接 |
| `packages/opencode/src/session/system.ts` | 系统提示词生成(含 Task tool 说明) |
| `packages/opencode/src/background/job.ts` | 后台作业包装层 |
| `packages/core/src/background-job.ts` | 后台作业核心引擎(内存注册表) |
@@ -0,0 +1,522 @@
# Phase 5:热身准备 — 实施方案
## 1. 背景与目标
Phase 5 是 v0.2.0 发布周期的**热身准备阶段**,包含三个互不依赖的 Step,为后续 Phase 6-12 的端到端集成提供基础设施。
**核心目标**
- 为 Phase 8(端到端示例)提供零 API key 的运行路径(Ollama
- 为公共枚举的向后兼容性加上编译期护栏(`#[non_exhaustive]`
- 为 Provider 构造提供统一的超时与重试配置入口(`ProviderConfig` 扩展)
三个 Step 之间**无依赖关系**,但出于实现效率考虑,按 **5.2 → 5.3 → 5.1** 顺序执行。理由:5.2 先新增 `Ollama` 枚举变体,5.3 再加 `#[non_exhaustive]`,避免枚举标记后添加变体需要在外部 crate 加 `_ =>` 兜底分支的困扰。
## 2. 需求分析
### Step 5.2 — Ollama Provider
| 维度 | 内容 |
|------|------|
| **需求** | 新增 `OllamaProvider`newtype 包装 `GenericOpenaiProvider`,默认连接本地 Ollama 实例 |
| **优先级** | P0 — 为 Phase 8 端到端示例提供无需 API key 的运行路径 |
| **预期交付物** | `src/llm/provider/ollama.rs` 新建文件;`ProviderType` 新增 `Ollama` 变体 |
| **代码量** | ~55 行 |
### Step 5.3 — `#[non_exhaustive]` 前置标记
| 维度 | 内容 |
|------|------|
| **需求** | 为 4 个公共枚举添加 `#[non_exhaustive]` 属性,避免后续新增变体时破坏下游 match |
| **优先级** | P1 — 编译期兼容性保障 |
| **预期交付物** | 修改 4 个枚举定义,各加一行属性 |
| **代码量** | ~4 行 |
### Step 5.1 — ProviderConfig 扩展
| 维度 | 内容 |
|------|------|
| **需求** | `ProviderConfig` 新增 `timeout_secs``max_retries` 字段;实现 `Default``from_env()` 构造;timeout 传导到各 Provider HTTP Client |
| **优先级** | P0 — 与 Roadmap 一致,Phase 8MVP 出口)依赖 from_env |
| **预期交付物** | `ProviderConfig` 扩展;`create_provider()` 超时注入;`from_env()` + 单元测试 |
| **代码量** | ~60 行 + 测试 |
## 3. 方案设计
### 3.1 Step 5.2 — Ollama Provider(先执行)
#### 改动文件清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `src/llm/provider/ollama.rs` | **新建** | OllamaProvider newtype 包装 |
| `src/llm/provider.rs` | 修改 | `ProviderType` 新增 `Ollama` 变体;`FromStr` 加解析;`create_provider()` 加分支 |
| `src/llm/provider/mod.rs` 或其他模块注册文件 | 修改(如需要) | 注册 `pub mod ollama` |
#### 关键代码
**`src/llm/provider/ollama.rs`**(新建):
```rust
//! Ollama Provider —— OpenAI-compatible 协议的 newtype 包装,零 API key。
//!
//! 默认 base_url = `http://localhost:11434/v1`,空 api_key 也可工作。
//! 实现方式同 DeepSeekProvider / QwenProvider,共享 GenericOpenaiProvider 的 HTTP/SSE/转换逻辑。
use reqwest::Client;
use std::pin::Pin;
use async_trait::async_trait;
use futures_core::Stream;
use super::openai::GenericOpenaiProvider;
use super::{LlmProvider, ProviderCapabilities};
use crate::llm::error::LlmError;
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
pub struct OllamaProvider(pub GenericOpenaiProvider);
impl OllamaProvider {
pub fn new(base_url: String, api_key: String, model: String) -> Self {
let url = if base_url.is_empty() {
"http://localhost:11434/v1".to_string()
} else {
base_url
};
Self(GenericOpenaiProvider::new_with_name(
url,
api_key,
model,
"ollama",
))
}
/// 替换默认 HTTP Client(用于 timeout 注入等场景)。
/// 与 `OpenaiChatProvider::with_client` 和 `DeepSeekProvider::with_client` 一致。
pub fn with_client(self, client: Client) -> Self {
Self(self.0.with_client(client))
}
}
#[async_trait]
impl LlmProvider for OllamaProvider {
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
self.0.chat(request).await
}
async fn chat_stream(
&self,
request: MessageRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
self.0.chat_stream(request).await
}
fn capabilities(&self) -> ProviderCapabilities {
let mut caps = self.0.capabilities();
caps.provider_name = "ollama";
caps
}
}
```
**`src/llm/provider.rs`** 的修改:
```rust
// ProviderType 新增变体
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderType {
OpenaiChat,
OpenaiResponse,
Anthropic,
DeepSeek,
Qwen,
/// Ollama(本地),默认 base_url = `http://localhost:11434/v1`。
Ollama,
}
// FromStr 加解析
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
// ... 已有条目 ...
"ollama" => Ok(ProviderType::Ollama),
_ => Err(format!("未知的 Provider 类型: {s}")),
}
}
// create_provider() 加分支
// Step 5.2 阶段仅展示基本构造。Step 5.1ProviderConfig 扩展)
// 执行到此分支时,将同步补充 with_client 链式调用注入 timeout
//
// let client = Client::builder()
// .timeout(Duration::from_secs(config.timeout_secs))
// .build()?;
// Ok(Box::new(
// ollama::OllamaProvider::new(config.base_url, config.api_key, config.model)
// .with_client(client),
// ))
ProviderType::Ollama => Ok(Box::new(ollama::OllamaProvider::new(
config.base_url,
config.api_key,
config.model,
))),
```
#### 集成方式
OllamaProvider 的 newtype 包装模式与 `DeepSeekProvider``QwenProvider` 完全一致,`LlmProvider` trait 委托给 `self.0``capabilities().provider_name` 返回 `"ollama"`
### 3.2 Step 5.3 — `#[non_exhaustive]` 前置标记
#### 改动文件清单
| 文件 | 行号 | 枚举 | 操作 |
|------|------|------|------|
| `src/llm/provider.rs` | ~21 | `ProviderType` | 加 `#[non_exhaustive]` |
| `src/llm/types/response_v2.rs` | ~22 | `StopReason` | 加 `#[non_exhaustive]` |
| `src/llm/types/shared.rs` | ~16 | `FinishReason` | 加 `#[non_exhaustive]` |
| `src/memory/store.rs` | ~35 | `EvictionPolicy` | 加 `#[non_exhaustive]` |
**排除清单**`SlotMode`
**决策理由**`SlotMode` 枚举在 Phase 10`src/llm/context.rs`)中才实际定义,Phase 5 尚不存在此类型。`#[non_exhaustive]` 无法标注不存在的枚举,因此排除标注。Roadmapv0.2.0 §Phase 5 Step 5.3)列出的 `SlotMode`(预置) 推迟到 Phase 10 实现时一并添加。
#### 关键代码
每个枚举在 `derive` 上方或下方加一行属性:
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ProviderType {
// ...
}
```
#### 影响分析
- `#[non_exhaustive]` 是纯编译期属性,不影响运行时行为
- 同一 crate 内的 exhaustive match 不受影响(同 crate 可穷举)
- 下游 crate 的 match 必须加 `_ =>` 兜底分支,这是期望行为——确保未来新增变体时不会 silent break
- **单向门**:此步骤一旦通过 `v0.2.0` 发布到公共 API 后,**不可回退**。回退意味着移除 `#[non_exhaustive]`,可能破坏已添加 `_ =>` 的下游代码。因此必须在发布前完成并确认所有枚举变体正确
### 3.3 Step 5.1 — ProviderConfig 扩展(最后执行)
#### 改动文件清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `src/llm/provider.rs` | 修改 | `ProviderConfig` 加字段;加 `impl Default`;加 `from_env()``create_provider` 注入 timeout |
| `src/llm/provider/openai.rs` | 修改 | `GenericOpenaiProvider` 新增 `timeout_secs` 字段;`new_with_name` 接受 timeout 参数;`map_reqwest_error` 参数化 |
| `src/llm/provider/anthropic.rs` | 修改 | 新增 `timeout_secs` 字段;`new()` 接受 timeout 参数;`map_reqwest_error` 参数化 |
| `src/llm/provider/anthropic.rs` | 修改 | 新增 `with_timeout()` 方法(返回 `Result<Self, LlmError>` |
| `src/llm/provider/openai_compat.rs` | 修改 | `DeepSeekProvider``QwenProvider` 新增公开 `with_client()` 方法 |
| `src/llm/provider/ollama.rs` | 修改 | `OllamaProvider` 新增公开 `with_client()` 方法 |
| `Cargo.toml` | 修改 | 加 `temp_env` dev-dependency |
| 测试文件(`provider.rs` 内联或独立) | 新增 | `from_env` 单元测试 + timeout 传导集成测试 |
#### 数据结构
```rust
/// Provider 构造参数 —— 通用 base_url + api_key + model + timeout/retry 配置。
pub struct ProviderConfig {
pub base_url: String,
pub api_key: String,
pub model: String,
/// 请求超时秒数(默认 30)。应用于 Provider 的 HTTP Client 级别。
pub timeout_secs: u64,
/// 最大重试次数(默认 3)。当前此字段仅由 `from_env()` 采集,
/// 实际重试逻辑由 `CycleConfig.retry.max_retries` 控制。
/// 未来可合并到统一的 retry 配置。
pub max_retries: u32,
}
impl Default for ProviderConfig {
fn default() -> Self {
Self {
base_url: String::new(),
api_key: String::new(),
model: String::new(),
timeout_secs: 30,
max_retries: 3,
}
}
}
impl ProviderConfig {
/// 从环境变量构造 ProviderConfig。
///
/// 必填变量:
/// - `{prefix}_BASE_URL`
/// - `{prefix}_API_KEY`
/// - `{prefix}_MODEL`
///
/// 可选变量(有默认值):
/// - `{prefix}_TIMEOUT_SECS`(默认 30
/// - `{prefix}_MAX_RETRIES`(默认 3
pub fn from_env(prefix: &str) -> Result<Self, String> {
let base_url = std::env::var(format!("{prefix}_BASE_URL"))
.map_err(|_| format!("{prefix}_BASE_URL 环境变量未设置"))?;
let api_key = std::env::var(format!("{prefix}_API_KEY"))
.map_err(|_| format!("{prefix}_API_KEY 环境变量未设置"))?;
let model = std::env::var(format!("{prefix}_MODEL"))
.map_err(|_| format!("{prefix}_MODEL 环境变量未设置"))?;
let timeout_secs = match std::env::var(format!("{prefix}_TIMEOUT_SECS")) {
Ok(v) => v.parse().unwrap_or_else(|_| {
tracing::warn!("{prefix}_TIMEOUT_SECS='{v}' 解析失败,使用默认值 30");
30
}),
Err(_) => 30,
};
let max_retries = match std::env::var(format!("{prefix}_MAX_RETRIES")) {
Ok(v) => v.parse().unwrap_or_else(|_| {
tracing::warn!("{prefix}_MAX_RETRIES='{v}' 解析失败,使用默认值 3");
3
}),
Err(_) => 3,
};
// ponytail: max_retries 当前仅采集,不传入 Provider。
// 实际重试由 CycleConfig.retry.max_retries 控制。
// 此 warn 在应用启动时通常只触发一次,多次调用 from_env 时
// 重复输出的风险低。如有噪声,可改用 std::sync::Once 控制。
if max_retries != 3 {
tracing::warn!(
"ProviderConfig.max_retries={} 已采集但当前未生效;\
重试次数由 CycleConfig.retry.max_retries 控制",
max_retries,
);
}
Ok(Self {
base_url,
api_key,
model,
timeout_secs,
max_retries,
})
}
}
```
#### Timeout 传导模式
`create_provider()` 中,对基于 `GenericOpenaiProvider` 的 ProviderOpenAI / DeepSeek / Qwen / Ollama),通过同一模式注入 timeout:构造带 timeout 的 `Client` 后调用 `with_client(client)`
所有 OpenAI-compatible 分支新增的 `with_client()` 公开方法:
| Provider | 方法 | 位置 |
|----------|------|------|
| `OpenaiChatProvider` | 已有 `with_client(Client) -> Self` | `openai.rs` |
| `DeepSeekProvider` | 新增 `with_client(Client) -> Self` | `openai_compat.rs` |
| `QwenProvider` | 新增 `with_client(Client) -> Self` | `openai_compat.rs` |
| `OllamaProvider` | 新增 `with_client(Client) -> Self` | `ollama.rs`(新建文件) |
**关于 `new_with_client` 的说明**`DeepSeekProvider``QwenProvider` 当前已有测试用的 `new_with_client(base_url, api_key, model, client)` 方法(通过 `inner.http_client = client` 直接写字段)。新增 `with_client` 后,`new_with_client` 应重构为 `Self::new(base_url, api_key, model).with_client(client)` 代理,统一走公开 API 路径。
代码示例(以 DeepSeek 为例,OpenAI/Qwen/Ollama 模式完全一致):
```rust
ProviderType::DeepSeek => {
let client = Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.build()
.map_err(|e| LlmError::Other(format!("创建 HTTP 客户端失败: {e}")))?;
Ok(Box::new(
openai_compat::DeepSeekProvider::new(
config.base_url,
config.api_key,
config.model,
)
.with_client(client),
))
}
```
Anthropic 由于需要保留 `default_headers`,使用独立的 `with_timeout` 模式:
AnthropicProvider 新增 `with_timeout` 方法:
```rust
impl AnthropicProvider {
/// 替换默认 HTTP Client 的超时配置。
///
/// ⚠️ 副作用:此方法**完全重建** `http_client`,调用后原有通过 `with_client`
/// 注入的 Client 将被替换。headers 逻辑与 `new()` 中的构造保持一致。
pub fn with_timeout(mut self, secs: u64) -> Result<Self, LlmError> {
// ponytail: 重建 http_client 时保留已有默认 headersx-api-key / anthropic-version)。
// 如后续 AnthropicProvider 的 headers 变为动态,此方法需同步更新。
let key_header = HeaderValue::from_str(&self.api_key)
.map_err(|_| LlmError::Other("Anthropic API key 包含无效的 HTTP 头部字符".into()))?;
let version_header = HeaderValue::from_static("2023-06-01");
self.http_client = Client::builder()
.timeout(Duration::from_secs(secs))
.default_headers({
let mut headers = HeaderMap::new();
headers.insert("x-api-key", key_header);
headers.insert("anthropic-version", version_header);
headers
})
.build()
.map_err(|e| LlmError::Other(format!("创建 Anthropic HTTP 客户端失败: {e}")))?;
Ok(self)
}
}
```
#### `map_reqwest_error` 中的硬编码超时修复
`openai.rs``anthropic.rs` 中的 `map_reqwest_error` 辅助函数当前在超时错误中返回硬编码的 `Duration::from_secs(120)`
```rust
// 现状 —— 硬编码 120s,与可配置 timeout 脱节
LlmError::Timeout { duration: Duration::from_secs(120) }
```
**修复方式**:采用**方案 A**——在 Provider struct 中存储 `timeout_secs` 字段,`map_reqwest_error` 读取该字段的值而非硬编码 120s。
```rust
// 修复后 —— 参数化,从 Provider 存储的 timeout_secs 读取
// GenericOpenaiProvider 新增 timeout_secs 字段:
pub struct GenericOpenaiProvider {
http_client: Client,
base_url: String,
api_key: String,
model: String,
provider_name: &'static str,
extra_headers: Vec<(String, String)>,
timeout_secs: u64, // ← 新增,由 new_with_name 的参数传入
}
// map_reqwest_error 使用 self.timeout_secs 而非硬编码 120
LlmError::Timeout { duration: Duration::from_secs(self.timeout_secs) }
```
**方案 B(从 reqwest::Client 提取 timeout)已被否决**`reqwest::Client` 不提供 timeout getter,无法从已构造的 client 中反向读取超时配置。
如果漏掉此修复,用户设置 `AG_LLM_TIMEOUT_SECS=60` 后超时,错误消息仍显示 "LLM 请求超时(120s",与实际配置不符。
---
#### max_retries 说明
`ProviderConfig.max_retries` 当前仅由 `from_env()` 采集存储,**实际重试操作由 `CycleConfig.retry.max_retries` 控制**。两者之间的关系通过文档注释声明:
```rust
/// 最大重试次数(默认 3)。当前此字段仅由 `from_env()` 采集,
/// 实际重试逻辑由 `CycleConfig.retry.max_retries` 控制。
/// 未来 Phase 6+ 可统一合并此字段到 CycleConfig。
```
#### 测试设计
使用 `temp_env` 在单元测试中隔离环境变量:
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_config_from_env_requires_all_vars() {
// 未设置任何变量时应返回 Err
let result = ProviderConfig::from_env("TEST_PROVIDER");
assert!(result.is_err());
}
#[test]
fn provider_config_from_env_uses_defaults() {
temp_env::with_vars([
("TEST_PROVIDER_BASE_URL", Some("http://localhost:11434/v1")),
("TEST_PROVIDER_API_KEY", Some("")),
("TEST_PROVIDER_MODEL", Some("llama3")),
], || {
let config = ProviderConfig::from_env("TEST_PROVIDER").unwrap();
assert_eq!(config.timeout_secs, 30);
assert_eq!(config.max_retries, 3);
});
}
#[test]
fn provider_config_from_env_reads_custom_timeout() {
temp_env::with_vars([
("TEST_PROVIDER_BASE_URL", Some("http://x")),
("TEST_PROVIDER_API_KEY", Some("k")),
("TEST_PROVIDER_MODEL", Some("m")),
("TEST_PROVIDER_TIMEOUT_SECS", Some("60")),
("TEST_PROVIDER_MAX_RETRIES", Some("5")),
], || {
let config = ProviderConfig::from_env("TEST_PROVIDER").unwrap();
assert_eq!(config.timeout_secs, 60);
assert_eq!(config.max_retries, 5);
});
}
}
```
## 4. 实现计划
### Step 5.2 — Ollama Provider~55 行)
| 步骤 | 操作 | 验证 |
|------|------|------|
| 1 | 创建 `src/llm/provider/ollama.rs`,实现 `OllamaProvider` newtype | 编译通过 |
| 2 | 在 `provider.rs` 注册 `pub mod ollama` | 编译通过 |
| 3 | `ProviderType` 新增 `Ollama` 变体 | 编译通过 |
| 4 | `FromStr``"ollama"` 解析 | 编译通过 |
| 5 | `create_provider()``Ollama =>` 分支 | 编译通过 |
| 6 | 运行 `cargo build` | 无错误 |
### Step 5.3 — `#[non_exhaustive]` 前置标记(~4 行)
| 步骤 | 操作 | 验证 |
|------|------|------|
| 1 | `ProviderType``provider.rs`)加 `#[non_exhaustive]` | 编译通过 |
| 2 | `StopReason``response_v2.rs`)加 `#[non_exhaustive]` | 编译通过 |
| 3 | `FinishReason``shared.rs`)加 `#[non_exhaustive]` | 编译通过 |
| 4 | `EvictionPolicy``memory/store.rs`)加 `#[non_exhaustive]` | 编译通过 |
| 5 | 运行 `cargo build --all-targets` | 无 warning |
### Step 5.1 — ProviderConfig 扩展(~60 行 + 测试)
| 步骤 | 操作 | 验证 |
|------|------|------|
| 1 | `ProviderConfig``timeout_secs` / `max_retries` 字段 | 编译通过 |
| 2 | 实现 `impl Default for ProviderConfig` | 编译通过 |
| 3 | 实现 `ProviderConfig::from_env()` | 编译通过 |
| 4 | `GenericOpenaiProvider``AnthropicProvider` 新增 `timeout_secs` 字段,`new_with_name``new()` 接受 timeout 参数 | 编译通过 |
| 5 | `map_reqwest_error` 在各 Provider 中改为从 `self.timeout_secs` 读取,移除硬编码 120s | 编译通过 |
| 6 | `create_provider()` 中各分支注入 timeoutOpenAI-compatible 用 `Client::builder().timeout()` + `with_client`Anthropic 用 `with_timeout()` | 编译通过 |
| 7 | `DeepSeekProvider``QwenProvider``new_with_client` 重构为 `Self::new(...).with_client(client)` 代理 | 测试通过 |
| 8 | `Cargo.toml` 添加 `temp_env` dev-dependency | `cargo build` 通过 |
| 8 | 添加 `from_env` 单元测试 + timeout 传导集成测试 | `cargo test` 通过 |
| 9 | 完整验证 | 见第 6 节 |
## 5. 风险评估
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|----------|
| `create_provider()``Client::builder().build()` 返回 `Result`,当前代码使用 `.expect()`,改为 `map_err` 转为 `LlmError` 后需确保所有分支正确转换 | 编译期强制处理,遗漏分支直接报错 | 低 | `create_provider` 返回 `Result<Box<dyn LlmProvider>, LlmError>``map_err` 天然适配。新增的 timeout 注入路径逐一检查 |
| `AnthropicProvider``default_headers``with_timeout` 中重建时与 `new()` 中的 headers 不一致 | Anthropic 认证失败 | 低 | `with_timeout` 方法复制 `new()` 中的 headers 构造逻辑。通过已有测试验证认证通过 |
| Ollama 实际运行时行为差异:版本兼容性、API 路径、模型名等 | 运行时才能发现 | 中 | Phase 5 仅做类型级验证(`cargo build`),Phase 8 端到端测试时通过 Ollama mock 或真实实例验证 |
| `max_retries` 存储了却未实际使用,造成困惑 | 开发者误以为已生效 | 中 | 通过文档注释明确声明 `max_retries` 当前仅采集,实际重试由 `CycleConfig.retry.max_retries` 控制 |
| `temp_env` 测试在多线程并发测试中互相污染环境变量 | 偶发测试失败 | 中(Rust 默认单线程测试用 `--test-threads=1` 可避免) | 将 `from_env` 测试控制在同一测试文件,避免并行执行。必要时在 CI 中确保 `--test-threads=1` |
## 6. 验收标准
以下条件**全部满足**方可认为 Phase 5 完成:
- [ ] `cargo build --all-targets` 通过,无错误
- [ ] `cargo test --all-targets` 通过,新增测试覆盖 `from_env` 的必填/选填/默认值场景
- [ ] `cargo clippy --all-targets -- -D warnings` 通过,无任何 warning
- [ ] `cargo doc --no-deps -D warnings` 通过,所有公共 API 有文档注释(`///`
- [ ] 新增文件:1`ollama.rs`
- [ ] 修改文件:9`provider.rs``openai.rs``anthropic.rs``openai_compat.rs``response_v2.rs``shared.rs``store.rs``Cargo.toml`、测试文件)
- [ ] 净代码增量:~160 行
- [ ] `ProviderType` 新增 `Ollama` 变体,`"ollama"` 字符串可解析
- [ ] 4 个公共枚举带有 `#[non_exhaustive]` 属性
- [ ] `ProviderConfig` 可从环境变量构造(`from_env()`),含默认值
- [ ] timeout 值已传导到 `create_provider()` 中各 Provider 的 HTTP Client 配置
- [ ] timeout 传导验证通过至少一个端到端 wiremock 集成测试(模拟 HTTP 服务在超时后返回 408,验证 Provider 返回 `LlmError::Timeout`
- [ ] `DeepSeekProvider``QwenProvider``OllamaProvider` 均有公开 `with_client()` 方法,可在 `create_provider` 中注入 timeout Client
- [ ] `map_reqwest_error` 中不再硬编码 `Duration::from_secs(120)`,改为参数化读取
+236
View File
@@ -0,0 +1,236 @@
# Phase 6 — ToolDefinition IR 正式化实施方案
## 背景与目标
在 agcore v0.2 路线图中,Phase 6 旨在引入 `ToolDef` 新类型,替换已标记 `#[deprecated(since = "0.1.0")]``ToolDefinition`(即 `OpenaiToolDefinition` 类型别名),消除 OpenAI wire format 对核心类型系统的泄漏,建立 Provider 无关的工具定义中间表示(IR)。
预期成果:
- 核心类型系统不再直接依赖 `OpenaiToolDefinition`
- 所有 Provider 适配层从统一的 `ToolDef` IR 出发,各自转换为对应 wire format
- 消除 `#[allow(deprecated)]` 抑制点,恢复 clippy 零警告状态
## 当前状态分析
当前代码库中工具定义相关的关键状态如下:
1. **`OpenaiToolDefinition` 结构体**定义于 `llm/types/tool.rs`,包含 4 个字段:
- `name: String`
- `description: Option<String>`
- `parameters: Value`
- `strict: Option<bool>`
当前存在多处 `#[allow(deprecated)]` 抑制点,分布在 `llm/cycle.rs``tools/registry.rs``tools/mcp.rs``agent/agent.rs` 等文件中。
2. **`ToolDefinition` 类型别名**定义于 `llm/types/mod.rs:105`,标记为 `#[deprecated]`
```rust
#[deprecated(since = "0.1.0", note = "use OpenaiToolDefinition directly")]
pub type ToolDefinition = OpenaiToolDefinition;
```
3. **`MessageRequest.tools` 字段**类型为 `Vec<OpenaiToolDefinition>`(直接引用原始类型,而非别名)。
4. **引用该类型的 4 个源文件**:
- `llm/cycle.rs`4 个方法参数使用 `Vec<ToolDefinition>`
- `tools/registry.rs``definitions() -> Vec<ToolDefinition>` 返回类型 + struct literal 构造(含 `strict: None`
- `tools/mcp.rs``list_tools() -> Vec<ToolDefinition>` 返回类型 + struct literal 构造(含 `strict: None`
- `agent/agent.rs``fn tool_definitions() -> Vec<ToolDefinition>` trait 默认实现
5. **Provider 适配层**`openai.rs` 和 `anthropic.rs` 从 `MessageRequest.tools` 读取数据并转换为各自的 wire format。`openai_compat.rs` 和 `ollama.rs` 委托给 `GenericOpenaiProvider`,无需直接改动。
## 需求推演
### 决策 1`strict` 字段的处理
当前所有构造路径均硬编码 `strict: None``registry.rs`、`mcp.rs`),`BaseTool` trait 无 `strict` 方法,用户 API 无法设置该值。
**结论:移除。** `strict` 是 OpenAI 的 Structured Outputs 专属字段,不属于 Provider 无关的 IR。未来如需支持,走 `MessageRequest.extra` 逃生舱,在各 Provider 适配层自行消费。
### 决策 2`description` 保持 `Option<String>`
Anthropic 要求 `description` 为必填(`String`),但 MCP 等来源可能缺失该字段。保持 `Option`,由 Anthropic 适配层以 `unwrap_or_default()` 兜底。
### 决策 3`parameters` 保持 `Value`
所有 Provider 的 wire format 均接受 JSON Schema 格式的 Value。当前不做 typed 方案,保留 `Value`。
### 决策 4:采用直接切断而非阶段性 deprecation
v0.1 已标记 `#[deprecated]`,用户已有预期。pre-1.0 阶段的 breaking change 是合理的。`OpenaiToolDefinition` 保留但降级为 `#[doc(hidden)]`。
## 方案设计
### ToolDef 结构体
位置:`src/llm/types/tool.rs`(与 `OpenaiToolDefinition` 同文件,不建独立文件/模块)。
```rust
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolDef {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub parameters: Value,
}
```
实现双向 `From` 转换:
```rust
impl From<ToolDef> for OpenaiToolDefinition {
fn from(t: ToolDef) -> Self {
Self {
name: t.name,
description: t.description,
parameters: t.parameters,
strict: None,
}
}
}
impl From<OpenaiToolDefinition> for ToolDef {
fn from(t: OpenaiToolDefinition) -> Self {
Self {
name: t.name,
description: t.description,
parameters: t.parameters,
}
}
}
```
serde 属性与 `OpenaiToolDefinition` 原有属性一致,保证 JSON 序列化兼容。
明确不做:
- builder 模式(Rust struct literal + `..Default::default()` 已足够)
- `#[non_exhaustive]`(IR 类型自有完整控制权,不需要)
- 独立文件(7 行 struct 无需独立模块)
### 4 单元切割计划
每步设计为可编译的安全 checkpoint。
#### 单元 6.1 — 新增 ToolDef + From 实现
| 项目 | 内容 |
|------|------|
| 涉及文件 | `llm/types/tool.rs` |
| 变更内容 | 新增 `ToolDef` struct(约 7 行)、2 个 `From` impl(约 12 行) |
| 验证标准 | `cargo build` 编译通过(旧代码照常编译,零影响) |
| 检查点 | 新类型存在但未被消费,安全 checkpoint |
#### 单元 6.2 — 别名切换 + 构造同步修复
| 项目 | 内容 |
|------|------|
| 涉及文件 | `llm/types/mod.rs`、`llm/types/request_v2.rs`、`llm/types/request.rs`、`tools/registry.rs`、`tools/mcp.rs` |
| 变更内容 | 切换别名 `pub type ToolDefinition = ToolDef``MessageRequest.tools` 改为 `Vec<ToolDef>`registry/mcp 构造去掉 `strict: None` |
| 验证标准 | `cargo build` 编译通过 |
| 风险提示 | `cycle.rs` 方法参数使用别名,自动生效无需修改;`agent/agent.rs` trait 默认实现使用别名,自动适配;暂不移除 `#[allow(deprecated)]` |
#### 单元 6.3 — Provider 适配
| 项目 | 内容 |
|------|------|
| 涉及文件 | `llm/provider/openai.rs` |
| 变更内容 | `convert_request()` 中 `tool_defs.into_iter().map(|t| OpenaiTool::Function { function: t })` → 改为 `.map(|t| OpenaiTool::Function { function: t.into() })``t` 类型从 `OpenaiToolDefinition` 变为 `ToolDef`,需 `Into` 转换 |
| 验证标准 | `cargo test --all-targets` 全部通过 |
| 不修改的文件 | `anthropic.rs`(同名字段访问自动适配)、`openai_compat.rs`/`ollama.rs`(委托给 `GenericOpenaiProvider` |
#### 单元 6.4 — 清理
| 项目 | 内容 |
|------|------|
| 涉及文件 | `llm/cycle.rs`、`tools/registry.rs`、`tools/mcp.rs`、`agent/agent.rs`、`llm/types/mod.rs`、`llm/types/tool.rs` |
| 变更内容 | 移除所有与 `ToolDefinition` 相关的 `#[allow(deprecated)]``llm/types/mod.rs` 移除旧 `#[deprecated]` 别名(仅保留 `pub use tool::ToolDef`);`OpenaiToolDefinition` 降级为 `#[doc(hidden)]`。精确列表由 `cargo clippy -D warnings` 检出——clippy 会标记所有不再需要的 `#[allow]` |
| 验证标准 | `cargo clippy --all-targets -- -D warnings` 零警告;`cargo test --all-targets` 全部通过 |
| 无需改动 | 测试代码(无一直接引用 `ToolDefinition` |
### Provider 适配策略
Provider 适配层改动最小化,仅在序列化入口处加一层 `From` 转换:
| Provider | 适配方式 | 改动 |
|----------|---------|------|
| OpenAI`GenericOpenaiProvider` | `MessageRequest.tools: Vec<ToolDef>` → lambda 内改为 `.map(\|t\| OpenaiTool::Function { function: t.into() })`,将 `ToolDef` 通过 `Into` 转为 `OpenaiToolDefinition` | `convert_request` lambda 内 +`.into()` |
| Anthropic | `t.name` / `t.description` / `t.parameters` 字段名不变,直接访问 | 零改动 |
| OpenAI CompatDeepSeek、Qwen | 委托给 `GenericOpenaiProvider` | 零改动 |
| Ollama | 委托给 `GenericOpenaiProvider` | 零改动 |
### 变更清单汇总
| 文件 | 改动类型 | 估计行数 |
|------|---------|---------|
| `llm/types/tool.rs` | +`ToolDef` + 2x `From` | +19 |
| `llm/types/mod.rs` | 改别名 + re-export | ~3 |
| `llm/types/request_v2.rs` | 改 `tools` 字段 + import | ~2 |
| `llm/cycle.rs` | 移 `#[allow(deprecated)]` | -1 |
| `tools/registry.rs` | 构造去掉 `strict` + 移 `allow` | ~4 |
| `tools/mcp.rs` | 构造去掉 `strict` + 移 `allow` | ~4 |
| `agent/agent.rs` | 移 `#[allow(deprecated)]` | -1 |
| `llm/provider/openai.rs` | `convert_request` 加 `.map(Into::into)` | +2 |
| 新增 roundtrip 测试 | `MessageRequest` 序列化 roundtrip 验证 | +15 |
| **合计** | | **约 47 行(+ 约 15 行测试)** |
### 测试策略
新增一条 `MessageRequest` 序列化 roundtrip 测试,覆盖 `ToolDef` 的 JSON 序列化/反序列化兼容性。该测试验证 `ToolDef` 的 serde 属性与 `OpenaiToolDefinition` 一致,确保 wire format 兼容。
## 实施步骤
1. 新建分支 `phase-6-tooldef-ir`
2. 按单元 6.1 → 6.2 → 6.3 → 6.4 顺序执行,每步提交一个 commit
3. 每步执行对应的验证标准
4. 全量通过后创建 PR
```
git checkout -b phase-6-tooldef-ir
# 执行单元 6.1 → commit
# 执行单元 6.2 → commit
# 执行单元 6.3 → commit
# 执行单元 6.4 → commit
# 全量验证
```
### 用户迁移指引
Phase 6 涉及公共 API 类型替换,下游用户升级到 v0.2 时需注意:
| 旧用法 | 新用法 |
|--------|--------|
| `use agcore::llm::types::ToolDefinition` | `use agcore::llm::types::ToolDef`(别名已移除) |
| `use agcore::llm::types::OpenaiToolDefinition` | `use agcore::llm::types::ToolDef``OpenaiToolDefinition` 已降级为 `#[doc(hidden)]` |
| 直接构造 `ToolDefinition { strict: None, .. }` | 构造 `ToolDef { .. }`(去掉 `strict` 字段) |
`OpenaiToolDefinition` 仍保留但标记 `#[doc(hidden)]`,极端情况仍需使用时可通过全路径访问。
## 验证标准
| 阶段 | 验证命令 |
|------|---------|
| 单元 6.1 | `cargo build` 编译通过 |
| 单元 6.2 | `cargo build` 编译通过(新旧代码全量编译) |
| 单元 6.3 | `cargo test --all-targets` 全部通过 |
| 单元 6.4 | `cargo clippy --all-targets -- -D warnings` 零警告;`cargo test --all-targets` 全部通过 |
| 最终 | `cargo build --all-targets` + `cargo test --all-targets` + `cargo clippy --all-targets -- -D warnings` 全绿 |
## 风险与缓解
| 风险 | 说明 | 缓解措施 |
|------|------|---------|
| Struct literal 断层 | Step 6.2 切别名与构造修复若不同步,registry/mcp 中使用 `OpenaiToolDefinition` struct literal 的构造代码会编译失败 | 别名切换与构造修复合并在同一单元,原子化提交 |
| 遗漏 `#[allow(deprecated)]` | 部分抑制点因 grep 遗漏而未在 6.4 移除 | clippy `-D warnings` 可检出;6.4 前做一次全库 grep 确认无遗漏 |
| JSON 兼容性 | `ToolDef` serde 属性与 `OpenaiToolDefinition` 不一致导致 wire format 变化 | `ToolDef` serde 属性与 `OpenaiToolDefinition` 保持一致;roundtrip 测试验证 |
| Provider 适配遗漏 | 部分 Provider 分支未经测试覆盖 | `cargo test --all-targets` 包含 Provider 测试 |
## 否决记录
| 否决方案 | 原因 |
|---------|------|
| 保留 `strict` 字段 | OpenAI 专属字段,当前所有构造路径传 `None`。不属于 Provider 无关的 IR。未来支持走 `MessageRequest.extra` 逃生舱 |
| 逐步 deprecation 过渡 | pre-1.0 阶段 breaking change 合理,v0.1 已标记 deprecation,用户已有预期 |
| `ToolDef` 建独立文件 | 约 7 行的 struct 不需要独立文件,与 `OpenaiToolDefinition` 共享 `types/tool.rs` 即可 |
| `ToolDef` 放在 `tools/` 模块 | 会创造 `llm` → `tools` 的逆向依赖,破坏模块分层 |
| 添加 builder 模式 | Rust struct literal + `..Default::default()` 已足够覆盖使用场景 |
| 添加 `#[non_exhaustive]` | IR 类型自有完整控制权,不需要对外隐藏字段 |
| 同 Phase 净化 `parameters` 类型化 | 属于独立工作,留给 v0.3+ 阶段处理 |
+526
View File
@@ -0,0 +1,526 @@
# Phase 7 — SqliteStore 持久化实现方案
- **文档编号**14
- **标题**Phase 7 — SqliteStore 持久化实现方案
- **日期**2026-07-05
- **状态**:已定稿
- **涉及模块**memory/store
- **关联文档**roadmap.md, 6-memory-system.md
---
## 背景与目标
Phase 7 的核心任务是完成 MemoryStore trait 的 SQLite 后端实现,使 Agent 进程重启后记忆数据不丢失。这是 v0.2.0 从"内存玩具"走向"可用工具"的关键门槛,也是后续 Phase 8MVP 出口)和 Phase 10ContextSlot)的前置依赖。
**成功标准**
- SqliteStore 完整实现 MemoryStore trait4 个方法:save/get/delete/list
- 进程关闭后重新打开同一数据库文件,数据完整可读
- 与现有 InMemoryStore 通过 MemoryStore trait 可互换,消费者零改动
- 所有现有测试保持通过,clippy 0 警告
### Scope & Non-goals
| 范围 | 内容 |
|------|------|
| 包含 | 单表 CRUD + prefix/since/offset/limit 查询 + WAL 并发 + Mutex 串行化 + 错误映射 |
| 不包含(Phase 7 | 淘汰策略(EvictionPolicy,仅 InMemoryStore 持有,需 v0.3 纳入 SqliteStore |
| 不包含(Phase 7 | Schema 迁移框架(PRAGMA user_version 足矣,不引入 refinery/sea-query |
| 不包含(Phase 7 | 批量写入 / 事务 API(N+1 clear 延迟可接受,优化后置) |
| 不包含(Phase 7) | 跨进程共享同一数据库文件(Mutex 为单进程设计) |
---
## 当前状态分析
### 现有实现
- MemoryStore trait 已在 v0.1 Phase 3 就绪,定义 4 个异步方法
- InMemoryStore 实现稳定运行,使用 `Mutex<HashMap>` 作为后端
- 全量测试 191 个通过,clippy 0 警告
- 项目当前无 SQLite 或其他数据库依赖
### 现有消费者
通过 `crate::memory::store::MemoryStore` 路径引用的模块:
| 模块 | 文件 | 使用方式 |
|------|------|----------|
| Agent Builder | `agent/builder.rs` | RuntimeBundle 中引用 MemoryStore |
| Session Memory | `agent/session_memory.rs` | SessionMemory 实现 |
| Agent Runtime | `agent/runtime.rs` | 类型标注 |
| Agent Session | `agent/session.rs` | 默认 InMemoryStore 兜底 |
| Conversation | `memory/conversation.rs` | ConversationMemory 测试 |
| Knowledge | `memory/knowledge.rs` | KnowledgeStore 测试 |
| Retriever | `memory/retriever.rs` | MemoryRetriever 测试 |
所有消费者均通过 `MemoryStore` trait 访问,不依赖具体实现类型,因此新增 SqliteStore 不会产生编译或运行时影响。
### 目录结构现状
```
src/memory/
├── mod.rs
├── store.rs ← 包含 MemoryStore trait + InMemoryStore + EvictionPolicy
├── conversation.rs
├── knowledge.rs
├── retriever.rs
└── vector.rs
```
`store.rs` 目前是一个单体文件,同时承载 trait 定义和 InMemoryStore 实现。
---
## 调研发现
### MemoryStore trait 定义
```rust
#[async_trait]
pub trait MemoryStore: Send + Sync {
async fn save(&self, item: MemoryItem) -> Result<(), MemoryError>;
async fn get(&self, id: &str) -> Result<Option<MemoryItem>, MemoryError>;
async fn delete(&self, id: &str) -> Result<(), MemoryError>;
async fn list(&self, filter: &MemoryFilter) -> Result<Vec<MemoryItem>, MemoryError>;
}
```
### 关键类型
| 类型 | 定义 |
|------|------|
| `MemoryItem` | `{ id: String, content: String, metadata: Value, created_at: OffsetDateTime }` |
| `MemoryFilter` | `{ prefix: Option<String>, since: Option<OffsetDateTime>, offset: Option<usize>, limit: Option<usize> }` |
| `MemoryError` | 变体:`NotFound` / `Storage` / `Serialization` / `InvalidInput` / `RetrievalError` |
| `EvictionPolicy` | `None` / `Ttl { ttl_secs }` / `Capacity { max_items }` |
| `EvictionConfig` | `{ policy, check_interval }` |
### 并发模型参考
InMemoryStore 当前使用 `Mutex<HashMap>` 实现 `Send + Sync`。SqliteStore 将遵循相同模式,使用 `Arc<Mutex<Connection>>` + `spawn_blocking` 满足异步 trait 约束。
### Schema 设计考虑
- `created_at` 使用 TEXT(ISO 8601) 存储——`.to_string()` 零转换,字典序与时间序一致(前提:所有时间戳归一化到 UTC;`OffsetDateTime::to_string()` 在 UTC 下输出 `"2026-07-05T12:00:00Z"` 格式,字典序与时间序严格对应)
- 初始 schema 即创建 `created_at` 索引,避免后续大数据量全表排序
- Schema 版本管理通过 `PRAGMA user_version` 实现,零外部依赖,后续加字段只需追加 `if version < N { ALTER TABLE }`
---
## 可选方案
### A. rusqlite + Mutex\<Connection\>(推荐)
| 维度 | 评估 |
|------|------|
| 新增依赖 | 1 个(rusqlite 0.32 + bundled features |
| 实现量 | ~200 行 |
| SQL 支持 | 原生支持 prefix LIKE 过滤 + ORDER BY 排序 |
| 性能 | 有索引时查询 O(log n),写入串行化 |
| 并发 | WAL 模式 + Mutex 串行化写入,适合单进程 Agent |
| 事务支持 | 完整 ACID |
| 崩溃安全 | WAL 模式,崩溃恢复有保障 |
**适用场景**:单进程 Agent 本地持久化、嵌入式场景、需要关系查询能力的通用存储。
**外部依赖评估**
- rusqlite 0.32 — 最新稳定版(2025-12 发布),维护活跃(月均 2+ 次提交),Apache-2.0 许可证
- `bundled` feature 编译 SQLite 源码(Public Domain)进二进制,无系统级 SQLite 依赖,零外部 C 库安装步骤
- 供应链风险:bundled 模式依赖 crate 发布节奏同步 SQLite 安全更新;SQLite 安全公告频率极低(年均 <3 例),此风险可接受
### B. JSONL 文件
| 维度 | 评估 |
|------|------|
| 新增依赖 | 0 |
| 实现量 | ~150 行 |
| get() 复杂度 | O(n) 全量扫描 |
| delete() 复杂度 | O(n) 全量重写 |
| 并发 | 需文件锁(flock |
| 事务支持 | 无 |
| 崩溃安全 | 无保障,写入中断可能丢失或损坏数据 |
**否决理由**:核心的 get() 查询场景不可接受 O(n) 性能;在 Agent 运行时频繁读写记忆的场景下,全量扫描的成本会随着数据积累线性增长,不符合可用性要求。
### C. sled 嵌入式 KV
| 维度 | 评估 |
|------|------|
| 新增依赖 | 1 个(纯 Rust |
| 实现量 | ~150 行 |
| prefix scan | 原生支持 |
| 排序 | 需手动实现 |
| 关系模型 | 不如 SQL 匹配当前查询模式 |
| 社区成熟度 | 较新,API 仍在演进 |
**否决理由**:当前查询模式(prefix 过滤 + 按 created_at 排序)在关系模型中用一条 SQL 即可表达,引入 KV 存储反而需要手动处理排序逻辑。非必要不引入新存储范式。
---
## 推荐方案
### 总体方向:方案 Arusqlite + Mutex\<Connection\>
选择理由:
1. **最少依赖,最高匹配**:1 个新增依赖即可完整支持 MemoryFilter 的所有查询维度(prefix LIKE、created_at 范围、offset/limit
2. **生产就绪**rusqlite 是 SQLite 的 Rust 绑定事实标准,bundled 模式免去系统 SQLite 依赖
3. **Schema 演进简单**PRAGMA user_version + 逐版本迁移,零外部迁移工具依赖
4. **与 InMemoryStore 语义一致**Mutex 串行化 + spawn_blocking 适配 async trait,与现有并发模型同构
### 关键设计决策
| 决策 | 选择 | 理由 |
|------|------|------|
| Schema 版本管理 | PRAGMA user_version | 零外部依赖,~20 行,后续 ALTER TABLE 即可 |
| created_at 存储格式 | TEXT(ISO 8601) + UTC 归一化 | 零转换代码;UTC 下输出 `"2026-07-05T12:00:00Z"`,字典序与时间序严格一致 |
| Upsert SQL 策略 | `INSERT ... ON CONFLICT(id) DO UPDATE SET ...` | 保留调用方传入的 `created_at`,避免被 `DEFAULT` 覆盖 |
| 性能索引 | 初始 schema 加 created_at 索引 | 避免大数据量全表排序 |
| 配置参数 | 仅 `path``busy_timeout=5s` | 其余内置默认值;5s 超时避免 `SQLITE_BUSY` 快速失败 |
| Mutex 中毒恢复 | `.lock().unwrap_or_else(\|e\| e.into_inner())` | 不 panic,恢复执行 |
| 批量操作 | 不加 | N+1 clear ~250ms(N=50),可接受,优化后置 |
| spawn_blocking 取消安全性 | 短事务模式(auto-commit) | 每个操作独立事务,取消时后台 task 自然完成/panic,不 Cross 操作持有 Mutex |
**我们放弃了什么**(集中 Trade-off 记录):
- **写入串行化**`Mutex<Connection>` 确保 SQLite 写入安全,代价是同一时刻只能有一个写入者。Agent 场景下写入频率低(每次 LLM 调用触发 1-2 次),串行化不构成瓶颈
- **单进程锁**:无法跨进程共享同一数据库文件。多进程场景需要网络后端(PostgreSQL/Redis
- **无横向扩展**:单文件 SQLite 无分片能力。需扩展时切换到分布式后端
### Schema 定义(初始版本)
```sql
CREATE TABLE IF NOT EXISTS memory_items (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_memory_items_created_at
ON memory_items(created_at);
```
### 数据完整性防御
| 异常场景 | 防御措施 | 错误映射 |
|---------|---------|---------|
| 数据库文件损坏 | `migrate()` 中执行 `PRAGMA quick_check`;失败时 `open()` 返回 `MemoryError::Storage` | `Storage` |
| created_at 解析失败 | `get()`/`list()``OffsetDateTime::parse` 失败不 panic,返回 `MemoryError::Serialization` | `Serialization` |
| content / metadata 为 NULL | `get()` 中检测 SQLite 返回值,NULL 时返回 `MemoryError::Storage` | `Storage` |
| 约束冲突(PRIMARY KEY / NOT NULL | 映射为 `MemoryError::InvalidInput` | `InvalidInput` |
| 序列化/反序列化失败 | `serde_json::to_string`/`from_str` 错误映射为 `MemoryError::Serialization` | `Serialization` |
### 性能预算(目标延迟,单条操作)
| 操作 | 目标延迟 | 说明 |
|------|---------|------|
| `save(1KB item)` | < 5ms | 含 serde_json 序列化 + spawn_blocking + SQLite INSERT |
| `get(1KB item)` | < 3ms | 含 SQLite SELECT + 反序列化 |
| `list(prefix 匹配 100 行)` | < 20ms | 含索引 B-tree 遍历 + ORDER BY + LIMIT |
| 并发 10 writer | p99 < 50ms | Mutex 串行化排队,每 writer 等待 9×5ms 内 |
实施后通过 Step C 测试验证以上预算。未达标时不阻塞发布,但记录为可观测告警阈值。
---
## 实施建议
### 实施计划
#### Step A — 目录重构(纯搬移,零行为变化)
目标:将单体 `store.rs` 拆分为模块目录架构,为新增 SqliteStore 做准备。
```
src/memory/
├── store.rs ← 模块根:MemoryStore trait + EvictionPolicy/EvictionConfig
│ + pub mod in_memory;
│ + pub mod sqlite_store;
│ + pub use in_memory::InMemoryStore;
├── store/
│ ├── in_memory.rs ← InMemoryStore 提取至此(struct + impl + 6 个内联测试)
│ └── sqlite_store.rs ← 新增 SqliteStore
```
模式参考:`llm/provider.rs``llm/provider/{openai,anthropic,ollama}.rs`
**外部消费者的导入路径不变**`crate::memory::store::MemoryStore`),零改动风险。
重构步骤:
1. 创建 `src/memory/store/` 目录
2. 创建 `src/memory/store/in_memory.rs`,从原 `store.rs` 提取 InMemoryStore 全部代码(struct + impl + Default + 6 个测试)
3. 修改 `src/memory/store.rs`:保留 MemoryStore trait + EvictionPolicy/EvictionConfig,加 `pub mod in_memory;` + `pub use in_memory::InMemoryStore;`
4. 验证:`cargo test --all-targets` 全绿,测试数量不变(191 pass)
#### Step B — SqliteStore 实现
1. `Cargo.toml` 添加 `rusqlite = { version = "0.32", features = ["bundled"] }`
2. 创建 `src/memory/store/sqlite_store.rs`,实现:
- `SqliteStore` 结构体:`{ conn: Arc<Mutex<Connection>> }`
- `SqliteStore::open(path)` 构造函数,支持 `":memory:"`
- `lock_conn()` 辅助方法(Mutex 中毒恢复)
- `migrate()` Schema 初始化 + 版本管理
- `MemoryStore` trait 的 4 个方法
- `From<rusqlite::Error> for MemoryError`
3. 修改 `src/memory/store.rs`:加 `pub mod sqlite_store;` + `pub use sqlite_store::SqliteStore;`
4. 修改 `src/memory.rs`:加 `pub use store::SqliteStore;`
5. 编写测试覆盖:
- CRUD 基本操作
- Upsert(同 id 重复 save 覆盖)
- prefix 过滤
- 由于/until 时间范围过滤
- 并发 10 个 writer × 10 次操作
- 持久化恢复(write → drop → reopen → read
6. 验证:`cargo test --all-targets` 全绿 + `cargo clippy --all-targets -- -D warnings` 0 警告
#### Step C — 验证确认
1. 确认现有 memory 模块内测试全部通过
2. 确认 agent/llm/tools/prompt 模块不受影响
3. 确认 SqliteStore 与 InMemoryStore 通过 MemoryStore trait 可互换
4. 确认 clippy 无新增警告
### Commit 安排
| 顺序 | 类型 | Scope | 描述 |
|------|------|-------|------|
| 1 | refactor | memory | 将 store.rs 拆分为模块目录,仅结构搬移 |
| 2 | feat | memory | 实现 SqliteStore 持久化 |
### 风险与缓解
| 风险 | 严重度 | 缓解措施 |
|------|--------|----------|
| Mutex 中毒导致后续操作全部失败 | 中 | `lock_conn()` 使用 `.lock().unwrap_or_else(\|e\| e.into_inner())` 恢复模式,不 panic |
| spawn_blocking 取消后连接状态不一致 | 中 | 每个操作使用短事务(auto-commit),不跨操作持有 Mutex;取消时遗留 task 自然完成或 panicMutex 通过 `.into_inner()` 恢复 |
| WAL 文件无限增长 | 低 | 内置 auto-checkpoint 阈值 + 启动时执行 `PRAGMA wal_checkpoint(TRUNCATE)` |
| list 无索引导致全表扫描 | 中(大数据量) | 初始 schema 即创建 `idx_memory_items_created_at` 索引 |
| 父目录不存在导致 open 失败 | 低 | `open()` 内部调用 `fs::create_dir_all()` 确保目录存在 |
| clear() N+1 删除性能 | 低 | 不走 trait 接口的逐条删除,可后续优化为直接 `DELETE FROM memory_items` |
| 数据库文件损坏 | 低 | `migrate()` 中执行 `PRAGMA quick_check`;失败时返回 `MemoryError::Storage`,调用方可切换 InMemoryStore |
### 可观测性(实施时落实)
- 所有 MemoryStore 方法通过 `tracing::instrument` 记录延迟和结果(`info!` 正常完成,`warn!` 超过性能预算阈值,`error!` 操作失败)
- `list()` 返回行数通过 `tracing::debug` 记录(调优参考)
- WAL 文件大小在 `migrate()` 后检查一次,超过 100MB 时记录 `warn!`
- 操作计数(读写次数、错误率)暂不暴露为独立 metrics,v0.3 按需添加
---
## 已知假设
| 假设 | 验证状态 | Fallback |
|------|---------|----------|
| 单进程独享 SQLite 文件,无跨进程竞争 | ✅ 设计前提(Mutex 为单进程设计) | 多进程场景使用网络后端(PostgreSQL/Redisv0.3+ |
| ISO 8601 TEXT 字典序等价于时间序 | ✅ 条件成立(需 UTC 归一化) | 若时区异常,切换 INTEGER(unix_timestamp) 存储后重建索引 |
| SqliteStore 初始化失败可 fallback 到 InMemoryStore | ✅ 调用方自行控制 | `open()` 返回 `MemoryError`,消费者 catch 后改用 `InMemoryStore::new()` |
| N+1 clear ~250ms(N=50) 可接受 | 🟡 未实测(基于 N×5ms 推算) | 若成为瓶颈,SqliteStore 内部加 `delete_by_prefix()` 方法(不走 trait 接口) |
| rusqlite bundled SQLite 版本足够新 | ✅ 0.32 版内置 SQLite 3.46 | 如需特定版本,切换 `bundled` 为指定版本或使用系统 SQLite |
| busy_timeout=5s 覆盖所有竞争场景 | 🟡 未实测(WAL 下写写冲突概率低) | 若观测到 `SQLITE_BUSY`,增大超时或在重试逻辑中处理 |
| spawn_blocking 线程池不会被耗尽 | ✅ 默认 512 线程,Agent 场景占用 ≤10 | 若观测到阻塞任务排队,启动时 `tokio::task::spawn_blocking` 已有兜底排队机制 |
---
## 参考来源
- [rusqlite crate](https://crates.io/crates/rusqlite) — 官方文档
- [SQLite PRAGMA user_version](https://www.sqlite.org/pragma.html#pragma_user_version) — Schema 版本管理机制
- [SQLite WAL mode](https://www.sqlite.org/wal.html) — 并发读写性能优化
- `docs/6-memory-system.md` — Phase 3 MemoryStore trait 原始设计
- `docs/roadmap.md` — 项目里程碑规划(Phase 7/8/10 依赖关系)
- `src/llm/provider.rs``src/llm/provider/` — 目录重构模式参考
---
## 实施计划
### 任务总览
3 个阶段、8 个任务单元、2 个 Commit。
### 阶段一:目录重构
#### Task A1 — 创建 store/ 目录并提取 InMemoryStore
| 项目 | 内容 |
|------|------|
| 任务描述 | 创建 `src/memory/store/` 目录,新建 `store/in_memory.rs`,从 `store.rs` 完整提取 InMemoryStore 结构体、impl MemoryStore、impl Default、6 个内联测试 |
| 涉及文件 | `src/memory/store.rs` → 分割到 `src/memory/store/in_memory.rs`(新增) |
| 前置依赖 | 无 |
| 预估工作量 | S< 1h |
| 风险等级 | 低 — 纯搬移,编译器可验证 |
| 验收条件 | `cargo build` 通过(此时 store.rs 尚未修改,store/in_memory.rs 应被 crate 忽略) |
注意:需要先在 store.rs 顶部添加 `pub mod in_memory;` 声明,否则子模块不会被编译。或者可以先创建目录和文件,等 Task A2 再统一加声明路径。
实际做法:先创建文件但不声明,A2 统一声明。这样 A1 和 A2 之间可以有一个无编译的中间状态。
#### Task A2 — 修改 store.rs 模块根
| 项目 | 内容 |
|------|------|
| 任务描述 | 修改 `store.rs` 为纯模块根:保留 `MemoryStore` trait、`EvictionPolicy``EvictionConfig`;添加 `pub mod in_memory;` + `pub use in_memory::InMemoryStore;`;删除已提取到 in_memory.rs 中的代码 |
| 涉及文件 | `src/memory/store.rs`(修改) |
| 前置依赖 | Task A1(文件已存在) |
| 预估工作量 | S< 1h |
| 风险等级 | 低 — 保留部分不变,提取部分在子模块中 |
| 验收条件 | `cargo test --all-targets` 全绿,测试数量不变(191 pass),clippy 0 warning |
#### Task A3 — 验证阶段一
| 项目 | 内容 |
|------|------|
| 任务描述 | 运行全量测试链确认目录重构零行为变化 |
| 涉及文件 | 全量 |
| 前置依赖 | Task A2 |
| 预估工作量 | XS(验证) |
| 风险等级 | 低 |
| 验收条件 | `cargo test --all-targets` 191 pass、`cargo clippy --all-targets -- -D warnings` 0 警告、`cargo build` 通过 |
### 阶段二:SqliteStore 实现
#### Task B1 — 添加 rusqlite 及 dev-dependencies
| 项目 | 内容 |
|------|------|
| 任务描述 | 在 `Cargo.toml` 中添加依赖:`[dependencies]``rusqlite = { version = "0.32", features = ["bundled"] }``[dev-dependencies]``tempfile = "3"`(用于测试隔离);运行 `cargo build` 确认编译通过,`cargo test --no-run` 验证 dev-dependencies 可用 |
| 涉及文件 | `Cargo.toml`(修改)、`Cargo.lock`(自动更新) |
| 前置依赖 | 无(可与阶段一并行) |
| 预估工作量 | XS< 15min |
| 风险等级 | 低 — 标准依赖添加 |
| 验收条件 | `cargo build` 成功,`cargo test --no-run` 成功,Cargo.lock 中生成 rusqlite 和 tempfile 条目 |
#### Task B2 — 实现 SqliteStore 核心
| 项目 | 内容 |
|------|------|
| 任务描述 | 创建 `src/memory/store/sqlite_store.rs`,实现以下 8 个子模块: |
| | 1`SqliteStore` 结构体 `{ conn: Arc<Mutex<Connection>> }` |
| | 2`SqliteStore::open(path)` — 支持 `":memory:"`,内部调用 `fs::create_dir_all` 确保父目录存在 |
| | 3`lock_conn()` — 内部辅助方法,`.lock().unwrap_or_else(\|e\| e.into_inner())` 处理 Mutex 中毒 |
| | 4`migrate()` — 按版本递增执行迁移:`PRAGMA user_version` 检查(初始版本号=1)→ 建表 `memory_items` + 索引 `idx_memory_items_created_at` + 设置 WAL 模式 + `busy_timeout=5s` + `PRAGMA synchronous = NORMAL` + `PRAGMA wal_autocheckpoint=1000` + `PRAGMA quick_check`(检测数据库损坏)+ `PRAGMA wal_checkpoint(TRUNCATE)` |
| | 5`MemoryStore` trait 的 4 个方法实现(save/get/delete/list),全部使用 spawn_blocking 包裹;**生命周期注意**:`spawn_blocking` 闭包前先 `.conn.clone()``Arc<Connection>`,参数调 `.clone()` 取 owned 值,再传入 `spawn_blocking(move \|{ ... })` |
| | — `save`: `INSERT INTO memory_items (id, content, metadata, created_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(id) DO UPDATE SET content=excluded.content, metadata=excluded.metadata, created_at=excluded.created_at`(全字段覆盖 upsert,与 InMemoryStore 行为一致) |
| | — `get`: `SELECT content, metadata, created_at FROM memory_items WHERE id = ?1` → Ok(None) 当无结果 |
| | — `delete`: `DELETE FROM memory_items WHERE id = ?1`(幂等,不返回 NotFound |
| | — `list`: 根据 filter 字段(prefix/since/offset/limit)组合动态构造 WHERE 子句 + `ORDER BY created_at ASC` + `LIMIT ? OFFSET ?`,全部使用参数化查询防注入 |
| | 6)序列化转换层:`time::OffsetDateTime` 存为 TEXT(ISO 8601),通过 `.to_string()` 绑定 `String` 参数;`serde_json::Value` 存为 TEXT,通过 `serde_json::to_string()` 绑定 `String` 参数;读取时通过 `OffsetDateTime::parse``serde_json::from_str` 反序列化。在 SqliteStore 内部实现 `to_sql_params()` / `from_sql_row()` 辅助方法集中处理 |
| | 7)错误映射:不在 blanket impl From 中处理所有 rusqlite Error,而是在每个方法内部按数据完整性防御表的映射规则逐类处理: |
| | — 数据库文件损坏 / IO 错误 → `MemoryError::Storage` |
| | — created_at 解析失败 → `MemoryError::Serialization`(不 panic |
| | — content/metadata 为 NULL → `MemoryError::Storage` |
| | — serde_json 序列化/反序列化失败 → `MemoryError::Serialization` |
| | — 约束冲突 → `MemoryError::InvalidInput` |
| | (8)可观测性:在 4 个 trait 方法和 `open()` 上添加 `#[tracing::instrument(skip(self))]`;正常完成记录 `trace!`,超过性能预算阈值记录 `warn!`,操作失败记录 `error!` |
| 涉及文件 | `src/memory/store/sqlite_store.rs`(新增) |
| 前置依赖 | Task B1rusqlite + tempfile 依赖)、Task A1store/ 目录存在) |
| 预估工作量 | M1-4h |
| 风险等级 | 高 — 3 个技术点需留意:`time::OffsetDateTime``rusqlite::ToSql` 实现,需显式处理 String 绑定;`spawn_blocking` + `&self` 生命周期需 clone 后才能传闭包;错误映射需精细匹配 `rusqlite::Error` 嵌套变体(`SqliteFailure` 内含 `ErrorCode` |
| 验收条件 | 单元测试通过(见 Task B4)、`cargo build` 通过 |
#### Task B3 — 注册模块并重导出
| 项目 | 内容 |
|------|------|
| 任务描述 | 在 `store.rs` 添加 `pub mod sqlite_store;` + `pub use sqlite_store::SqliteStore;`;在 `memory.rs` 添加 `pub use store::SqliteStore;` |
| 涉及文件 | `src/memory/store.rs`(修改)、`src/memory.rs`(修改) |
| 前置依赖 | Task B2sqlite_store.rs 文件存在) |
| 预估工作量 | XS< 15min |
| 风险等级 | 低 |
| 验收条件 | `cargo build` 通过,`SqliteStore` 可从 `agcore::memory::SqliteStore` 路径访问 |
#### Task B4 — 编写测试
| 项目 | 内容 |
|------|------|
| 任务描述 | 在 `sqlite_store.rs` 中编写 `#[cfg(test)] mod tests`,覆盖: |
| | 1CRUD 基本操作(save → get → list → delete → get None |
| | 2Upsert 语义(同 id 重复 save 覆盖内容,created_at 保持调用方传入值) |
| | 3prefix 过滤(MemoryFilter.prefix |
| | 4)时间范围过滤(MemoryFilter.since |
| | 5offset/limit 分页 |
| | 6)并发 10 个 writer × 10 次写入,验证无数据丢失 |
| | 7)持久化恢复(write → drop store → reopen 同一文件 → read |
| | 8)错误路径:`open("/nonexistent_dir/ag.db")` 返回 Storage 错误 |
| | 辅助函数:`make_item(id)` 创建 MemoryItem,使用 `tempfile::TempDir`(或自定义 tmp 路径)隔离测试数据库文件 |
| 涉及文件 | `src/memory/store/sqlite_store.rs`(修改追加 test mod |
| 前置依赖 | Task B2(实现完成) |
| 预估工作量 | M1-4h |
| 风险等级 | 中 — 并发测试的时序控制、持久化恢复测试的 TempDir 管理 |
| 验收条件 | 全量测试通过,新增测试 ≥ 8 个 |
#### Task B5 — 验证阶段二
| 项目 | 内容 |
|------|------|
| 任务描述 | 运行全量测试链确认 SqliteStore 实现正确,不影响已有模块 |
| 涉及文件 | 全量 |
| 前置依赖 | Task B3、Task B4 |
| 预估工作量 | S< 1h |
| 风险等级 | 低 |
| 验收条件 | `cargo test --all-targets` 全绿(191 + 新增测试)、`cargo clippy --all-targets -- -D warnings` 0 警告、`cargo build` 通过 |
### 阶段三:验证收尾
#### Task C1 — 跨模块兼容性验证
| 项目 | 内容 |
|------|------|
| 任务描述 | 1)确认 `ConversationMemory` / `KnowledgeStore` / `MemoryRetriever` / `AgentSession` / `SessionMemory``Arc<dyn MemoryStore>` 接受 SqliteStore 时编译通过且测试全绿 |
| | 2)确认 SqliteStore 与 InMemoryStore 可互换——修改一个现有测试将后端从 InMemoryStore 换为 SqliteStore(使用 `":memory:"`),测试全绿 |
| | (3)验证性能预算:在测试环境下测量 save(1KB)/get(1KB)/list(100行) 的单次延迟,确认 < 5ms / < 3ms / < 20ms |
| 涉及文件 | 测试文件(memory/ 模块内各 test mod |
| 前置依赖 | Task B5 |
| 预估工作量 | S< 1h |
| 风险等级 | 低 |
| 验收条件 | 全量测试通过 + 互换测试通过 + 性能预算大致满足(未达标不阻塞发布) |
### 依赖关系图
```
阶段一(目录重构) 阶段二(SqliteStore 实现)
┌──────────────┐ ┌──────────────┐
│ Task A1 │ │ Task B1 │ ← 无依赖,可与 A 并行
│ 创建目录+提取 │ │ Cargo.toml │
└──────┬───────┘ └──────┬───────┘
↓ ↓
┌──────────────┐ ┌──────────────┐
│ Task A2 │ │ Task B2 │
│ 修改store.rs │← A1 ───→│ 核心实现 │← B1 + A1
└──────┬───────┘ └──────┬───────┘
↓ ↓
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Task A3 │ │ Task B3 │← B2 ──→│ Task B4 │
│ 验证阶段一 │ │ 注册+重导出 │ │ 编写测试 │
└──────────────┘ └──────┬───────┘ └──────┬───────┘
↓ ↓
┌──────────────┐────────────────┘
│ Task B5 │← B3 + B4
│ 验证阶段二 │
└──────┬───────┘
阶段三(验证收尾)
┌──────────────┐
│ Task C1 │
│ 跨模块兼容性 │
└──────────────┘
```
### Commit 安排
| 顺序 | Commit 类型 | Scope | 描述 | 包含 Task |
|------|------------|-------|------|-----------|
| 1 | refactor | memory | 将 store.rs 拆分为模块目录,仅结构搬移 | A1 → A2 → A3 |
| 2 | feat | memory | 实现 SqliteStore 持久化(含错误映射、测试、WAL 模式) | B1 → B2 → B3 → B4 → B5 → C1 |
注意:Task B1 与阶段一无依赖,可以在 Commit 1 合并进行或在 Commit 2 开头。建议在 Commit 2 开头,因为 Cargo.toml 变更属于功能变更而非重构。
### 验证全链
实施完毕后整体认证链路:
1. `cargo test --all-targets` — 全量测试通过
2. `cargo clippy --all-targets -- -D warnings` — 0 警告
3. `cargo build --release` — release 构建通过
4. 确认 `cargo doc --no-deps` 无 warning(新增公开类型文档注释)
5. 确认测试数量:191 + (8 个 new sqlite_store tests) = 199+ pass
+543
View File
@@ -0,0 +1,543 @@
# Phase 8 — MVP 集成出口实现方案
- **文档编号**15
- **标题**Phase 8 — MVP 集成出口实现方案
- **日期**2026-07-05
- **状态**:已定稿
- **涉及模块**:全局(llm/types、agent、tools、memory、prompt、examples
- **关联文档**roadmap.md(§Phase 8)、14-phase7-sqlite-store.md
---
## 1. 背景与目标
Phase 5-7 已交付 P0 功能闭环:ProviderConfig `from_env()`Phase 5)、ToolDef IR 正式化(Phase 6)、SqliteStore 持久化(Phase 7)。当前 200 个测试全绿、clippy 0 警告,但缺乏一个"可被人依赖"的集成出口。
Phase 8 的目标是完成 API 稳定性扫尾 + Quick Start 示例 + 端到端示例,产出 **v0.2.0-rc.1** 标签。三个 Step 分别对应三类用户群体:
| Step | 受众 | 交付物 |
|------|------|--------|
| **8.1** | 存量升级者(v0.1 → v0.2 | API 稳定性扫尾 + CHANGELOG |
| **8.2** | 新用户评估者("30 秒决定要不要用" | Quick Start 示例 |
| **8.3** | 技术决策者("这框架能跑真实场景吗") | 端到端集成示例 |
---
## 2. 当前状态
| 度量 | 数值 |
|------|------|
| `cargo test --all-targets` | ✅ 200 passed / 0 failed |
| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 |
| 已存在 `#[non_exhaustive]` 枚举 | 4 个(StopReason / FinishReason / EvictionPolicy / ProviderType |
| 已存在 `#[deprecated]` 项 | 3 个(ChatResponse / ToolDefinition / task_agent_demo 中旧类型使用) |
| 已有示例 | 8 个 |
| `StepStatus::Completed` 使用类型 | `ChatResponse`(已 `#[deprecated]` |
### 2.1 关键技术债
```
// agent/task.rs —— StepStatus 当前使用已废弃类型
#[allow(deprecated)]
pub enum StepStatus {
Completed(ChatResponse), // ← ChatResponse 已在 0.1.0 标记 #[deprecated]
...
}
```
`task_agent_demo.rs` 中同时使用了 `ChatResponse` / `OpenaiChatMessage` / `FinishReason` 三个废弃类型,入口处有 `#![allow(deprecated)]`
---
## 3. 实施方案
### 3.1 Step 8.1 — API 稳定性扫尾
拆为 4 个增量 commit
| Commit | 内容 | 涉及文件 |
|--------|------|---------|
| **commit 1** | 14 个公开枚举追加 `#[non_exhaustive]` | 各枚举定义文件(详见 §3.1.1) |
| **commit 2** | `StepStatus::Completed(ChatResponse)``Completed(MessageResponse)` + `task_agent_demo.rs` 清理全部 3 个废弃类型(`ChatResponse` / `OpenaiChatMessage` / `FinishReason`),移除 `#![allow(deprecated)]` | `src/agent/task.rs``examples/task_agent_demo.rs` |
| **commit 3** | CHANGELOG v0.2 条目 + Cargo.toml version → `0.2.0-rc.1` + README 更新 | `CHANGELOG.md``Cargo.toml``README.md` |
| **commit 4** | 验证:`cargo test + clippy + cargo doc` 零告警 | 无代码改动 |
#### 3.1.1 `#[non_exhaustive]` 追加清单(14 个枚举)
按优先级分级:
| 优先级 | 枚举 | 模块路径 | 理由 |
|--------|------|---------|------|
| **P0 核心** | `Message` | `llm/types/message.rs` | 核心 IR 类型,未来可能新增变体(MultiModal 扩展) |
| | `ContentBlock` | `llm/types/message.rs` | 同上 |
| | `ContentBlockType` | `llm/types/message.rs` | 同上 |
| | `StreamEvent` | `llm/types/response_v2.rs` | 流式事件集,Provider 扩展可能新增事件 |
| | `HookEvent` | `llm/hooks.rs` | 生命周期钩子,框架扩展需要新增事件点 |
| **P0 Error** | `AgentError` | `agent/error.rs` | 顶层错误,下游 match 需保护 |
| | `LlmError` | `llm/error.rs` | LLM 调用错误 |
| | `ToolError` | `tools/error.rs` | 工具系统错误 |
| | `MemoryError` | `memory/error.rs` | 记忆系统错误 |
| | `PromptError` | `prompt/error.rs` | 提示词工程错误 |
| **P1 其他** | `MemoryStrategy` | `memory/conversation.rs` | 对话策略,未来可扩展(如 Summarize) |
| | `StepStatus` | `agent/task.rs` | 步骤状态机,可扩展(如 Cancelled) |
| | `ToolChoice` | `llm/types/request.rs` | Provider 工具选择策略 |
| | `ResponseFormat` | `llm/types/shared.rs` | 响应格式枚举 |
**明确不加的**
| 类别 | 枚举 | 原因 |
|------|------|------|
| 内部 wire-format | `OpenaiChatMessage` / `OpenaiTool` / `OpenaiToolCall` / `ContentField` / `OpenaiContentPart` / `LegacyStreamEvent` | 内部转换层,不构成公共 API 契约 |
| 语义稳定 | `Role` / `ServiceTier` / `Modality` / `ImageDetail` / `AudioFormat` / `StopSequence` | 语义已收敛,协议层无新增变体预期 |
| 使用面窄 | `TemplateValue` / `Permission` / `McpTransport` / `ContentBlockBuilder` / `ExtraError` | 内部实现细节或使用频率极低,下游不直接 match |
> **`#[non_exhaustive]` 的不可逆性**:一旦 v0.2.0-rc.1 发布,以下游代码可能依赖 `_ =>` 通配分支。在 v0.3+ 中移除 `#[non_exhaustive]` 将构成 semver breaking change(新增变体不再触发编译警告,下游 match 可能遗漏新变体),因此当前追加的标记应视为永久 API 契约。
#### 3.1.2 StepStatus 迁移细节
```rust
// 变更前
#[allow(deprecated)]
pub enum StepStatus {
Completed(ChatResponse), // ChatResponse 已 #[deprecated]
...
}
// 变更后
#[non_exhaustive]
pub enum StepStatus {
Completed(MessageResponse),
...
}
```
**字段映射差异**`MessageResponse` 不是 `ChatResponse` 的简单改名——两者结构不同,迁移需要做字段适配:
| ChatResponse 字段 | 类型 | MessageResponse 字段 | 类型 | 映射方式 |
|-------------------|------|---------------------|------|---------|
| `message` | `OpenaiChatMessage` | `message` | `Message` | 类型替换:`OpenaiChatMessage::assistant_text(t)``Message::Assistant { content: vec![ContentBlock::Text { text: t.into() }] }` |
| `usage` | `Usage` | `usage` | `Usage` | ✅ 同类型,直接迁移 |
| `stop_reason` | `Option<FinishReason>` | `stop_reason` | `StopReason` | 类型替换:`Some(FinishReason::Stop)``StopReason::Stop`;无 Option 包裹 |
| — | — | `id` | `String` | 新增必填字段,使用空字符串 `""` 占位 |
| — | — | `model` | `String` | 新增必填字段,使用 `"mock"` 或空字符串占位 |
| — | — | `extra` | `HashMap<String, Value>` | 新增字段,使用 `HashMap::new()` 占位 |
**迁移示例**`task_agent_demo.rs` 的构造代码):
```rust
// 旧代码(3 个废弃类型)
StepStatus::Completed(ChatResponse {
message: OpenaiChatMessage::assistant_text("天气:晴,22°C"),
usage: Usage::from_input_output(10, 5),
stop_reason: Some(FinishReason::Stop),
})
// 新代码(纯 MessageResponse
StepStatus::Completed(MessageResponse {
id: String::new(),
model: "mock".into(),
message: Message::assistant("天气:晴,22°C"),
usage: Usage::from_input_output(10, 5),
stop_reason: StopReason::Stop,
extra: HashMap::new(),
})
```
涉及文件:
- `src/agent/task.rs`:枚举定义 + `#[allow(deprecated)]` 移除 + `#[non_exhaustive]` 追加
- `examples/task_agent_demo.rs``ChatResponse{...}``MessageResponse{...}` 构造替换,同时替换 `OpenaiChatMessage` / `FinishReason` 引用,移除 `#![allow(deprecated)]`
### 3.2 Step 8.2 — Quick Start 示例
| 属性 | 值 |
|------|-----|
| 文件 | `examples/quick_start.rs` |
| 规模 | ~36 行 |
| Provider | `MockProvider`FIFO 单响应队列) |
| 工具 | `EchoTool`(回传 `"收到: {input}"`,完整 JSON Schema 参数声明) |
| 执行 | `submit_turn("你好")` → 验证输出包含 `"收到"` |
| 验证 | `cargo run --example quick_start` exit 0 |
设计要点:
- 展示四层抽象:Agent trait / BaseTool 自定义 / AgentBuilder 装配 / AgentSession 执行
- 无外部依赖、无 API key、零配置
### 3.3 Step 8.3 — 端到端示例
| 属性 | 值 |
|------|-----|
| 文件 | `examples/end_to_end.rs` |
| 规模 | ~160 行(**最小可行边界**:3 工具 + 3 轮 + 持久化验证,防止实施中进一步膨胀) |
| Provider | 自动检测 `AG_LLM_*``from_env()`fallback 到 `MockProvider` |
| 工具组合 | EchoTool(回显)+ CalcTool(四则运算,本地执行)+ NoteTool(笔记,通过 MemoryStore trait 操作 SessionMemory |
| 持久化 | `tempfile::TempDir` + `SqliteStore`,drop 后重建连接验证数据不丢 |
| 对话 | 3 轮:计算 → 记笔记 → 回忆 |
| 验证 | `cargo run --example end_to_end` exit 0(无需任何外部配置) |
**真实 Provider 切换**:示例在文件顶部注释中说明 "设置 `AG_LLM_BASE_URL` / `AG_LLM_API_KEY` / `AG_LLM_MODEL` 环境变量即可使用真实 LLM Provider(支持 OpenAI / Ollama 等);未设置时自动降级为 MockProvider,零配置可运行。"
**`from_env()` 部分环境变量策略**`from_env()` 要求完整的三件套(`{prefix}_BASE_URL` + `{prefix}_API_KEY` + `{prefix}_MODEL`)。当环境变量部分设置时,示例**整体降级到 MockProvider**——不在"半配置"状态下尝试部分初始化。日志输出形如 `"AG_LLM_* 环境变量不完整(检测到: {found_vars}),回退到 MockProvider"`
架构亮点:
```
┌─────────────────────────┐
│ AgentSession │
│ (submit_turn × 3) │
└────┬──────┬──────┬──────┘
│ │ │
┌────┘ │ └──────┐
▼ ▼ ▼
┌──────────┐ ┌────────┐ ┌──────────┐
│ EchoTool │ │CalcTool│ │ NoteTool │
│ (回显) │ │(四则) │ │ (记忆) │
└──────────┘ └────────┘ └────┬─────┘
┌──────▼──────┐
│ SessionMemory│
│ (MemoryStore)│
└──────┬──────┘
┌──────▼──────┐
│ SqliteStore │
│ (temp dir) │
└─────────────┘
```
NoteTool 展示 `MemoryStore` trait 解耦能力:不绑定 SqliteStore,上层 `AgentSession` 通过 `SessionMemory` 操作,底层可互换。
---
## 4. 否决项记录
| 否决方案 | 否决原因 |
|---------|---------|
| `#[non_exhaustive]` 仅加 5 个核心类型 | 全面覆盖 Error enums 为零运行时成本,对下游更友好。Error 枚举是下游 match 最密集的地方,漏标会在 v0.3 引入 breakage |
| StepStatus::Completed 留到 v0.3 再修 | rc.1 前清理 deprecated 类型污染最划算——越晚 migration cost 越高,且当前仅 1 个示例 + 1 个测试引用 |
| Quick Start 纯文本路线(不展示自定义工具) | 含 EchoTool 展示核心差异化,仅多 5 行代码但传递了"可以自定义工具"的关键信息 |
| 端到端仅 Echo + Calc(无 NoteTool | NoteTool 展示 MemoryStore trait 解耦能力是架构亮点,跳过后新用户无法理解 memory 如何集成到 Agent 流程 |
| 持久化仅注释说明不实际运行(方案 Y) | 进程内实操验证(create → drop → reopen → assert)比注释更有说服力,增加约 15 行代码 |
---
## 5. 关键假设
1. **MockProvider FIFO 队列满足 auto-tool-loop 消费顺序**MockProvider 的 `pop()` 按预设顺序弹出。当 LLM 返回多个 tool call 时队列消费顺序与预设一致,无需额外同步
2. **StepStatus 切换需做字段适配**`ChatResponse`(3 字段) 到 `MessageResponse`(6 字段) 存在字段类型差异(`message` 类型不同、`stop_reason` 类型 + Option 有无不同、`id`/`model`/`extra` 为新增必填字段),消费者需按字段映射表提供占位值。但消费者仅 1 个(`task_agent_demo.rs`)+ 1 个内联测试,手动适配工作量极小。`StepStatus``is_terminal()` / `is_pending()` 行为不受影响
3. **所有 10 个示例零外部配置 exit 0**:已有 8 个示例已验证,新增 2 个(quick_start + end_to_end)均使用 MockProvider fallback,无需 API key
4. **`#[non_exhaustive]` × 14 不触发额外 clippy warning**:当前无代码对以上枚举做 exhaustive match(不含 `_`),追加 `#[non_exhaustive]` 是纯安全标记
---
## 6. 实施顺序与验证标准
### 6.1 提交顺序
```
Step 8.1 (4 commits)
→ commit 1: #[non_exhaustive] × 14
→ commit 2: StepStatus 修复(Completed(ChatResponse) → Completed(MessageResponse)
→ commit 3: CHANGELOG v0.2 + Cargo.toml version 0.2.0-rc.1 + README 更新
→ commit 4: 验证(test / clippy / doc 零告警)
Step 8.2
→ commit 5: examples/quick_start.rs~36 行)
Step 8.3
→ commit 6: examples/end_to_end.rs~160 行)
最终验证
→ cargo test --all-targets
→ cargo clippy --all-targets -- -D warnings
→ cargo doc --no-deps
→ git tag v0.2.0-rc.1
```
### 6.2 验收标准
| 指标 | 要求 |
|------|------|
| `cargo test --all-targets` | 全绿 |
| `cargo clippy --all-targets -- -D warnings` | 0 警告 |
| `cargo doc --no-deps` | 0 warning |
| 所有 10 个示例 | `cargo run --example <name>` exit 0 |
| Cargo.toml version | `0.2.0-rc.1` |
| CHANGELOG | v0.2 条目完整(Added / Changed / Deprecated / Fixed / Removed 各节) |
| README | 示例列表 + 版本号更新 |
| git tag | `v0.2.0-rc.1` |
---
## 7. 参考来源
- **roadmap.md** — Phase 8 原始定义(Step 8.1/8.2/8.3)、依赖关系(Phase 5/6/7 → Phase 8
- **`src/agent/task.rs`** — `StepStatus` 当前实现,`Completed(ChatResponse)` 类型
- **`src/llm/types/message.rs`** — `Message` / `ContentBlock` / `ContentBlockType` 枚举定义
- **`src/llm/types/response_v2.rs`** — `StreamEvent` / `StopReason` 枚举定义(StopReason 已有 `#[non_exhaustive]`
- **`src/llm/types/shared.rs`** — `ResponseFormat` / `Role` / `FinishReason` 等枚举(FinishReason 已有 `#[non_exhaustive]`
- **`src/llm/types/request.rs`** — `ToolChoice` 枚举定义
- **`src/llm/hooks.rs`** — `HookEvent` 枚举定义
- **`src/llm/error.rs`** — `LlmError` 枚举定义
- **`src/agent/error.rs`** — `AgentError` 枚举定义
- **`src/tools/error.rs`** — `ToolError` 枚举定义
- **`src/memory/error.rs`** — `MemoryError` 枚举定义
- **`src/memory/conversation.rs`** — `MemoryStrategy` 枚举定义
- **`src/prompt/error.rs`** — `PromptError` 枚举定义
- **`examples/task_agent_demo.rs`** — 当前使用 `#[allow(deprecated)]` + `ChatResponse` 的示例
---
## 8. 实施计划
### 8.1 实施步骤
#### Step 8.1 — API 稳定性扫尾
拆为 4 个增量 commit,依次提交。
##### commit 1: #[non_exhaustive] × 14
| 属性 | 值 |
|------|-----|
| 涉及文件 | 14 个枚举定义所在文件(见下方清单) |
| 前置依赖 | 无 |
| 预估工作量 | S<1h |
| 风险等级 | 低 |
在每个目标枚举定义处的 `pub enum` 之前加一行 `#[non_exhaustive]`,纯文本属性追加,无逻辑变更。
| 目标枚举 | 文件路径 | 行号附近 |
|---------|---------|---------|
| `Message` | `src/llm/types/message.rs` | `pub enum Message` (L22) |
| `ContentBlock` | `src/llm/types/message.rs` | `pub enum ContentBlock` (L99) |
| `ContentBlockType` | `src/llm/types/message.rs` | `pub enum ContentBlockType` (L134) |
| `StreamEvent` | `src/llm/types/response_v2.rs` | `pub enum StreamEvent` (L167) |
| `HookEvent` | `src/llm/hooks.rs` | `pub enum HookEvent` (L9) |
| `AgentError` | `src/agent/error.rs` | `pub enum AgentError` (L20) |
| `LlmError` | `src/llm/error.rs` | `pub enum LlmError` (L10) |
| `ToolError` | `src/tools/error.rs` | `pub enum ToolError` (L6) |
| `MemoryError` | `src/memory/error.rs` | `pub enum MemoryError` (L8) |
| `PromptError` | `src/prompt/error.rs` | `pub enum PromptError` (L3) |
| `MemoryStrategy` | `src/memory/conversation.rs` | `pub enum MemoryStrategy` (L14) |
| `StepStatus` | `src/agent/task.rs` | `pub enum StepStatus` (L59) |
| `ToolChoice` | `src/llm/types/request.rs` | `pub enum ToolChoice` (L14) |
| `ResponseFormat` | `src/llm/types/shared.rs` | `pub enum ResponseFormat` (L70) |
> **注意**`StepStatus` 在 commit 2 中会同时被修改(variant 类型替换 + 移除 `#[allow(deprecated)]`)。commit 1 仅追加 `#[non_exhaustive]` 属性,commit 2 再处理变体变更和清理。
**验收条件**`cargo build --all-targets` 通过
##### commit 2: StepStatus 修复 + 废弃类型清理
| 属性 | 值 |
|------|-----|
| 涉及文件 | `src/agent/task.rs``examples/task_agent_demo.rs` |
| 前置依赖 | commit 1StepStatus 先标记 `#[non_exhaustive]`,此处改 variant 时一并保留,无实际冲突) |
| 预估工作量 | S<1h,约 20 行改动) |
| 风险等级 | 低 |
两步操作:
1. **`src/agent/task.rs`**L59-L71):
- `StepStatus::Completed(ChatResponse)``Completed(MessageResponse)`
- 移除 `#[allow(deprecated)]`(第 13、59 行两处)
2. **`examples/task_agent_demo.rs`**
- 替换 3 个废弃类型:`ChatResponse``MessageResponse``OpenaiChatMessage::assistant_text(t)``Message::assistant(t)``FinishReason::Stop``StopReason::Stop`
- 补充 `id: String::new()``model: "mock".into()``extra: HashMap::new()` 占位字段
- 移除 `#![allow(deprecated)]`(第 26 行)
- 移除 `use` 中的 `ChatResponse``OpenaiChatMessage``FinishReason`
- 添加 `use std::collections::HashMap``use agcore::llm::types::{Message, MessageResponse, StopReason}`(注意:`Message::assistant_text(t)` 不存在,需使用 `Message::assistant(t)`)
字段映射参见 §3.1.2 的字段映射表和迁移示例。
**验收条件**`cargo build --all-targets` 通过,零 deprecated warning
##### commit 3: CHANGELOG + 版本号 + README
| 属性 | 值 |
|------|-----|
| 涉及文件 | `CHANGELOG.md``Cargo.toml``README.md` |
| 前置依赖 | commit 1+2CHANGELOG 需记录实际变更) |
| 预估工作量 | S<1h |
| 风险等级 | 低 |
1. **`CHANGELOG.md`**:新增 `[0.2.0-rc.1]` 条目,包含:
- **Added**SqliteStore 持久化 / OllamaProvider / ProviderConfig::from_env / ToolDef IR / Quick Start 和 end_to_end 示例
- **Changed**MessageRequest.tools 切换 ToolDef / StepStatus::Completed 类型替换
- **Deprecated**ChatResponse / with_system_prompt() / with_client()
- **Non-exhaustive**14 个枚举标记清单
2. **`Cargo.toml`**:第 3 行 `version = "0.1.0"``version = "0.2.0-rc.1"`
3. **`README.md`**:更新示例列表从 7 个改为 10 个(含新增 2 个),版本号同步
**验收条件**:人工 review CHANGELOG + `git diff` 确认版本号
##### commit 4: 验证
| 属性 | 值 |
|------|-----|
| 涉及文件 | 无代码改动 |
| 前置依赖 | commit 3 |
| 预估工作量 | S(<1h,主要等待编译) |
| 风险等级 | 低 |
运行三条命令:
```bash
cargo test --all-targets
cargo clippy --all-targets -- -D warnings
cargo doc --no-deps 2>&1 | grep "^warning:" && echo "WARNINGS FOUND" || echo "0 warnings"
```
**验收条件**:前两条 0 错误,第三条输出 `0 warnings`
#### Step 8.2 — Quick Start 示例
##### commit 5: examples/quick_start.rs
| 属性 | 值 |
|------|-----|
| 涉及文件 | `examples/quick_start.rs` |
| 前置依赖 | 无(可从 Phase 7 独立创建) |
| 预估工作量 | S<1h |
| 风险等级 | 低 |
新文件 `examples/quick_start.rs`~36 行,结构如下:
```
1- 6 use 块(agcore 类型 + Arrow/std 类型)
7- 8 struct Greeter + impl Agentname / system_prompt
9-14 struct EchoTool + #[async_trait] impl BaseTool(完整 JSON Schema 带 text 参数)
15-20 fn mock_response() -> MessageResponse 辅助函数(构造纯文本响应)
21-33 #[tokio::main] async fn main():
- ToolRegistry::new() + register EchoTool
- MockProvider 预设 1 条 mock_response
- AgentBuilder::new() + provider + tool_registry + hook_executor → build
- AgentSession::new + submit_turn("你好")
- println!("{}", response.text())
```
**设计约束**
- EchoTool 的 `parameters()` 返回完整 JSON Schema`{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}`
- 无外部依赖、无 API key、零配置
- 展示四层抽象:Agent trait / BaseTool 自定义 / AgentBuilder 装配 / AgentSession 执行
**验收条件**`cargo run --example quick_start` exit 0,输出包含 `"收到"`
#### Step 8.3 — 端到端示例
##### commit 6: examples/end_to_end.rs
| 属性 | 值 |
|------|-----|
| 涉及文件 | `examples/end_to_end.rs` |
| 前置依赖 | commit 5(示例编写模式已建立);SqliteStorePhase 7 已完成) |
| 预估工作量 | M1-4h |
| 风险等级 | 中 |
新文件 `examples/end_to_end.rs`,~160 行,最小可行边界(3 工具 + 3 轮 + 持久化验证)。
**Provider 初始化策略**
```
if env::var("AG_LLM_BASE_URL").is_ok() && env::var("AG_LLM_API_KEY").is_ok() {
// 使用真实 ProviderAG_LLM_MODEL 非必填,from_env 内部会处理默认值)
let provider: Arc<dyn LlmProvider> = Arc::from(create_provider(
ProviderType::OpenaiChat, ProviderConfig::from_env("AG_LLM").unwrap()
)?);
} else {
// MockProvider fallback,预设 4 条响应序列
let found = ["AG_LLM_BASE_URL", "AG_LLM_API_KEY"].iter()
.filter(|k| env::var(k).is_ok()).collect::<Vec<_>>();
eprintln!("AG_LLM_* 环境变量不完整(检测到: {:?}),回退到 MockProvider", found);
}
```
**工具定义**
| 工具 | 功能 | 关键技术点 |
|------|------|-----------|
| `EchoTool` | 回显输入 | 基础工具注册模式 |
| `CalcTool` | 本地执行四则运算 | 手动解析算术表达式(ponytail:基础 +-*/ 运算无需引入 `rhai` 依赖) |
| `NoteTool` | 通过 MemoryStore trait 读写笔记 | 直接持有 `Arc<dyn MemoryStore>`key 前缀 `"note:"`save 用 `MemoryStore::save(MemoryItem { id: "note:{key}", content, .. })`query 用 `MemoryStore::list(MemoryFilter { prefix: Some("note:"), .. })` |
**持久化验证**
```rust
let dir = tempfile::TempDir::new()?;
let db_path = dir.path().join("agcore.db");
let backend = Arc::new(SqliteStore::open(&db_path)?);
// ... 构建 RuntimeBundle + AgentSession,写入数据 ...
drop(bundle); // 释放所有对 backend 的 Arc 引用
drop(session);
// 此时 backend 无活跃引用,SQLite 连接自动关闭
let backend2 = Arc::new(SqliteStore::open(&db_path)?); // 重建连接
// assert 数据仍在
```
**输出示范**
```
=== agcore 端到端演示 ===
🔄 Provider: MockProvider (离线回退模式)
💾 SqliteStore: /tmp/agcore_XXXXX/agcore.db
🔧 注册工具: echo, calc, note
第 1 轮 用户: 帮我算 25 * 4
→ 调用 calc(...) → 100
→ 回答: 25 * 4 = 100
第 2 轮 用户: 记下来:结果是 100
→ 调用 note(save, ...)
→ 回答: 已记录
第 3 轮 用户: 我刚才算了什么?
→ 调用 note(query)
→ 回答: 您刚才的计算结果是 100
📊 用量: prompt=XX, completion=XX
=== 持久化验证 ===
✓ 跨连接数据存活验证通过
✓ 端到端演示完成
```
**设计约束**
- 文件顶部注释说明 `AG_LLM_*` 环境变量切换真实 Provider
- 零外部配置可运行(Mock fallback
- 最小可行边界:3 工具 + 3 轮 + 持久化验证,不膨胀
**验收条件**`cargo run --example end_to_end` exit 0(零外部配置)
### 8.2 并行机会
commit 1 和 commit 5 可以并行执行(零文件重叠)。commit 5 也可与 commit 2 并行。commit 6 实质上也仅依赖「代码库状态稳定」而非某个具体 commit。
| 并行组 | commit A | commit B | 前提 |
|--------|---------|---------|------|
| 1 | commit 1#[non_exhaustive] | commit 5Quick Start | 零文件重叠 |
| 2 | commit 2StepStatus 修复) | commit 5Quick Start | 零文件重叠 |
| 3 | commit 5Quick Start | commit 6(端到端) | 零文件重叠,但存在知识依赖——commit 6 需参考 commit 5 的 `MessageResponse` 构造、`MockProvider` 用法、`AgentBuilder` 装配模式。推荐 commit 5 先行或实施前同步这些模式 |
### 8.3 风险与应对
| 风险 | 影响 | 可能性 | 应对 |
|------|------|--------|------|
| MockProvider 响应序列与 tool-loop 消费顺序不匹配 | commit 6 端到端示例不通过 | 中 | 按 §5 假设 1:设计响应队列时确保每条 Mock 响应的 `stop_reason` 与 ToolUse/Stop 匹配。出现不匹配时改用完整 `MessageResponse` 构造显式控制 |
| NoteTool 与 AgentSession 的数据传递路径需要扩展现有 API | commit 6 需要修改 `session.rs` | 低 | ponytail 方案:NoteTool 直接持有 `Arc<dyn MemoryStore>` 引用,在 execute 时直接操作 `MemoryStore::save/get`,绕过 AgentSession 的 session_memory 封装 |
| `#[non_exhaustive]` 在某个 enum 上导致 crate 内 match 编译失败 | commit 1 不通过 | 低 | 实施前先运行 `rg "match.*(Message|ContentBlock|ContentBlockType|StreamEvent|HookEvent|AgentError|LlmError|ToolError|MemoryError|PromptError|MemoryStrategy|StepStatus|ToolChoice|ResponseFormat)" src/ --include="*.rs"` 快速扫描 exhaustive match。若某 enum 编译失败,回退该 enum 上的 `#[non_exhaustive]` 属性,标注原因 |
### 8.4 测试策略
| commit | 测试 | 方式 |
|--------|------|------|
| commit 1 | 编译测试 | `cargo build --all-targets` |
| commit 2 | 编译 + 单测 + 无 deprecated warning | `cargo build --all-targets && cargo test` |
| commit 3 | 人工 review | `git diff` |
| commit 4 | 全量自动化 | `cargo test + clippy + doc` |
| commit 5 | 示例运行 | `cargo run --example quick_start` |
| commit 6 | 示例运行 | `cargo run --example end_to_end` |
| 最终 | 全量回归 | 全部三项 + 所有 10 个示例 |
@@ -0,0 +1,827 @@
# Phase 9 — 流式体验增强实施方案
- **文档编号**16
- **标题**:Phase 9 — 流式体验增强实施方案
- **日期**2026-07-05
- **状态**:已定稿
- **涉及模块**llm/cycle、llm/types/response_v2、agent/session
- **关联文档**roadmap.md(§Phase 9)、15-phase8-mvp-integration.md
---
## 1. 背景与目标
agcore 已发布 v0.2.0-rc.1Phase 0-8 全部完成。当前 Agent 会话只有非流式 API(`submit_turn`),开发者无法看到实时 token 输出和工具执行过程。Phase 9 的目标是为 `AgentSession` 新增流式方法 `submit_turn_stream`,让开发者能实时看到 LLM token 生成和工具执行状态。
### 1.1 现有能力
| 能力 | 方法 | 流式 | 自动工具循环 | 状态 |
|------|------|------|-------------|------|
| LLM 流式请求 | `LlmCycle::submit_stream` | ✅ | ❌ | 已就绪 |
| LLM 工具循环 | `LlmCycle::submit_with_tools` | ❌ | ✅ | 已就绪 |
| Agent 会话 | `AgentSession::submit_turn` | ❌ | ✅ | 已就绪 |
| 流事件枚举 | `StreamEvent`(11 变体) | — | — | 缺工具执行事件 |
| Mock 流 | `MockProvider::chat_stream` | ✅ | — | 可模拟流事件序列 |
### 1.2 核心矛盾
流式能力和工具循环能力分别存在于两个方法中,从未被组合。`submit_stream` 只管将 LLM 流事件原样转发,不理解工具调用;`submit_with_tools` 自动执行工具循环但全程阻塞。Phase 9 就是要组合它们:**在工具循环中,每一轮 LLM 调用都是流式的,并在工具执行前后插入语义事件**。
---
## 2. 需求分析
### 2.1 功能需求
1. **`AgentSession::submit_turn_stream(user_input)`** — 返回 `StreamEvent` 流,开发者通过 `while let Some(event) = stream.next().await` 逐事件消费
2. **流式工具循环** — 多轮工具调用过程中流不卡死,每轮工具执行前后插入 `ToolExecutionStarted` / `ToolExecutionCompleted` 事件
3. **`finalize_turn(response)`** — 流消费完成后同步 session 状态(cost 累计 + `OnTurnEnd` hook 触发)
4. **新增 `StreamEvent` 变体**`ToolExecutionStarted` + `ToolExecutionCompleted`,携带工具名称、调用 ID、参数/结果摘要
### 2.2 非功能需求
- **零影响**:现有 `submit_turn``submit_with_tools` 行为不变,存量测试 0 回归
- **异步流**:消费者通过 `futures_util::StreamExt::next()` 逐事件消费
- **错误事件化**:错误通过 `StreamEvent::Error` 事件表达,不通过 `Result` 通道终止流
- **最少代码**:复用现有 `submit_with_tools` 的工具循环逻辑模式和 `submit_stream` 的流管道模式
### 2.3 不做事项
| 事项 | 理由 |
|------|------|
| 新增示例(Phase 9.2 再加) | 缩窄 Phase 9 范围至核心能力 |
| `OnTurnEnd` 自动触发 | Rust 所有权约束:流是延迟求值,`&mut self` 无法进入闭包;由消费者收到 `MessageComplete` 后手动调用 `finalize_turn` |
| 修复 cost 统计 | 中间轮 cost 丢失是已知限制,与 `submit_turn` 行为一致 |
| 跨 turn 消息历史保留 | Phase 10 `ContextSlot` 的职责 |
| 并行 tool 调用的事件细化 | 当前工具调用是顺序 `for` 循环,并行化留待后续优化 |
| `run_tool_loop` 内消息压缩 | `run_tool_loop` 不接收 `compact_config` 参数,不执行上下文压缩。长工具循环中消息增长可能导致 context window 溢出,这是流式实现的已知限制。后续可通过传递 `compact_config``run_tool_loop` 支持 |
| LLM 请求自动 retry | 流式版本不在 `run_tool_loop` 内部实现 retry(详见 §3.6 说明)。调用方可自行包装 `RetryProvider` 或在 `LlmProvider` 实现层处理 |
---
## 3. 方案设计
### 3.1 架构总览
```
┌──────────────────────────────────────────────────────────────┐
│ AgentSession │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ submit_turn_stream() │ │
│ │ ├─ OnTurnStart hook(同步触发,返回流之前) │ │
│ │ ├─ 组装 LlmCyclesystem_prompt / compact_config │ │
│ │ ├─ 调用 submit_with_tools_stream() │ │
│ │ ├─ turn_index += 1 │ │
│ │ └─ 返回流 │ │
│ │ │ │
│ │ finalize_turn(response) │ │
│ │ ├─ cost_so_far.add(&response.usage) │ │
│ │ └─ OnTurnEnd hookturn_index - 1 │ │
│ └──────────────────────────────────────────────────────┘ │
│ submit_with_tools_stream(prompt, Arc<ToolRegistry>)
┌──────────────────────────────────────────────────────────────┐
│ LlmCycle (tokio::spawn task — run_tool_loop 状态机) │
│ │
│ max_turns = max_tool_turns.unwrap_or(10) │
│ for round in 1..=max_turns { │
│ ① build_request(messages, tools) │
│ ② provider.chat_stream(request).await │
│ 匹配 Err → tx.send(Error{..}) + return(不 panic
│ ③ 消费 LLM 流,所有事件 → mpsc unbounded tx(全量转发) │
│ ④ partial.finalize() → MessageResponse │
│ ⑤ if has_tool_use: │
│ ├─ tx → ToolExecutionStarted { tool_name, id, args } │
│ ├─ registry.invoke_all(calls, timeout).await │
│ ├─ for result: tx → ToolExecutionCompleted { ... } │
│ ├─ push tool results → messages │
│ └─ continue(新一轮) │
│ else: break(最终轮,已发出 MessageComplete
│ } │
│ │
│ 产出事件序列(通过 mpsc::unbounded_channel): │
│ MessageStart → ... → ToolCallEnd → ToolExecutionStarted → │
│ ToolExecutionCompleted → MessageStart → TextDelta → ... → │
│ CostUpdate → MessageComplete │
└──────────────────────────────────────────────────────────────┘
```
> **`max_tool_turns` 语义**:与非流式 `submit_with_tools` 一致——`None` 退化为 `10``unwrap_or(10)`)。默认值 `Some(10)` 已提供安全上限;如需增大限制,手动设置为 `Some(N)`。⚠️ 生产环境建议始终设有限值防止无限循环。
### 3.2 事件序列约定
**纯文本流**(无 tool_use):
```
MessageStart → ContentBlockStart → TextDelta* → ContentBlockEnd → CostUpdate → MessageComplete
```
**单轮工具调用**
```
MessageStart → ContentBlockStart → TextDelta* → ContentBlockEnd
→ ContentBlockStart → ToolCallArgumentsDelta* → ToolCallEnd
→ CostUpdate → MessageComplete { stop_reason: ToolUse }
→ ToolExecutionStarted → [工具执行] → ToolExecutionCompleted
→ ContentBlockStart → TextDelta* → ContentBlockEnd
→ CostUpdate → MessageComplete { stop_reason: Stop }
```
**多轮工具调用**
```
... → ToolExecutionCompleted(第 1 轮)
→ ToolCallArgumentsDelta* → ToolCallEnd
→ ToolExecutionStarted → ToolExecutionCompleted(第 2 轮)
→ ... → CostUpdate → MessageComplete(最终轮)
```
**工具不可恢复错误**
```
... → ToolCallEnd → ToolExecutionStarted
→ Error { "tool 'search' 不可恢复错误: ..." } → MessageComplete
```
> **`MessageComplete.full_response` 内容范围**:每轮 LLM 调用独立产生一个 `MessageComplete`,其中 `full_response` 仅包含**该轮 LLM 的单个响应**(不累积前面工具轮次的结果)。中间轮(`stop_reason: ToolUse`)的 `full_response` 通常只包含 `ToolUse` block,无文本。最终轮(`stop_reason: Stop`)的 `full_response` 包含 LLM 的最终输出。消费者如需追踪完整对话历史,应自行累加所有轮次的 `Message`。
### 3.3 StreamEvent 新增变体
`src/llm/types/response_v2.rs``StreamEvent` 枚举中追加两个变体:
```rust
/// 工具开始执行 —— 在 ToolCallEnd 之后、registry.invoke 之前发出。
/// 让 UI 层可以显示 "正在执行工具:add(1, 2)"。
ToolExecutionStarted {
tool_name: String,
tool_call_id: String,
/// 工具参数(JSON 字符串形式)
arguments: String,
},
/// 工具执行完成 —— 在工具返回后、新一轮 LLM 流开始之前发出。
ToolExecutionCompleted {
tool_name: String,
tool_call_id: String,
/// 结果摘要(前 200 字符)
result_summary: String,
/// 是否出错
is_error: bool,
},
```
`PartialMessageResponse::apply_to` 中追加:
```rust
StreamEvent::ToolExecutionStarted { .. } | StreamEvent::ToolExecutionCompleted { .. } => true,
```
这两个是**元事件**,不参与内容块累积,`apply_to` 直接返回 `true`
### 3.4 新增方法签名
**`LlmCycle` 层**`src/llm/cycle.rs`):
```rust
/// 提交消息并自动处理工具调用循环,流式产出所有事件。
///
/// 与 `submit_with_tools` 的区别:
/// - LLM 响应是流式的(全程 `chat_stream` 而非 `chat`
/// - 工具执行前后插入 `ToolExecutionStarted` / `ToolExecutionCompleted` 事件
/// - 错误以 `StreamEvent::Error` 形式出现在流中,而非终止 `Result`
/// - 消费方需手动 `push_message()` 同步消息历史
///
/// **运行时要求**:内部使用 `tokio::spawn`,需要 tokio 多线程运行时。
pub async fn submit_with_tools_stream(
&mut self,
prompt: String,
tool_registry: Arc<ToolRegistry>,
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError>
```
```rust
/// 运行工具循环的核心异步状态机。
///
/// 接收 owned 字段,通过 mpsc::unbounded_channel 产出事件序列。
/// 由 `submit_with_tools_stream` 在 tokio::spawn 中调用。
///
/// **运行时要求**:此函数内部使用 `tokio::spawn`,要求调用方运行在
/// tokio 多线程运行时中(`#[tokio::main]` 或 `#[tokio::test(flavor = "multi_thread")]`)。
/// 不在 WASM 目标下可用。
async fn run_tool_loop(
messages: Vec<Message>,
provider: Arc<dyn LlmProvider>,
config: CycleConfig,
tool_registry: Arc<ToolRegistry>,
tools: Vec<ToolDef>,
tx: mpsc::UnboundedSender<StreamEvent>,
hook_executor: Option<Arc<HookExecutor>>,
)
```
**`AgentSession` 层**`src/agent/session.rs`):
```rust
/// 提交一轮对话(流式,含自动 tool 循环),返回 `StreamEvent` 流。
///
/// 与 `submit_turn` 的区别:
/// - 以流事件序列而非 `MessageResponse` 返回
/// - 工具执行期间插入 `ToolExecutionStarted` / `ToolExecutionCompleted` 事件
/// - 消费方在收到 `MessageComplete` 后需手动调用 `finalize_turn` 同步状态
///
/// **运行时要求**:内部委托 `submit_with_tools_stream`,需要 tokio 多线程运行时。
pub async fn submit_turn_stream(
&mut self,
user_input: impl Into<String>,
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, AgentError>
/// 完成一轮 turn:累计 cost + 触发 OnTurnEnd hook。
///
/// 由消费者在收到 `MessageComplete.full_response` 后调用。
pub async fn finalize_turn(&mut self, response: &MessageResponse)
```
> **实施偏差(Phase 10 适配)**:实际签名扩展为
> `pub async fn finalize_turn(&mut self, response: &MessageResponse, new_messages_from_cycle: Vec<Message>) -> Result<(), AgentError>`。
> - `new_messages_from_cycle`:本轮新增消息(`[user_input, ...tool_results, final_response]`),由消费者在流消费完毕后从 `cycle.messages()[input_len..]` 提取并传入;`finalize_turn` 增量追加到当前 slot(不覆盖已有消息)。
> - 返回 `Result<(), AgentError>`:错误传播更清晰,与 `submit_turn` 的 slot 边界错误(`SlotReadonly` / `SlotNotFound`)对齐。
> - Phase 10 ContextSlot 实施时扩展。Phase 9 消费者若不接入 slot 持久化,可传 `vec![response.message.clone()]` 兜底。
### 3.5 消费者使用模式
```rust
use futures_util::StreamExt;
let mut stream = session.submit_turn_stream("计算 1+2").await?;
let mut final_response = None;
while let Some(event) = stream.next().await {
match &event {
StreamEvent::TextDelta { text } => print!("{}", text),
StreamEvent::ToolExecutionStarted { tool_name, arguments, .. } => {
println!("\n🔧 [{}({})]", tool_name, arguments);
}
StreamEvent::ToolExecutionCompleted { result_summary, .. } => {
println!("{}", result_summary);
}
StreamEvent::MessageComplete { full_response } => {
final_response = Some(full_response.clone());
}
_ => {}
}
}
std::io::stdout().flush().ok();
if let Some(response) = final_response {
session.finalize_turn(&response).await;
}
```
> **⚠️ 消费者注意**`finalize_turn` 是开发者责任 —— 遗漏调用会导致 `cost_so_far` 不累计、`OnTurnEnd` hook 不触发。session 状态仍然可用,后续 `submit_turn` 也能正常执行,但 cost 信息不完整。`finalize_turn` 无自动补偿机制,建议使用 `Drop` guard 或在 `while` 循环的 `finally` 块中确保调用。
### 3.6 run_tool_loop 核心逻辑
`run_tool_loop` 是此方案的核心状态机(约 90 行),其伪代码逻辑如下:
```
1. 接收 owned 字段:messages, provider, config, tool_registry, tools, tx, hook_executor
2. max_turns = config.max_tool_turns.unwrap_or(10)
// None → 10(退化为默认值),Some(n) → n
// 与非流式 submit_with_tools 行为一致
3. 工具循环(for round in 1..=max_turns):
a. build_request(messages, tools)
// 空 tool_registry 时 tools 为空列表,流退化为纯文本流(可安全运行)
b. PreRequest hook(如果有 hook_executor
c. 发起流式 LLM 调用:
let stream = match provider.chat_stream(request).await {
Ok(s) => s,
Err(e) => {
// 第一层错误:chat_stream 自身失败(网络/认证/限流)
// 这里不做 retryretry 逻辑留给上层循环的 submit_request 模式,
// 流式场景中 retry 需重新建立 mpsc 通道,复杂度与收益不匹配
tx.send(StreamEvent::Error { message: e.to_string() }).ok();
return; // 直接结束 task
}
};
d. 消费 LLM 流:
- PartialMessageResponse::new()
- while let Some(result) = stream.next().await
- match result:
Ok(event) → apply_to + tx.send(event)
Err(e) → tx.send(Error { message }) + break
// 第二层错误:stream 内部事件错误(如 chunk 解析失败)
e. partial.finalize()? → response
f. push response.message → messages
g. 检查 has_tool_calls_in_response(&response)
h. 如果没有 tool_use: break(最终轮,流已自然结束)
i. 如果有 tool_use:
- extract_tool_calls_from_response(&response)
- tx.send(ToolExecutionStarted { tool_name, tool_call_id, arguments })
- registry.invoke_all(calls, tool_timeout).await
- for result in results:
tx.send(ToolExecutionCompleted { tool_name, tool_call_id, result_summary, is_error })
- push tool results → messages
- continue(新一轮 LLM 流)
4. 流结束(tokio::spawn 自然退出)
```
> **关于 LLM 请求 retry**:非流式 `submit_with_tools` 内部通过 `submit_request` 的 retry 循环处理临时错误。流式版本 `run_tool_loop` **不在内部实现 retry**。原因:(1)retry 需要重新建立 mpsc 通道和事件流上下文,复杂度与收益不匹配;(2)`unbounded_channel` 已发出的事件无法撤回。如果需要 retry 语义,调用方应在上层做 fallback 策略,或在 `llm provider` 实现层完成 retry(如 `RetryProvider` 包装器)。
**错误处理**
| 场景 | 行为 |
|------|------|
| LLM 请求失败(`chat_stream` 返回 `Err` | `tx.send(Error { message })` + `return` 结束 task。**不做 retry**(见上方说明) |
| LLM 流内事件错误(stream Item 的 `Err` | `tx.send(Error { message })` + `break` 结束当轮流,终止循环 |
| 可恢复工具错误(`is_recoverable() == true` | 作为 tool result 回传 LLM,流继续,不出 Error 事件 |
| 不可恢复工具错误(`is_recoverable() == false` | `tx.send(Error { message })` + 终止循环 |
| 工具超时(`tokio::time::timeout` | 视为不可恢复,`tx.send(Error)` + 终止 |
| 最大工具循环轮次超限 | `tx.send(Error { "达到最大工具循环轮次" })` + 终止 |
| spawn task 内部 panic | 由于 `JoinHandle` 不保存(detached),panic 由 tokio 运行时静默捕获;消费者看到 stream 直接结束(返回 `None`),无 `Error` 事件。建议在 `run_tool_loop` 内部避免 `unwrap()`,所有可失败路径通过 `Result` + `?` 传播 |
### 3.7 修改文件清单
| # | 文件 | 改动 | 估算行数 |
|---|------|------|---------|
| 1 | `llm/types/response_v2.rs` | +2 `StreamEvent` 变体 +2 `apply_to` arm | ~20 |
| 2 | `llm/cycle.rs` | +`submit_with_tools_stream` 方法 + `run_tool_loop` 模块函数 | ~140 |
| 3 | `llm/cycle.rs` | `CycleConfig``#[derive(Clone)]` | ~1 |
| 4 | `agent/session.rs` | +`submit_turn_stream` + `finalize_turn` | ~70 |
| — | **测试**(内联) | 4 个场景测试(纯度本、单轮、多轮、超限) | ~150 |
| | **合计** | | **~380** |
> 注:`RetryConfig` 已标注 `#[derive(Debug, Clone)]`,无需额外修改。
---
## 4. 实现计划
按 5 个 Step 增量实施,每步可独立编译和测试。
### Step 1 — 基础设施准备
**目标**:数据层就绪,为流事件新增变体和配置 Clone 奠基。
**改动**
- `llm/types/response_v2.rs`
- `StreamEvent` 枚举追加 `ToolExecutionStarted` / `ToolExecutionCompleted` 变体
- `PartialMessageResponse::apply_to` 追加两个新变体的 arm(均返回 `true`
- `llm/cycle.rs`
- `CycleConfig``#[derive(Clone)]`(所有字段为基础类型 + `RetryConfig`
**验证**`cargo build` 通过
### Step 2 — `LlmCycle::submit_with_tools_stream` 核心
**目标**:实现流式工具循环的核心状态机,这是整个 Phase 9 的技术关键。
**改动**
- `llm/cycle.rs`
- 新增 `run_tool_loop()` 模块函数(约 90 行),基于 `mpsc::unbounded_channel` 通信
- 新增 `submit_with_tools_stream()` 公开方法,入口参数为 `prompt` + `Arc<ToolRegistry>`
- 内部 `tokio::spawn` 启动 `run_tool_loop`,返回 `rx` 端作为 `dyn Stream`
**验证**`cargo build` 通过
### Step 3 — 单元测试(LlmCycle 层)
**目标**:验证 `submit_with_tools_stream` 在 8 个核心场景下的行为和事件序列正确性(含 §8 Step 3 扩展的工具错误路径)。
**新增**`llm/cycle.rs` 内联测试 `#[cfg(test)]`):
| 场景 | Mock 响应序列 | 验证点 |
|------|---------------|--------|
| 1 — 纯文本流 | 1 个 text 响应 | 事件序列与 `submit_stream` 一致;无 `ToolExecutionStarted`/`ToolExecutionCompleted` |
| 2 — 单轮工具调用 | 2 个响应:tool_use → text | 包含 `ToolExecutionStarted` + `ToolExecutionCompleted`;最终 `stop_reason``Stop` |
| 3 — 多轮工具调用 | 4 个响应:3 × tool_use → 1 × text | 3 对 `ToolExecutionStarted`/`ToolExecutionCompleted`;消息历史长度正确 |
| 4 — 最大轮次超限 | 3 个 tool_use 响应,`max_tool_turns: Some(2)` | 流中出现 `Error` 事件;消息历史停在第 2 轮 |
**验证**`cargo test` 全部通过
### Step 4 — `AgentSession` 层包装
**目标**:为 `AgentSession` 新增流式会话接口,保持与 `submit_turn` 一致的行为语义。
**改动**
- `agent/session.rs`
- `submit_turn_stream(user_input)` — 触发 `OnTurnStart` hook → 组装 `LlmCycle` → 调用 `submit_with_tools_stream``turn_index += 1` → 返回流
- `finalize_turn(response)``cost_so_far.add(&response.usage)` → 触发 `OnTurnEnd` hook
**验证**`cargo build` 通过
### Step 5 — 集成测试 + 扫尾
**目标**:端到端验证 `submit_turn_stream` + `finalize_turn` 的完整链路,确保零回归。
**新增**`agent/session.rs` 内联测试,2026-07-08 实施审查补全):
- `submit_turn_stream_end_to_end``submit_turn_stream` 跑通 mock provider → 消费流(验证收到 TextDelta + MessageComplete`finalize_turn``cost_so_far` 正确更新(`prompt_tokens=10, completion_tokens=5` + `turn_index=1` + default slot 包含 user/assistant 消息
- `submit_turn_stream_triggers_turn_hooks` — 验证 `OnTurnStart``submit_turn_stream` 返回流之前已触发(计数=1+ `OnTurnEnd``finalize_turn` 之前**不**触发(计数=0+ `finalize_turn``OnTurnEnd` 触发(计数=1
**验证**
```bash
cargo test --all-targets # 全绿,存量测试 0 回归
cargo clippy --all-targets -- -D warnings # 0 警告
```
---
## 5. 运行细节
### 5.1 `run_tool_loop` 的 spawn 生命周期
#### 执行模型:立即执行 vs 惰性流
`submit_with_tools_stream` 采用 **立即执行** 模型(`tokio::spawn` + `mpsc`),这与 `submit_stream`**惰性执行**`async_stream::stream!` 宏,消费者首次 `next()` 时才触发 LLM 调用)不同。
**选择理由**:工具循环是 **不确定轮次的** —— 每个工具执行的结果可能影响后续 LLM 调用。惰性流无法表达这种"边消费边控制"的语义。通过 `tokio::spawn` 将工具循环移到独立 task 中运行,使得:
- 消费者可以随时开始消费(不丢失事件)
- 工具循环在后台独立运行,不受消费者消费节奏影响
- `mpsc::unbounded_channel` 作为事件缓冲区,解耦生产者与消费者
**对消费者的影响**`submit_with_tools_stream().await?` 返回时,工具循环可能已经开始执行(事件已开始写入 channel)。消费者应尽快开始 `while let Some(event) = stream.next().await`,避免 channel 缓冲过多事件。如果在返回流后长时间不消费,事件会堆积在 mpsc buffer 中(内存开销,无阻塞风险 —— 见 §6 风险表)。
#### 生命周期
```
submit_with_tools_stream()
├─ mpsc::unbounded_channel() → (tx, rx)
├─ messages.push(user_text(prompt))
├─ compact check
├─ tokio::spawn(run_tool_loop(messages, provider, config, ..., tx))
└─ return Box::pin(rx) as dyn Stream
[用户消费 stream]
└─ while let Some(event) = rx.recv().await { yield event }
[用户 drop rx / 结束循环]
└─ rx 被 drop → tx.send() 返回 Err
→ run_tool_loop 检测到 tx.closed()
→ break → task 自然终止
```
#### JoinHandle 与 panic 处理
`run_tool_loop``JoinHandle` 在 spawn 后**不保存**detached pattern)。panic 由 tokio 运行时捕获并通过 `tracing::error` 记录:
```rust
// submit_with_tools_stream 内部
tokio::spawn(async move {
run_tool_loop(..., tx).await;
});
```
如果 `run_tool_loop` 内部发生 panic(如 `unwrap()`),tokio 的 `spawn` 会静默吞掉 panic 并终止 task。消费者此时看到 stream 直接返回 `None`,不会收到 `StreamEvent::Error`。实际编码中应避免 `unwrap()`,所有 `Result` 使用 `?``match` 处理。
Rx 侧实现 `Stream` trait:使用 `tokio_stream::wrappers::UnboundedReceiverStream` 包装 `mpsc::UnboundedReceiver`,因为 `mpsc::UnboundedReceiver` 本身不实现 `Stream``tokio-stream = "0.1"` 已在 `Cargo.toml` 中存在)。
### 5.2 消息历史同步
`submit_with_tools_stream` 内部由 `run_tool_loop` 管理 `messages` 的拷贝,不会写入 `self.messages`。消费方在收到 `MessageComplete` 后需手动:
```rust
let response = full_response.clone();
cycle.push_message(response.message.clone());
```
`AgentSession::submit_turn_stream` 中,由于流是延迟求值且 `&mut self` 无法进入 spawn 闭包,消息历史同步交由消费方在 `finalize_turn` 前自行决定。当前方案中 `submit_turn_stream` **不自动同步消息历史**,这与 `submit_stream` 的已有行为一致(ponytail: Phase 2 FIX-E 注释)。
---
## 6. 风险评估
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| `&mut self` 约束导致流内无法访问 session 状态 | 中 | 复用 `submit_stream` 已有模式:方法体内读取 `self` 后构建 owned 数据,spawn 闭包不捕获 `&mut self` |
| spawn task 生命周期管理 | 低 | 用户 drop rx → `tx.send` 返回 `Err``run_tool_loop` 自然终止 |
| spawn task panic 静默丢失 | 中 | `run_tool_loop` 内部使用 `match`/`?` 避免 `unwrap()``JoinHandle` 不做 `await`detached),panic 由 tokio 运行时记录日志。消费者看到 stream 提前结束(收到 `None`)但无 Error 事件 |
| 中间轮 cost 不累加到 `cost_so_far` | 低 | 与现有 `submit_turn` 行为一致(仅最终轮计入),标记为已知限制,不在此 Phase 修复 |
| 工具循环中 hook 可用性 | 低 | `PreRequest`/`PostRequest` hook 通过 `hook_executor.clone()` 进入 spawn taskhook 在 `run_tool_loop` 循环内触发 |
| `run_tool_loop` 不支持消息压缩 | 中 | 长工具循环中消息不断增长,可能超出 context window。当前不传递 `compact_config`,后续可扩展 `run_tool_loop` 签名增添此参数 |
| `unbounded_channel` 在消费慢于生产时内存增长 | 低 | LLM 流式输出天然有节流(token 生成速度远慢于 CPU 处理速度),消费者通常快于生产者。后续如需背压可切换为 `mpsc::channel(N)` + backpressure |
| 流式版本不做 LLM retry | 低 | 非流式 `submit_with_tools` 通过 `submit_request` 的 retry 循环处理临时错误。流式版本中 retry 需重建 mpsc 通道,复杂度不匹配。调用方可使用 `RetryProvider` 包装器或在 Provider 层实现 retry |
| 执行模式与 `submit_stream` 不一致(立即 vs 惰性) | 低 | `submit_stream` 的惰性语义不适配需要后台执行的工具循环。消费者应在 `submit_turn_stream` 返回后尽快消费流事件 |
| `tokio::spawn` 要求 tokio 多线程运行时 | 低 | agcore 已依赖 tokio,涉及 IO 的 API 均使用 async。`#[tokio::test]` 单线程运行时不支持 `spawn`,测试中将 `run_tool_loop` 提取为可独立调用的函数,测试不走 spawn 直接调用 |
| `CycleConfig``Clone` 影响现有代码 | 无 | 纯配置 struct,所有字段是基础类型或已 `Clone``RetryConfig` |
---
## 7. 验收标准
| # | 验收项 | 验证方式 |
|---|--------|---------|
| 1 | `cargo build --all-targets` 通过 | ✅ 编译器无错误 |
| 2 | `submit_with_tools_stream` 纯文本流事件序列正确 | 单元测试验证:事件类型、顺序与 `submit_stream` 一致 |
| 3 | `submit_with_tools_stream` 单轮工具调用事件序列正确 | 单元测试验证:含 `ToolExecutionStarted` / `ToolExecutionCompleted` |
| 4 | `submit_with_tools_stream` 多轮工具调用事件序列正确 | 单元测试验证:多对 `ToolExecutionStarted`/`ToolExecutionCompleted` |
| 5 | `submit_with_tools_stream` 最大轮次超限产生 Error 事件 | 单元测试验证:流中出现 `StreamEvent::Error` |
| 6 | `submit_turn_stream` + `finalize_turn` 端到端链路 | 集成测试验证:cost 更新 + hook 触发 |
| 7 | `cargo test --all-targets` 全绿,存量测试 0 回归 | ✅ 无回归 |
| 8 | `cargo clippy --all-targets -- -D warnings` 0 警告 | ✅ 无警告 |
| 9 | 现有 `submit_turn` / `submit_with_tools` / `submit_stream` 行为零影响 | ✅ 存量测试通过 |
---
---
## 8. 实施计划
按 5 个 Step 分阶段实施,每步产出独立 commit,可验证后退。
### 依赖关系
```mermaid
graph LR
S1["Step 1: 基础设施"]:::s1
S2["Step 2: 核心状态机"]:::s2
S3["Step 3: LlmCycle 单元测试"]:::s3
S4["Step 4: AgentSession 包装"]:::s4
S5["Step 5: 集成测试 + 扫尾"]:::s5
S1 --> S2
S1 --> S4
S2 --> S3
S2 --> S4
S3 --> S5
S4 --> S5
classDef s1 fill:#e2e8f0,stroke:#94a3b8
classDef s2 fill:#fbbf24,stroke:#d97706
classDef s3 fill:#93c5fd,stroke:#2563eb
classDef s4 fill:#93c5fd,stroke:#2563eb
classDef s5 fill:#4ade80,stroke:#16a34a
```
| Step | 依赖 | 并行机会 |
|------|------|---------|
| S1 | 无 | — |
| S2 | S1 | 可与 S4 并行 |
| S3 | S2 | 阻塞,需 S2 完成 |
| S4 | S1, S2 | 功能依赖 S2(调用 `submit_with_tools_stream`);文件级无重叠但需先编译过 S2 |
| S5 | S3 + S4 | 需 S3 和 S4 都完成 |
### Step 1 — 基础设施准备
**工作量**S< 1h
**风险**:低(纯新增,不影响现有代码逻辑)
| # | 任务 | 涉及文件 | 前置依赖 | 风险 |
|---|------|---------|---------|------|
| 1.1 | `StreamEvent` 枚举追加 `ToolExecutionStarted` 变体 | `llm/types/response_v2.rs` | 无 | 低 |
| 1.2 | `StreamEvent` 枚举追加 `ToolExecutionCompleted` 变体 | `llm/types/response_v2.rs` | 1.1 | 低 |
| 1.3 | `PartialMessageResponse::apply_to` 追加两个元事件 arm(均返回 `true` | `llm/types/response_v2.rs` | 1.2 | 低 |
| 1.4 | `CycleConfig``#[derive(Clone)]` | `llm/cycle.rs` | 无 | 低 |
**验收条件**
- `cargo build` 通过,编译器无 warning
- 新增的 `StreamEvent` 变体可通过 `serde` roundtrip 序列化/反序列化
- `CycleConfig` 可正常 clone
### Step 2 — `LlmCycle::submit_with_tools_stream` 核心
**工作量**M1-4h
**风险**:中(核心实现,需正确设计 spawn + mpsc 生命周期)
**前置依赖**S1
| # | 任务 | 涉及文件 | 前置依赖 | 风险 |
|---|------|---------|---------|------|
| 2.1 | 实现 `run_tool_loop()` 模块函数:消息循环构建请求 → `chat_stream` → 消费流 → 检测 tool_use → 工具执行 → 新一轮 | `llm/cycle.rs` | S1 | 中 |
| 2.2 | 实现 `submit_with_tools_stream()` 公开方法:提取字段 → spawn `run_tool_loop` → 返回 `UnboundedReceiverStream` | `llm/cycle.rs` | 2.1 | 中 |
| 2.3 | 新增导入:`tokio::sync::mpsc``tokio_stream::wrappers::UnboundedReceiverStream` | `llm/cycle.rs` | 2.2 | 低 |
**关键实现细节**
```rust
// run_tool_loop 函数签名
async fn run_tool_loop(
mut messages: Vec<Message>,
provider: Arc<dyn LlmProvider>,
config: CycleConfig,
tool_registry: Arc<ToolRegistry>,
tools: Vec<ToolDef>,
tx: mpsc::UnboundedSender<StreamEvent>,
hook_executor: Option<Arc<HookExecutor>>,
) {
let max_turns = config.max_tool_turns.unwrap_or(10);
let tool_timeout = config.tool_timeout_secs;
let max_bytes = config.max_tool_result_bytes;
let mut round = 0u32;
loop {
round += 1;
if round > max_turns {
// §3.6 错误表:最大轮次超限 → Error 事件 + 终止
let _ = tx.send(StreamEvent::Error { message: "达到最大工具循环轮次".to_string() });
break;
}
// ① 构建请求
let request = MessageRequest {
model: config.model.clone(),
messages: messages.clone(),
tools: tools.clone(),
tool_choice: ToolChoice::Auto,
max_tokens: config.max_tokens,
temperature: config.temperature,
..Default::default()
};
// ② PreRequest hook
// ...
// ③ chat_stream
let stream = match provider.chat_stream(request).await {
Ok(s) => s,
Err(e) => {
let _ = tx.send(StreamEvent::Error { message: e.to_string() });
return;
}
};
// ④ 消费流
let mut partial = PartialMessageResponse::new();
let mut stream = stream;
while let Some(result) = stream.next().await {
match result {
Ok(event) => {
partial.apply_to(&event);
if tx.send(event).is_err() { return; }
}
Err(e) => {
// ponytail: 流内事件错误后 partial 处于损坏状态,
// 不能继续执行 finalize/finalize —— 直接 return 结束 task
let _ = tx.send(StreamEvent::Error { message: e.to_string() });
return;
}
}
}
// ⑤ finalize
let response = match partial.finalize() {
Ok(r) => r,
Err(e) => { let _ = tx.send(StreamEvent::Error { .. }); return; }
};
messages.push(response.message.clone());
// ⑥ 检测 tool_use
if !has_tool_calls_in_response(&response) {
break; // 最终轮
}
// ⑦ 执行工具
let tool_calls = extract_tool_calls_from_response(&response);
let calls: Vec<_> = tool_calls.into_iter()
.map(|(id, name, args)| {
let value = serde_json::from_str(&args).unwrap_or(Value::Null);
(id, name, value)
}).collect();
for (tool_call_id, tool_name, args_value) in &calls {
let args_json = serde_json::to_string(&args_value).unwrap_or_default();
if tx.send(StreamEvent::ToolExecutionStarted {
tool_name: tool_name.clone(),
tool_call_id: tool_call_id.clone(),
arguments: args_json,
}).is_err() { return; }
}
let results = tool_registry.invoke_all(calls, tool_timeout).await;
for result in &results {
let summary = match &result.output {
Ok(v) => serde_json::to_string(v).unwrap_or_default(),
Err(e) => e.to_string(),
};
// ponytail: 复用现有 truncate_tool_result 函数(cycle.rs 末尾),
// 确保多字节 UTF-8 字符不被截断破坏。上限 200 字符。
let truncated = truncate_tool_result(&summary, 200);
if tx.send(StreamEvent::ToolExecutionCompleted {
tool_name: result.tool_name.clone(),
tool_call_id: result.tool_call_id.clone(),
result_summary: truncated,
is_error: result.output.is_err(),
}).is_err() { return; }
}
for result in results {
let is_error = result.output.is_err();
let content = match &result.output {
Ok(v) => serde_json::to_string(v).unwrap_or_default(),
Err(e) if e.is_recoverable() => format!("错误: {}", e),
Err(e) => {
let _ = tx.send(StreamEvent::Error { .. });
return;
}
};
messages.push(Message::tool_result(result.tool_call_id, content, is_error));
}
}
}
```
**验收条件**
- `cargo build` 通过
- 新增方法签名与方案设计一致
- 未修改现有 `submit_with_tools`/`submit_stream` 的行为
### Step 3 — 单元测试(LlmCycle 层)
**工作量**M1-4h
**风险**:低(与现有测试模式一致,使用已有 MockProvider
**前置依赖**S2
测试策略:直接使用公开的 `crate::llm::mock::MockProvider`(已完整实现 `chat_stream` + 预设响应队列),避免改造 `cycle.rs` 测试模块内的内联 Stub。测试中调用 `submit_with_tools_stream` 时通过 `#[tokio::test(flavor = "multi_thread")]` 满足 spawn 运行时要求,或在单元级将 `run_tool_loop` 作为独立函数直接测试(不走 spawn)。
| # | 测试场景 | Mock 响应序列 | 验证点 | 覆盖路径 |
|---|---------|---------------|--------|---------|
| 3.1 | 纯文本流 | 1 个 text 响应 | 事件序列与 `submit_stream` 一致;无 `ToolExecutionStarted`/`ToolExecutionCompleted` | 正常路径:单轮 LLM → 文本返回 |
| 3.2 | 单轮工具调用 | 2 个响应:tool_use + text | 包含一对 `ToolExecutionStarted`/`ToolExecutionCompleted`;最终 `stop_reason``Stop` | 正常路径:LLM → 工具 → LLM |
| 3.3 | 多轮工具调用 | 4 个响应:3×tool_use + 1×text | 3 对 `ToolExecutionStarted`/`ToolExecutionCompleted`;消息历史长度为 8user + 3×(assistant+tool) + final assistant | 正常路径:LLM → 工具 → LLM → 工具 → LLM |
| 3.4 | 最大轮次超限 | 3 个 tool_use 响应,`max_tool_turns: Some(2)` | 流中出现 `StreamEvent::Error`;消息历史停在第 2 轮 | 边界条件:超出上限 |
| 3.5 | `chat_stream` 返回 Err | Mock `chat_stream` 返回 `Err(LlmError::Other(...))` | 流中第一个事件为 `StreamEvent::Error`;随后流结束 | 异常路径:LLM 不可用 |
| 3.6 | 空 tool_registry | 1 个 text 响应,registry 中无工具 | 流退化为纯文本流,事件序列与 3.1 一致 | 退化场景:无工具可用 |
| 3.7 | 不可恢复工具错误 | 2 个响应:tool_use → text,工具返回 `ToolError::ExecutionFailed`(不可恢复) | 流中出现 `StreamEvent::Error`;消息历史中不含该工具结果(循环终止前未 push) | 异常路径:工具执行失败 |
| 3.8 | 可恢复工具错误 | 2 个响应:tool_use → text,工具返回 `ToolError::ExecutionFailed`(可恢复) | 工具结果作为 `ToolResult { is_error: true }` 回传 LLM;流正常结束,无 `Error` 事件 | 异常路径:工具出错但可恢复 |
| 3.9 | 工具超时 | 2 个响应:tool_use → text`tool_timeout_secs: 1`,模拟工具耗时 10 秒 | 流中出现 `StreamEvent::Error`;循环终止前未 push 工具结果 | 异常路径:工具执行超时 |
**验收条件**
- `cargo test` 新增 8 个测试全部通过
- `cargo test` 存量测试 0 回归
### Step 4 — `AgentSession` 层包装
**工作量**S< 1h
**风险**:低(薄包装层,逻辑简单)
**前置依赖**S1
| # | 任务 | 涉及文件 | 前置依赖 | 风险 |
|---|------|---------|---------|------|
| 4.1 | 实现 `submit_turn_stream()`:触发 `OnTurnStart` → 组装 `LlmCycle` → 调用 `submit_with_tools_stream``turn_index += 1` → 返回流 | `agent/session.rs` | S1 | 低 |
| 4.2 | 实现 `finalize_turn()``cost_so_far.add()` → 触发 `OnTurnEnd` hook | `agent/session.rs` | S1 | 低 |
**验收条件**
- `cargo build` 通过
- 新增方法签名与方案设计一致
-`submit_turn` 的 system_prompt / compact_config / bundle 使用方式一致
### Step 5 — 集成测试 + 扫尾
**工作量**S< 1h
**风险**:低(基于现有测试框架)
**前置依赖**S3 + S4
| # | 任务 | 涉及文件 | 前置依赖 | 风险 |
|---|------|---------|---------|------|
| 5.1 | `submit_turn_stream` 端到端测试:跑通 mock provider → 消费流验证各事件到达 → `finalize_turn` 后 cost 更新正确 | `agent/session.rs`(内联测试) | S4 | 低 |
| 5.2 | Hook 触发验证:`OnTurnStart``submit_turn_stream` 返回流之前触发;`finalize_turn` 调用后 `OnTurnEnd` 正确触发 | `agent/session.rs`(内联测试) | S4 | 低 |
| 5.3 | `cargo test --all-targets` 全绿验证 | 全仓 | S5.1+S5.2 | 低 |
| 5.4 | `cargo clippy --all-targets -- -D warnings` 0 警告 | 全仓 | S5.3 | 低 |
| 5.5 | `cargo build --all-targets` 发布模式验证 | 全仓 | S5.4 | 低 |
**验收条件**
- 全量测试通过,存量 0 回归
- clippy 0 警告
- 发布模式零 warning
### 实施总览
| | Step 1 | Step 2 | Step 3 | Step 4 | Step 5 | **合计** |
|--|--------|--------|--------|--------|--------|---------|
| **工作量** | S | M | M | S | S | **M-L** |
| **文件数** | 2 | 1 | 1(内联) | 1 | 1(内联) | **~4** |
| **代码行** | ~20 | ~140 | ~150 含测试 | ~70 | ~80 含测试 | **~380** |
| **风险** | 低 | 中 | 低 | 低 | 低 | 中 |
| **并行** | — | 阻塞(S4 依赖 S2) | 阻塞 | 阻塞(依赖 S2) | 阻塞 | — |
---
## 附录 A:新增 StreamEvent 变体的 apply_to 语义
```rust
// 在 PartialMessageResponse::apply_to 中追加:
StreamEvent::ToolExecutionStarted { .. } | StreamEvent::ToolExecutionCompleted { .. } => {
// 元事件:不参与内容块累积,不修改 partial response 状态
true
}
```
## 附录 BCycleConfig 的 Clone 推导
```rust
/// LLM 调用周期配置。
#[derive(Debug, Clone)] // ← 追加 Clone
pub struct CycleConfig {
pub model: String,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub max_turns: Option<u32>,
pub retry: RetryConfig, // 已 #[derive(Clone)]
pub max_tool_turns: Option<u32>,
pub tool_timeout_secs: u64,
pub max_tool_result_bytes: usize,
}
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,647 @@
# Phase 11: 测试与检索补强
## 背景与目标
AG Core 当前(v0.2.0-rc.1)已完成 Phase 0-10,全量测试 254 个,clippy 0 警告,11 个离线示例全部 exit 0。功能性交付物覆盖了 LLM Cycle、Prompt、Tool、Memory、Agent Runtime、流式事件、ContextSlot 上下文管理。但有两个系统性的短板尚未补齐:
1. **检索抽象缺失**`memory` 模块只有 `MemoryRetriever`(基于 TextOverlap Dice 系数的关键词检索),缺少语义向量的检索抽象。`docs/roadmap.md` P1 中「VectorRetriever trait」一直未实现。
2. **测试覆盖缺口**Provider 的 roundtrip 测试停留在"基本响应 + 普通 401/500"层面,缺少结构化错误体解析、请求头验证、流式边界、工具调用端到端等关键场景的回归覆盖。多线程并发的 MemoryStore 测试只在 SqliteStore 有一个 10×10 场景,InMemoryStore 完全没有并发压力测试。
Phase 11 是 v0.2.0 正式版发布前的最后一个功能 Phase,三个 Step 的目标:
| Step | 内容 | 定位 |
|------|------|------|
| **11.1** | `VectorRetriever` trait + `InMemoryVectorRetriever` 引用实现 | P1 功能补全 |
| **11.2** | wiremock Provider roundtrip 测试(12 个场景) | 测试质量补强 |
| **11.3** | 并发测试补强(InMemoryStore + SqliteStore | 并发安全验证 |
最终目标:全量测试从 254 → 275+,为 v0.2.0 正式版建立更高的质量基线。
## 需求推演概要
### Step 11.1 — VectorRetriever trait
**核心需求**:定义一个与后端无关的语义检索抽象接口,包含 `index(id, embeddings)` 索引和 `search(query, k)` 检索两个方法。附带一个基于 `HashMap` 全量余弦相似度扫描的参考实现。
**边界识别**
- 只定义 trait,不绑定任何具体后端(pgvector / qdrant / lancedb 留给社区或下游)
- 不引入第三方向量数据库依赖
- 引用实现的 `search()` 不做索引加速(O(n) 全量扫描已足够验证 trait 契约)
- 不与 `MemoryStore` 耦合——`VectorRetriever` 是独立维度
- 不嵌入到 `AgentSession``ContextSlot`(Phase 11 不承担集成消费端)
**关键假设**
- `Vec<f32>` 作为 embedding 类型已足够(大部分 embedding 模型输出 f32 向量)
- 余弦相似度作为默认评分函数可覆盖主流场景
- InMemoryVectorRetriever 的 `Mutex<HashMap>` 在 ~10K 向量内性能可接受
### Step 11.2 — wiremock Provider roundtrip 测试
**核心需求**:补充 12 个 wiremock 测试,覆盖目前缺失的关键回归场景——结构化 JSON 错误体解析、请求头验证、429 限流头解析、流式边界、工具调用端到端。
**边界识别**
- 只做 HTTP mock 层验证,不做端到端 LLM 模型调用
- 每个测试自包含(启动自己的 MockServer),不抽共享 helper
- 测试集中在 OpenAI`GenericOpenaiProvider`)和 Anthropic(独立实现)两个核心 Provider 上
- DeepSeek/Qwen/Ollama 同属 OpenAI Compat,继承 `GenericOpenaiProvider` 的测试覆盖
**关键假设**
- wiremock 的 `body_partial_json` matcher 可用且稳定(当前 dev-dependencies 中已有 wiremock
- OpenAI 和 Anthropic 的结构化错误体格式在当前 SDK 版本中未变化
### Step 11.3 — 并发测试补强
**核心需求**:验证 `MemoryStore` 两种实现(InMemoryStore + SqliteStore)在多线程并发写和混合读写场景下的正确性。
**边界识别**
- 不测试 `KnowledgeStore` / `ConversationMemory` 的并发——它们的行为完全由 `MemoryStore` 决定,不引入新 race 条件
- 不测试 TTL 淘汰的并发正确性(TTL 淘汰使用 wall clock,非原子,不保证精确)
- 混合读写测试只验证"无 panic + 数量正确",不验证"读到的结果恰好与写顺序一致"(后者需要强一致快照,当前 Mutex 模型不提供)
**关键假设**
- `tokio::spawn` 100 个 task 同时写入 `Mutex<HashMap>`InMemoryStore)不会死锁
- SqliteStore 的 WAL 模式 + `busy_timeout=5000` 足够容忍 100 并发写
## 当前状态分析
### 测试覆盖率现状
| 维度 | 当前值 | Phase 11 目标 |
|------|--------|-------------|
| 全量测试 | 254 passed | 275+ passed |
| InMemoryStore 测试 | 6 个(save/get/list/upsert/eviction/TTL | +4 个并发 |
| SqliteStore 测试 | 9 个(含 1 个 10×10 并发) | +1 个 100 并发 |
| OpenAI wiremock 测试 | 4 个(basic/401/500/stream | +8 个 |
| Anthropic wiremock 测试 | 4 个(basic/401/529/stream | +4 个 |
| 请求头验证测试 | 0 个 | +2 个 |
| ToolUse 端到端 mock 测试 | 0 个 | +2 个(OpenAI + Anthropic |
### Provider 测试缺口
现有 wiremock 测试仅覆盖最基础的响应路径,以下关键场景缺失回归保护:
| 场景 | 缺失风险 |
|------|---------|
| OpenAI 请求体格式验证 | `body_partial_json` 未匹配,请求体结构变化无声 |
| Authorization header 验证 | header 注入被修改时不告警 |
| 结构化 401 JSON 错误体 | `error.message`/`error.code` 未消费,错误消息丢失 |
| 429 + `retry-after` 头 | `RateLimit.retry_after` 字段不准确 |
| ToolUse 端到端 mock | tool_flow 解析路径无回归 |
| 流式 last chunk usage-only | `{choices:[], usage:{...}}` 可能 panic |
### MemoryStore 并发测试缺口
| Store | 当前并发测试 | 覆盖度 | 风险 |
|-------|-------------|--------|------|
| InMemoryStore | 0 个 | 无 | `Mutex` 锁竞争、deadlock、写入丢失 |
| SqliteStore | 1 个(10 写者 × 10 次 = 100 条) | 中等 | `spawn_blocking` 线程池耗尽、WAL 锁等待超时 |
### 向量检索现状
`memory` 模块已有 `MemoryRetriever`(关键词检索)和 `retriever.rs` 中的 `RetrievalResult`/`ScoredItem` 类型。但语义向量检索维度完全空缺——无 trait、无引用实现、无测试。`docs/roadmap.md` 将 VectorRetriever 列为 P1,与 ContextSlotP1Phase 10)同级。
## 架构决策记录
| 决策 | 选择 | 放弃 | 理由 |
|------|------|------|------|
| 1. VectorRetriever trait 参数类型 | `Vec<f32>` 裸向量 | `Embedding` newtype | 包装类型增加可见复杂度但未提供运行时保护;大部分 embedding 模型输出 f32 向量;下游可自行包装 |
| 2. `search()` 返回类型 | `Vec<(String, f32)>` | `ScoredItem`/`RetrievalResult` 命名 struct | `(String, f32)` 是 (id, score) 的最小表达;Phase 3 的 `RetrievalResult` 绑定了 `KnowledgePage` 引用,不适合向量检索场景;tuple 在 consumer 侧模式匹配更简洁 |
| 3. 文件归属 | 新文件 `memory/vector.rs` | 合入 `memory/retriever.rs` | `retriever.rs` 已承载 302 行关键词检索代码,语义维度独立不应耦合;`vector.rs` 作为独立模块便于后期扩展(pgvector adapter 等) |
| 4. 是否附带引用实现 | `InMemoryVectorRetriever` | trait-only | trait-only 是纯推测代码,无 consumer 验证引用实现作为"编译期测试"验证 trait 方法签名可用 |
| 5. InMemoryVectorRetriever 余弦相似度实现方式 | 手动三行点积/范数 | `ndarray`/`approx` 等第三方依赖 | 余弦相似度数学固定,不需要外部依赖;1e-10 防零除;零新依赖原则 |
| 5a | 引用实现不做向量维度校验 | 运行时维度检查 | 维度校验是具体后端(pgvector等)的职责;引用实现面向测试/验证场景;调用方负责传入等长向量 |
| 6. 错误类型 | 复用 `MemoryError` 现有变体 | 新增 `VecRetrieval` 变体 | 向量检索与关键词检索语义等价于"检索";`RetrievalError` 变体已覆盖索引/评分异常场景 |
| 7. wiremock 测试组织 | 自包含(每个测试启动自己的 MockServer | 共享 helper 函数 | 沿用现有测试模式(openai.rs line 825+、anthropic.rs line 900+);自包含测试可独立运行、定位更直接 |
| 8. 请求头验证 | 做(`body_partial_json` + `header` matcher) | 跳过 | 回归防御价值高——Provider 请求体结构变化会直接导致请求被拒绝,头验证是低成本高收益的回归保护 |
| 9. 并发测试模式 | 100 并发写 + 混合读写(5 读 + 5 写)双模式 | 只做 100 并发写 | 两种模式互补:纯写入验证数据完整性和无 id 重复;混合读写验证读操作在并发写期间不 panic 且返回有效数据 |
| 10. 实施顺序 | 11.1 → 11.2 → 11.3 | 任意顺序 | 与 roadmap 原定的 Step 顺序一致;11.1 是纯新增可独立交付;11.2/11.3 是对既有代码的测试追加,可并行但不优先于 11.1 |
## 设计方案
### Step 11.1 — VectorRetriever trait + InMemoryVectorRetriever
#### 文件位置
- 新增:`src/memory/vector.rs`
- 修改:`src/memory.rs`+2 行:module 声明 + re-export
#### Trait 定义
```rust
/// 语义向量检索器抽象接口。
///
/// 下游可实现此 trait 以对接向量数据库(pgvector / qdrant / lancedb 等)。
/// 默认引用实现 [`InMemoryVectorRetriever`] 基于进程内 HashMap + 余弦相似度。
///
/// **稳定性**:实验性 APIv0.2.x),方法签名可能在 v0.3 中调整。
/// 若未来需要 `remove()` / `clear()` 等方法,将在此 trait 中追加(带默认实现)。
#[async_trait]
pub trait VectorRetriever: Send + Sync {
/// 将 `id` 对应的文本向量 `embeddings` 加入索引。
async fn index(&self, id: String, embeddings: Vec<f32>) -> Result<(), MemoryError>;
/// 检索与 `query` 向量最相似的 `k` 条记录。
/// 返回 `Vec<(id, score)>`,按 score 降序排列,score ∈ [0.0, 1.0]。
async fn search(&self, query: Vec<f32>, k: usize) -> Result<Vec<(String, f32)>, MemoryError>;
}
```
#### InMemoryVectorRetriever 实现要点
```rust
pub struct InMemoryVectorRetriever {
vectors: Mutex<HashMap<String, Vec<f32>>>,
}
impl InMemoryVectorRetriever {
pub fn new() -> Self {
Self {
vectors: Mutex::new(HashMap::new()),
}
}
}
#[async_trait]
impl VectorRetriever for InMemoryVectorRetriever {
async fn index(&self, id: String, embeddings: Vec<f32>) -> Result<(), MemoryError> {
let mut vectors = self.vectors.lock().map_err(|e| {
MemoryError::RetrievalError(format!("lock poisoned: {e}"))
})?;
vectors.insert(id, embeddings);
Ok(())
}
async fn search(&self, query: Vec<f32>, k: usize) -> Result<Vec<(String, f32)>, MemoryError> {
let vectors = self.vectors.lock().map_err(|e| {
MemoryError::RetrievalError(format!("lock poisoned: {e}"))
})?;
if vectors.is_empty() || k == 0 {
return Ok(Vec::new());
}
let query_norm = dot(&query, &query).sqrt();
if query_norm == 0.0 {
return Ok(Vec::new());
}
let mut scored: Vec<(String, f32)> = vectors
.iter()
.map(|(id, vec)| {
let dot_product = dot(&query, vec);
let vec_norm = dot(vec, vec).sqrt();
let similarity = dot_product / (query_norm * vec_norm + 1e-10);
(id.clone(), similarity)
})
.collect();
// 降序排列
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(k);
Ok(scored)
}
}
/// 点积(手动循环,零依赖)。
///
/// 注意:`zip` 对不等长向量静默截断到较短者。引用实现不做维度校验,
/// 调用方应确保 `a` 和 `b` 等长——不等长时结果无意义但不 panic。
fn dot(a: &[f32], b: &[f32]) -> f32 {
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}
```
#### 边界与约束
- **无维度校验**:不同维度向量传入 `search()` 时点积不报错,但余弦相似度结果无意义。维度校验是具体后端(pgvector等)的职责,引用实现不做运行时检查。
- **零向量处理**:query 为零向量时直接返回空结果(`query_norm == 0.0`)。
- **1e-10 防零除**:避免空库或全零向量导致除零 panic。
#### 测试(4 个)
| # | 测试名 | 验证点 |
|---|--------|--------|
| 1 | `basic_index_and_search` | index 两条("rust" + "python"),用 "rustacean" 查询应排在首位 |
| 2 | `search_empty_store` | 空库返回空 Vec |
| 3 | `concurrent_index` | 10 个 task 各 index 1 条,总量 10、id 无重复 |
| 4 | `concurrent_index_and_search` | 10 个 writer + 5 个 searcher 并发 2 秒,`search()` 遍历期间 `index()` 写入锁竞争不 panic |
#### 修改 `src/memory.rs`
```rust
pub mod vector;
// 在高频 re-export 区追加
pub use vector::{InMemoryVectorRetriever, VectorRetriever};
```
### Step 11.2 — wiremock Provider roundtrip 测试
#### 测试清单
全部 12 个测试均遵循现有自包含模式:`MockServer::start()``Mock::given(...).and(...).respond_with(...)``provider.chat_blocking(...)` / `provider.chat_stream_inner(...)` → assert。
##### P07 个)
| # | 测试名 | 所属文件 | Mock 关键点 | 断言 |
|---|--------|---------|------------|------|
| 1 | `openai_request_body_format` | `openai.rs` | `body_partial_json` 匹配 `{"model": "gpt-4o", "messages": [{"role": "user"}]}` | 请求体结构正确,响应解析正常 |
| 2 | `openai_authorization_header` | `openai.rs` | `header("authorization", "Bearer sk-test")` | header 精确匹配,响应解析正常 |
| 3 | `openai_401_structured_error` | `openai.rs` | 返回 401 + `{"error": {"message": "Incorrect API key", "code": "invalid_api_key"}}` | `LlmError::Authentication(msg)` 且 message 包含 "Incorrect API key" |
| 4 | `anthropic_401_structured_error` | `anthropic.rs` | 返回 401 + `{"error": {"type": "authentication_error", "message": "Invalid API key provided"}}` | `LlmError::Authentication(msg)` 且 message 包含 "Invalid API key" |
| 5 | `openai_429_with_retry_after` | `openai.rs` | 返回 429 + `{"error": {"message": "Rate limit exceeded"}}` + `retry-after: 30` 头 | `LlmError::RateLimit { retry_after: Some(30s) }` |
| 6 | `openai_tool_use_response` | `openai.rs` | 返回包含 `tool_calls` 的响应(choices[0].message.tool_calls ≠ null | `StopReason::ToolUse` + `ContentBlock::ToolUse` 正确解析 |
| 7 | `anthropic_tool_use_response` | `anthropic.rs` | 返回含 `type: "tool_use"` content block + `stop_reason: "tool_use"`Anthropic 独立 wire 格式) | `StopReason::ToolUse` + `ContentBlock::ToolUse` 正确解析 |
##### P15 个)
| # | 测试名 | 所属文件 | Mock 关键点 | 断言 |
|---|--------|---------|------------|------|
| 8 | `anthropic_version_header` | `anthropic.rs` | `header("anthropic-version", "2023-06-01")` | header 精确匹配 |
| 9 | `openai_stream_usage_only_last_chunk` | `openai.rs` | 流式最后 chunk `{"choices":[],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}` | 不 panic`MessageComplete` 包含正确 usage |
| 10 | `anthropic_529_overloaded_structured` | `anthropic.rs` | 返回 529 + `{"error": {"type": "overloaded_error", "message": "Overloaded"}}` | `LlmError::RateLimit { retry_after: None }` |
| 11 | `openai_500_structured_error` | `openai.rs` | 返回 500 + `{"error": {"message": "Internal server error", "type": "server_error"}}` | `LlmError::Request { status: 500, body }` 且 body 包含 "Internal server error" |
| 12 | `openai_stream_mid_stream_error` | `openai.rs` | 流式前几个 chunk 正常,中途服务端断开连接(模拟网络中断/限流断开) | `LlmError::Request(_)` — 流式中断映射为请求错误 |
#### 测试模式说明
```rust
// 每个测试自包含,不抽共享 helper(沿用现有模式)
#[tokio::test]
async fn openai_authorization_header() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.and(header("authorization", "Bearer sk-test"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "chatcmpl-hdr",
"object": "chat.completion",
"created": 1,
"model": "gpt-4o",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "OK"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
})))
.mount(&server)
.await;
let provider = GenericOpenaiProvider::new_with_name(
server.uri(), "sk-test".into(), "gpt-4o".into(), "openai", 30,
);
let response = provider.chat_blocking(MessageRequest {
model: "gpt-4o".into(),
messages: vec![Message::user_text("hi")],
..Default::default()
}).await.unwrap();
assert_eq!(response.text(), "OK");
}
```
#### 新增依赖
dev-dependencies 中 wiremock 已就绪(当前 openai.rs / anthropic.rs 已在测试中使用),无需新增。
### Step 11.3 — 并发测试补强
#### 测试清单
| # | 测试名 | Store | 模式 | 验证标准 |
|---|--------|-------|------|---------|
| 1 | `concurrent_writers_max_pressure` | InMemoryStore | 100 task × 1 write | 总量 100, id 无重复 |
| 2 | `concurrent_writers_max_pressure` | SqliteStore | 100 task × 1 write | 总量 100, id 无重复 |
| 3 | `concurrent_mixed_read_write` | InMemoryStore | 预热 20 条, 5 读 + 5 写并发 2 秒 | 无 panic |
| 4 | `concurrent_mixed_read_write` | SqliteStore | 预热 20 条, 5 读 + 5 写并发 2 秒 | 无 panic |
| 5 | `concurrent_capacity_eviction` | InMemoryStore | 15 写者, max_items=10 | 最终 ≤ 10 |
#### 关键实现要点
**100 并发写模式**InMemoryStore + SqliteStore 各一):
```rust
#[tokio::test]
async fn concurrent_writers_max_pressure() {
let store = Arc::new(InMemoryStore::new());
let mut handles = Vec::new();
for i in 0..100 {
let s = Arc::clone(&store);
handles.push(tokio::spawn(async move {
let id = format!("concurrent_{i}");
s.save(make_item(&id)).await.unwrap();
}));
}
for h in handles {
h.await.unwrap();
}
let list = store.list(&MemoryFilter::default()).await.unwrap();
assert_eq!(list.len(), 100);
let mut ids: Vec<String> = list.iter().map(|v| v.id.clone()).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), 100);
}
```
**混合读写模式**InMemoryStore + SqliteStore 各一):
```rust
#[tokio::test]
async fn concurrent_mixed_read_write() {
let store = Arc::new(InMemoryStore::new());
// 预热
for i in 0..20 {
store.save(make_item(&format!("seed_{i}"))).await.unwrap();
}
let mut handles = Vec::new();
// 5 个写者
for w in 0..5 {
let s = Arc::clone(&store);
handles.push(tokio::spawn(async move {
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
let mut i = 0;
while tokio::time::Instant::now() < deadline {
let id = format!("writer{w}_item{i}");
s.save(make_item(&id)).await.unwrap();
i += 1;
}
}));
}
// 5 个读者
for r in 0..5 {
let s = Arc::clone(&store);
handles.push(tokio::spawn(async move {
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while tokio::time::Instant::now() < deadline {
let _ = s.list(&MemoryFilter::default()).await.unwrap();
}
}));
}
for h in handles {
h.await.unwrap();
}
// 不 panic 即算通过
}
```
**容量淘汰并发模式**InMemoryStore):
```rust
#[tokio::test]
async fn concurrent_capacity_eviction() {
let eviction = EvictionConfig {
policy: EvictionPolicy::Capacity { max_items: 10 },
check_interval: 1,
};
let store = Arc::new(InMemoryStore::with_eviction(eviction));
let mut handles = Vec::new();
for i in 0..15 {
let s = Arc::clone(&store);
handles.push(tokio::spawn(async move {
s.save(make_item(&format!("item_{i}"))).await.unwrap();
}));
}
for h in handles {
h.await.unwrap();
}
let list = store.list(&MemoryFilter::default()).await.unwrap();
assert!(list.len() <= 10);
}
```
**测试归属**
- InMemoryStore 并发测试 → `src/memory/store/in_memory.rs``mod tests`
- SqliteStore 并发测试 → `src/memory/store/sqlite_store.rs``mod tests`
- 混合读写测试中的 `make_item` 辅助函数:直接复用各文件现有 `fn make_item`
## 已否决的方案
### 1. 砍掉 11.1PM 建议)
**内容**PM 在讨论中提出 VectorRetriever trait 无消费者,建议整体砍掉,等 Phase 12 或 v0.3 有人用时再做。
**否决理由**:引用实现作为 trait 契约的编译期验证手段——没有 consumer 不意味着 trait 签名不需要测试。同时社区贡献(pgvector adapter 等)需要稳定的 trait 边界。附带引用实现还可作为"如何在 agcore 中实现一个 VectorRetriever"的示例,降低社区参与门槛。代码量仅 ~80 行,维护成本可忽略。
### 2. trait-only VectorRetriever(无引用实现)
**内容**:只定义 `VectorRetriever` trait,不做 `InMemoryVectorRetriever`
**否决理由**trait-only 是纯推测代码——没有运行时验证,无法确认 trait 方法签名在实际调用链中是否可编译。理想情况下每个 trait 至少有一个引用实现来验证"这个 trait 确实可以被实现"。
### 3. 跳过请求头验证
**内容**:请求体格式和 Authorization header 验证是"过度保护"。
**否决理由**`body_partial_json` + `header` matcher 的回归防御价值高。Provider 适配层的最大风险是请求体结构无声变更(如 `ToolDef` IR 切换时漏改了序列化字段),头验证是低成本(每测试 ~5 行)高收益的回归保护。
### 4. 先发 v0.2.0 正式版再迭代
**内容**:当前 rc.1 已经包含所有 P0 功能,建议直接发正式版,Phase 11 推迟到 v0.2.1。
**否决理由**:测试补强是正式版的信号而非负担。Phase 11 的三个 Step 都是"如果现在不做,以后更不会做"的类型。在正式版前补齐测试基线,避免「发布了再补测试」的经典陷阱。
### 5. 只做 100 并发写,不做混合读写
**内容**:并发写验证数据完整性已足够。
**否决理由**:纯写入和混合读写暴露不同类型的 bug。纯写入验证"数据不丢、id 无重复";混合读写验证"读操作在并发写期间不 panic、返回有效数据"。两种模式互补缺失。
### 6. (实施后补充)openai_stream_mid_stream_error 的 mock 模式偏差
**实际实施**:返回 `200 + SSE content-type + 畸形 JSON payload``data: {not-valid-json}\n\ndata: [DONE]\n\n`),断言 `ChunkToEventStream` 产出 `StreamEvent::Error`
**方案原文**:返回"前几个 chunk 正常,中途服务端断开连接",断言 `LlmError::Request(_)`
**偏差原因**wiremock 0.6 标准 responder 的 `set_delay` / `set_body_string` 行为是「延迟响应 + 发完 body 后关闭连接」,无法精确模拟「send partial body then hang 保持连接」。`read_timeout` 配合 `set_delay` 触发的超时属于 send 阶段(`LlmError::Timeout`),不属于流式中断。
**采纳方案**:用畸形 JSON payload 替代——同样验证"流中途产生错误事件而不 panic"的回归保护意图,且在 wiremock 0.6 上 100% 可重现。断言改为 `StreamEvent::Error{message}` + "stream 最终结束",保持核心回归价值。
**影响**:测试意图(流阶段错误检测)完全保留;mock 行为从「TCP 断开」变为「畸形应用层数据」;断言从 `LlmError::Request` 改为 `StreamEvent::Error`(语义等价:客户端发现流异常)。
### 7. (实施后补充)openai.rs 的 429 retry-after 解析修复
**实际实施**`handle_error_response` 新增 `retry-after` header 解析逻辑(与 anthropic.rs 完全对齐)。
**方案原文**:方案测试 11.2.4 要求 `RateLimit { retry_after: Some(30s) }`,但 ADC 表中未显式列出此修复作为生产代码变更。
**修复原因**:原 `openai.rs:186-191` 的 429 分支固定 `retry_after: None`,与 `anthropic.rs:339-351` 已有的解析逻辑不一致。原代码注释甚至已写"仅读取 retry-after",但实际未实现——这是隐藏 bug。修复让 OpenAI 兼容层(DeepSeek/Qwen 等)的限流重试信息可用,与 Anthropic 行为统一。
**影响**:方案测试 11.2.4 从「不可通过的回归保护」变为「可验证的实际行为」。变更 5 行,与 anthropic 实现完全镜像。
## 实施计划与顺序
**实施顺序**11.1 → 11.2 → 11.3(与 roadmap 一致,每步可单独交付验证)。
| Step | 文件变更 | 测试增量 | 预估代码量 | 验证标准 |
|------|---------|---------|-----------|---------|
| 11.1 | +`src/memory/vector.rs`~80 行),~`src/memory.rs`+2 行) | +4 | ~85 行实现 + 70 行测试 | `cargo build --all-targets` 编译通过,4 个测试通过 |
| 11.2 | ~`src/llm/provider/openai.rs`+6 个测试),~`src/llm/provider/anthropic.rs`+2 个测试) | +127 P0 + 5 P1 | ~290 行(含 test mod 和 mock 数据) | `cargo test --all-targets` 全绿,wiremock 12 场景均绿 |
| 11.3 | ~`src/memory/store/in_memory.rs`+3 个测试),~`src/memory/store/sqlite_store.rs`+2 个测试) | +5 | ~120 行 | `cargo test --all-targets` 全绿 |
| **总计** | 6 个文件(1 新增 + 5 修改) | +21 | ~500 行 | 全量 254 → 275+`cargo clippy --all-targets -- -D warnings` 0 警告 |
### 验证通过标准
1. `cargo build --all-targets` —— 编译通过,无 warning
2. `cargo test --all-targets` —— 全部通过(254 + 21 = 275+
3. `cargo clippy --all-targets -- -D warnings` —— 0 警告
4. `cargo test --all-targets 2>&1 | grep -E "test result:"` —— 确认新增测试全部出现在执行列表中
5. 新增 wiremock 测试单独验证网络隔离(无需 API key,纯本地 mock
## 参考来源
- **讨论收口结论**Phase 11 讨论,含 PM/SA 双视角输入(2026-07-07
- **现有代码模式**
- `src/llm/provider/openai.rs` line 824-1034——wiremock 测试模式(`MockServer::start``Mock::given(...).and(...).respond_with(...)``provider.chat_blocking` → assert
- `src/llm/provider/anthropic.rs` line 899-1079——Anthropic provider wiremock 测试
- `src/memory/store/sqlite_store.rs` line 458-483——`concurrent_writers_no_data_loss` 10×10 并发模式
- `src/memory/store.rs`——`MemoryStore` trait 定义(`#[async_trait]` 风格)
- `src/memory/error.rs`——`MemoryError` 枚举(`#[non_exhaustive]` + `RetrievalError` 变体)
- `src/memory/retriever.rs`——现有检索模块(`RetrievalResult` / `ScoredItem`
- `src/memory.rs`——模块根与 re-export 模式
- **方案文档**`docs/roadmap.md` Phase 11 章节(line 516-528
- **编译器 pragma**`#[non_exhaustive]` —— 新增枚举变体需要此标记,公开结构体字段未来变化预留兼容空间
## 关键假设与风险
### 关键假设清单
| # | 假设 | 影响 | 推翻后的应对 |
|---|------|------|------------|
| 1 | wiremock `body_partial_json` matcher 在 wiremock 0.6+ 中可用 | Step 11.2 测试 #1 的实现方式 | 改用 `body_json`(精确匹配)或 `body_string`(部分串匹配) |
| 2 | `tokio::spawn` 100 task 并发写入 `Mutex<HashMap>` 无死锁 | Step 11.3 #1 InMemoryStore 并发 | 降低并发数(50)继续验证,或换 `tokio::sync::Mutex` |
| 3 | SqliteStore 的 `busy_timeout=5000` 能容忍 100 并发写 | Step 11.3 #2 SqliteStore 并发 | 增加 `busy_timeout`10s),或限制最大并发数 |
| 4 | 新增测试不使用 wiremock 以外的未列在 dev-dependencies 中的依赖 | Phase 11 零新增外部依赖 | 若需要额外 matcher,评估后加入 dev-dependencies |
| 5 | InMemoryVectorRetriever 的 O(n) 全量扫描在测试规模下 (<1000 向量) 性能可接受 | Step 11.1 测试通过 | 如果有竞态问题,改为 read/write lock`RwLock<HashMap>` |
| 6 | `MemoryError::RetrievalError` 变体足够覆盖向量检索的索引/评分失败场景 | Step 11.1 错误映射 | 如果不够,可增加新的 `MemoryError` 变体 |
| 7 | OpenAI 和 Anthropic 的结构化错误体格式在当前 SDK 版本中未变化 | Step 11.2 测试 #3/#4/#7/#10/#11(结构化错误解析断言) | 若 SDK 变更错误体格式,更新 mock body 和断言匹配新格式 |
| 8 | 调用方传入 `search()` 的向量与已索引向量维度一致(引用实现不做维度校验,`dot()` 对不等长向量静默截断) | Step 11.1 InMemoryVectorRetriever 正确性 | 若须维度校验,在 `index()` 时记录维度并在 `search()` 时断言;引用实现维持零校验 |
### 已识别的风险
| 风险 | 等级 | 缓解措施 |
|------|------|---------|
| wiremock `body_partial_json` matcher 行为在版本升级后变化 | 低 | 限定 wiremock 版本范围(当前已在 Cargo.lock 中锁定);P0 测试不依赖该 matcher |
| 100 并发写暴露 SqliteStore 的 `spawn_blocking` 线程池瓶颈 | 中 | 观察 CI 执行时间;如果超时,降低并发到 50 或增加 `max_blocking_threads` |
| InMemoryVectorRetriever 的 `Mutex` 锁争用导致测试 flaky | 低 | Mutex 不会死锁(单线程持有不 await),测试不依赖精确时序 |
| 新增 wiremock 测试与现有测试冲突(端口占用) | 低 | `MockServer::start()` 自动选择随机端口,不冲突 |
| `cargo test --all-targets` 执行时间增加 >30% | 低 | 预估 +21 个测试,增量约 8%254→275),其中 wiremock 测试有网络 IO 但延迟 <10ms/个 |
### 非阻塞已知项
- **Ollama Provider** 是 OpenAI Compatwiremock 测试继承 `GenericOpenaiProvider`,不单独新增
- **DeepSeek / Qwen Provider** 同样通过 `GenericOpenaiProvider` 实现,继承测试
- **Step 11.1 不消费到 AgentSession / ContextSlot**,留待 Phase 12 或 v0.3 做消费端集成
- **Phase 11 完成后**,全量测试预计 275+,`cargo test --all-targets` 执行时间预计 < 60s
---
## 实施计划(附录)
**实施顺序**11.1 → 11.2 ‖ 11.311.2 与 11.3 无文件冲突,可并行交付;11.2 优先因回归保护价值更高)。每步完成后运行 `cargo test --all-targets` + `cargo clippy --all-targets -- -D warnings` 验证无回归。
---
### Step 11.1 — VectorRetriever trait + InMemoryVectorRetriever~160 行,4 测试)
**前置**:无。与 Step 11.2/11.3 可并行开发但优先交付。
| 任务 | 描述 | 文件 | 前置 | 工作量 | 风险 | 验收条件 |
|------|------|------|------|--------|------|---------|
| **11.1.1** | 创建 `memory/vector.rs`:定义 `VectorRetriever` trait + `InMemoryVectorRetriever` struct + `dot()` 辅助函数 | `src/memory/vector.rs` | 无 | S | 低 | `cargo build --all-targets` 编译通过 |
| **11.1.2** | 实现 `InMemoryVectorRetriever``index()` — Mutex insert`search()` — 全量余弦扫描 + 降序排列 + k 截断 | `src/memory/vector.rs` | 11.1.1 | S | 低 | trait 实现编译通过;`Mutex::lock()` 使用 `map_err` 处理 poison,不 panic |
| **11.1.3** | 添加 4 个内联测试:`basic_index_and_search``search_empty_store``concurrent_index``concurrent_index_and_search` | `src/memory/vector.rs` (mod tests) | 11.1.2 | S | 低 | 4 测试全部通过 |
| **11.1.4** | 修改 `src/memory.rs`:加 `pub mod vector;` + `pub use vector::{VectorRetriever, InMemoryVectorRetriever};` | `src/memory.rs` | 11.1.1 | S | 低 | `cargo build --all-targets` 无 warning |
**Step 验证**
```
cargo test --all-targets # 254 + 4 = 258+ passed
cargo clippy --all-targets -- -D warnings # 0 warning
```
---
### Step 11.2 — wiremock Provider roundtrip 测试(12 测试,P0=7 + P1=5
**前置**:无。与 Step 11.1 无文件冲突,可并行。
**`openai.rs` 新增测试(8 个:P0=5 + P1=3**
| 任务 | 测试名 | 优先级 | 前置 | 工作量 | 风险 | Mock 模式 | 验收条件 |
|------|--------|--------|------|--------|------|----------|---------|
| **11.2.1** | `openai_request_body_format` | P0 | 无 | S | 低 | `body_partial_json` 匹配 model/messages | 请求体结构正确,响应解析正常 |
| **11.2.2** | `openai_authorization_header` | P0 | 无 | S | 低 | `header("authorization", "Bearer sk-test")` | header 精确匹配 |
| **11.2.3** | `openai_401_structured_error` | P0 | 无 | S | 低 | 401 + `{"error":{"message":"...","code":"invalid_api_key"}}` | `LlmError::Authentication` 含 "Incorrect API key" |
| **11.2.4** | `openai_429_with_retry_after` | P0 | 无 | S | 低 | 429 + `retry-after: 30` | `RateLimit { retry_after: Some(30s) }` |
| **11.2.5** | `openai_tool_use_response` | P0 | 无 | S | 低 | 响应含 `tool_calls` | `StopReason::ToolUse` + `ContentBlock::ToolUse` 正确解析 |
| **11.2.6** | `openai_stream_usage_only_last_chunk` | P1 | 无 | S | 低 | 流式最后 chunk `{choices:[], usage:{...}}` | 不 panic`MessageComplete` 含正确 usage |
| **11.2.7** | `openai_500_structured_error` | P1 | 无 | S | 低 | 500 + `{"error":{"message":"server error"}}` | `Request { status: 500 }` 含 body |
| **11.2.8** | `openai_stream_mid_stream_error` | P1 | 无 | M | 中 | 前几个 chunk 正常后连接断开 | `LlmError::Request(_)` 流中断映射 |
**`anthropic.rs` 新增测试(4 个:P0=2 + P1=2**
| 任务 | 测试名 | 优先级 | 前置 | 工作量 | 风险 | Mock 模式 | 验收条件 |
|------|--------|--------|------|--------|------|----------|---------|
| **11.2.9** | `anthropic_401_structured_error` | P0 | 无 | S | 低 | 401 + `{"error":{"type":"authentication_error","message":"..."}}` | `LlmError::Authentication` 消息透传 |
| **11.2.10** | `anthropic_tool_use_response` | P0 | 无 | S | 低 | 响应含 `type:"tool_use"` content block + `stop_reason:"tool_use"` | `StopReason::ToolUse` + `ContentBlock::ToolUse` 正确解析 |
| **11.2.11** | `anthropic_version_header` | P1 | 无 | S | 低 | `header("anthropic-version", "2023-06-01")` | header 精确匹配 |
| **11.2.12** | `anthropic_529_overloaded_structured` | P1 | 无 | S | 低 | 529 + `{"error":{"type":"overloaded_error","message":"Overloaded"}}` | `RateLimit { retry_after: None }` |
**Step 验证**
```
cargo test --all-targets # 258 + 12 = 270+ passed
cargo clippy --all-targets -- -D warnings # 0 warning
```
每个测试自包含(`MockServer::start()``Mock::given(...)``provider.chat_blocking()/chat_stream_inner()` → assert),无需共享 helper。
---
### Step 11.3 — 并发测试补强(5 测试)
**前置**:无。与 Step 11.1/11.2 无文件冲突。
| 任务 | 测试名 | 文件 | 前置 | 工作量 | 风险 | 模式 | 验收条件 |
|------|--------|------|------|--------|------|------|---------|
| **11.3.1** | `concurrent_writers_max_pressure` | `in_memory.rs` | 无 | S | 中 | 100 task × 1 write | 总量 100id 无重复 |
| **11.3.2** | `concurrent_writers_max_pressure` | `sqlite_store.rs` | 无 | S | 中 | 100 task × 1 write | 总量 100id 无重复 |
| **11.3.3** | `concurrent_mixed_read_write` | `in_memory.rs` | 无 | S | 中 | 预热 20 条,5 写 + 5 读并发 2 秒 | 无 panic |
| **11.3.4** | `concurrent_mixed_read_write` | `sqlite_store.rs` | 无 | S | 中 | 预热 20 条,5 写 + 5 读并发 2 秒 | 无 panic |
| **11.3.5** | `concurrent_capacity_eviction` | `in_memory.rs` | 无 | S | 中 | 15 写者,max_items=10 | 最终 ≤ 10(竞争激烈时可能过渡态 >10,主断言 ≤ 10,宽松备选 ≤ 15) |
**实现要点**
- 沿用现有 `Arc<Store> + tokio::spawn + h.await.unwrap()` 模式(参考 `sqlite_store.rs:458-483`
- `make_item` 辅助函数直接复用各文件现有实现
- SqliteStore 测试使用 `:memory:` 数据库(与现有并发测试一致)
- 混合读写测试使用 `tokio::time::Instant::now() + Duration` 做时限
- 100 并发写是一次性 spawn 100 task(非分批),暴露最大锁竞争压力
**Step 验证**
```
cargo test --all-targets # 270 + 5 = 275+ passed
cargo clippy --all-targets -- -D warnings # 0 warning
```
---
### 整体发布核查清单
| # | 检查项 | 验证命令 | 预期结果 |
|---|--------|---------|---------|
| 1 | 编译 | `cargo build --all-targets` | 通过,0 warning |
| 2 | 全量测试 | `cargo test --all-targets` | 275+ passed0 failed |
| 3 | Lint | `cargo clippy --all-targets -- -D warnings` | 0 warning |
| 4 | 文档 | `cargo doc --no-deps` | 0 warningVectorRetriever trait 公共 API doc 完整) |
| 5 | 确认新增测试 | `cargo test --all-targets 2>&1 \| grep -E "test result:"` | 所有新增测试名出现在执行列表中 |
| 6 | wiremock 隔离 | 新增 wiremock 测试不依赖网络 | 纯本地 mock,无需 API key |
| 7 | 并行安全 | 并发测试独立运行时无 flaky | 连续 3 次 `cargo test` 结果一致 |
| 8 | 存量零回归 | 已有 254 个测试全部通过 | 与 Phase 10 基线对比无 fail |
| 9 | 公共 API doc comment | `grep -r "pub trait VectorRetriever" src/ && rg "^///" -c src/memory/vector.rs` | trait 和方法都有 `///` 注释 |
**若核查项失败的回退策略**
- **测试失败(P0)**:阻断发布。定位到具体测试名 → 检查 Mock JSON 格式与 Provider 解析逻辑是否匹配(结构化错误体格式变化 → 更新 mock body;流式状态机变化 → 更新 `chat_stream_inner` 路径测试)
- **测试失败(P1)**:不阻断发布。标记 `#[ignore]` + file issue,确认无 P0 失败后即可发布
- **clippy warning**:修复 lint 后重跑;若为 `#[allow(...)]` 可抑制,在 code review 中申明理由
- **flaky 并发测试**:检查 `tokio::spawn` 是否跨 `.await` 持锁;若 SqliteStore 超时,增加 `busy_timeout` 或降低并发数
- **11.1 模块发布阻塞**:若 `InMemoryVectorRetriever` 无法按时交付,可临时注释 `src/memory.rs` 中的 `pub mod vector;` 行,跳过整个模块(零消费者,不影响发布)。回退后再补交
@@ -0,0 +1,640 @@
# Phase 13 — 热身清理 + ContextSlot fork/merge 实施方案
- **文档编号**19
- **标题**Phase 13 — 热身清理 + ContextSlot fork/merge 实施方案
- **日期**2026-07-08
- **状态**:待实施
- **涉及模块**agent/context、agent/session、llm/types、llm/provider/openai、llm/stream
- **关联文档**roadmap.md(§Phase 13)、17-phase10-contextslot.md
- **对应**Roadmap §Phase 13v0.3.0 第一阶段)
---
## 1. 背景与目标
v0.3.0 是 agcore 从"LLM 调用工具箱"升级为"多 Agent 基础系统"的关键版本。Phase 13 是 v0.3.0 的第一阶段,定位为"热身",包含两大部分:
- **技术债清理**:删除 Phase 0 遗留的旧 types 文件(`request.rs``response.rs``old_stream.rs`),以及已标记 `#[deprecated]``ChatResponse` 结构体
- **ContextSlot fork/merge**:为 ContextSlot 增加分叉和合并能力,为后续 Phase 17 Checkpointer 和 Phase 18 SubAgent Dispatch 打基础
**依赖关系**:无(独立交付)
**优先级**P0
**预估规模**:净减 ~200 行代码(新增 ~505 行,删除 ~704 行)
---
## 2. 需求分析
### 2.1 功能需求
1. **技术债清理**:删除 `src/llm/types/request.rs`187 行)、`response.rs`177 行)、`old_stream.rs`45 行),将其中的 OpenAI wire-format 类型移入 `src/llm/provider/openai.rs`;删除 `types/mod.rs` 中的 `ChatResponse` 废弃结构体
2. **`ContextSlot::fork`**:从现有 context slot 分支出独立的子 slot
3. **`ContextSlot::merge`**:将子 slot 的消息合并回父 slot
4. **`MergeStrategy`** 枚举:Append(追加)/ Replace(替换),`#[non_exhaustive]` 预留 Phase 16 Summarize 扩展
### 2.2 非功能需求
- **每步可编译**:5 个 Step 按物理文件切割,每步 `cargo build --all-targets + cargo test` 验证
- **指定公共 API 路径保持向后兼容**:`agcore::llm::types::ToolChoice`re-export 不变)、`crate::llm::stream::StreamEvent`(重导出保留);其余 wire-format 类型(`OpenaiChatRequest``OpenaiChatResponse/Chunk``StreamOptions` 等)移入 `provider/openai.rs` 后属 Breaking Change,详见 §4.3 CHANGELOG
- **向后兼容的 StreamEvent 路径**`crate::llm::stream::StreamEvent` 重导出保留,不修改 `cycle.rs``session.rs` 的 import
---
## 3. 方案设计
### 3.1 整体架构
Phase 13 分为 5 个 Step,按执行顺序排列:
```
Step 13.5 (fork/merge) → Step 13.4 (ToolChoice) → Step 13.1 (request types) → Step 13.2 (response types) → Step 13.3 (cleanup)
```
这种顺序的好处:
- **先交付价值**:13.5 是唯一有用户功能交付的 Step,先做建立节奏
- **排序约束**13.4 必须先于 13.1ToolChoice 不搬走,request.rs 不能删)
- **13.3 收尾**:删除旧文件和 `ChatResponse` 是 breaking change,放在最后
### 3.2 Step 13.5 — ContextSlot fork/merge
#### MergeStrategy 枚举
定义在 `src/agent/context.rs`
```rust
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum MergeStrategy {
/// 子 slot 消息追加到父 slot 末尾。
Append,
/// 用子 slot 消息替换父 slot 内容。
Replace,
}
```
- `#[non_exhaustive]` 保证 Phase 16 加入 `Summarize` 变体时不破坏现有代码
- 不预埋 `Summarize` 占位变体(YAGNI 原则)
#### ContextSlot::fork
```rust
impl ContextSlot {
pub fn fork(&self, child_id: String, strategy: DeriveStrategy) -> ContextSlot {
let messages = match &strategy {
DeriveStrategy::Full => self.messages.clone(),
DeriveStrategy::Focused(cfg) => Self::filter_focused(&self.messages, cfg),
};
tracing::debug!(
parent_id = %self.id,
child_id = %child_id,
?strategy,
"ContextSlot::fork"
);
ContextSlot {
id: child_id,
session_id: self.session_id.clone(),
config: SlotConfig {
mode: match &strategy {
DeriveStrategy::Full => SlotMode::Full,
DeriveStrategy::Focused(cfg) => SlotMode::Focused(cfg.clone()),
},
source: SlotSource::Derived {
parent_id: self.id.clone(),
strategy,
},
budget: self.config.budget.clone(),
compact: self.config.compact,
},
messages,
meta: SlotMeta::new(),
}
}
}
```
设计要点:
- 纯数据层操作,不持久化
- 子 slot 的 `meta` 全新创建(`SlotMeta::new()`),不继承父 slot 的 message_count
- 子 slot 的 source 记录 `parent_id`,血缘可追溯
- 添加 `tracing::debug!` 日志,支持多 slot 交互场景的审计追踪
#### ContextSlot::merge
```rust
impl ContextSlot {
/// 将子 slot 的消息合并到当前 slot。
///
/// **注意**:本方法仅操作内存数据,不自动持久化。
/// 调用方需在 merge 后自行调用 `self.save(&store)` 将结果写入后端存储。
pub fn merge(&mut self, child: ContextSlot, strategy: MergeStrategy) -> Result<(), AgentError> {
// 防御性检查
if self.id == child.id {
return Err(AgentError::Config("不能将 slot 合并到自身".into()));
}
if self.session_id != child.session_id {
return Err(AgentError::Config("不能合并不同 session 的 slot".into()));
}
if matches!(self.config.mode, SlotMode::Readonly) {
return Err(AgentError::SlotReadonly("Readonly slot 不允许合并".into()));
}
tracing::debug!(
self_id = %self.id,
child_id = %child.id,
?strategy,
"ContextSlot::merge"
);
match strategy {
MergeStrategy::Append => {
let count = child.messages.len();
self.messages.extend(child.messages);
self.meta.message_count += count;
}
MergeStrategy::Replace => {
self.messages = child.messages;
self.meta.message_count = self.messages.len();
}
}
Ok(())
}
}
```
#### AgentSession::derive_slot 重构
现有 `derive_slot`session.rs:213-260)的手工复制代码改为调用 `parent.fork()`
```rust
pub async fn derive_slot(
&mut self,
id: impl Into<String>,
parent_id: &str,
strategy: DeriveStrategy,
) -> Result<(), AgentError> {
let slot_id = id.into();
if self.slots.contains_key(&slot_id) {
return Err(AgentError::SlotAlreadyExists(slot_id));
}
let parent = self
.slots
.get(parent_id)
.ok_or_else(|| AgentError::SlotNotFound(parent_id.to_string()))?;
let child = parent.fork(slot_id.clone(), strategy); // ← 用 fork
child.save(&*self.resolve_store()).await?;
self.slots.insert(slot_id, child);
Ok(())
}
```
重复检查、查找父 slot 的代码不变;消息复制逻辑委托给 `fork()`
#### 测试计划(新增 9 个)
| 测试名 | 验证点 |
|--------|--------|
| `fork_full_copies_messages` | fork Full 策略复制父 slot 全部消息 |
| `fork_focused_filters_messages` | fork Focused 策略按 config 过滤 |
| `fork_preserves_independence` | 父 slot 追加消息不影响子 slot |
| `fork_sets_derived_source` | 子 slot source 正确记录 parent_id |
| `merge_append_appends_messages` | Append 追加到父 slot 末尾,message_count 正确 |
| `merge_replace_replaces_messages` | Replace 替换父 slot 消息,message_count 正确 |
| `merge_self_rejected` | self-merge 返回 `Err` |
| `merge_readonly_rejected` | 合并到 Readonly slot 返回 `Err` |
| `merge_cross_session_rejected` | 跨 session 合并返回 `Err` |
### 3.3 Step 13.4 — ToolChoice 移入 tool.rs
#### 变更文件
| 文件 | 变更 |
|------|------|
| `src/llm/types/request.rs` | 删除 `ToolChoice` 枚举 + serde impl~28-99 行) |
| `src/llm/types/tool.rs` | 新增 `ToolChoice` 枚举 + serde impl(原样搬入) |
| `src/llm/types/mod.rs` | `pub use request::{..., ToolChoice}``pub use tool::ToolChoice` |
| `src/llm/types/request_v2.rs` | import 路径 `request::ToolChoice``tool::ToolChoice` |
**import 路径变化**
| 当前 | 移动后 |
|------|--------|
| `crate::llm::types::request::ToolChoice` | `crate::llm::types::tool::ToolChoice` |
| `crate::llm::types::ToolChoice`(通过 re-export | `crate::llm::types::ToolChoice`(通过 tool.rs re-export,保持不变) |
**验证**`cargo build --all-targets` + `cargo test` + `cargo clippy`
### 3.4 Step 13.1 — request.rs 类型移入 openai.rs
#### 变更文件
| 文件 | 变更 |
|------|------|
| `src/llm/types/request.rs` | **整文件删除**187 行) |
| `src/llm/provider/openai.rs` | 新增 `StreamOptions``OpenaiTool``AudioParam``PredictionContent``UserLocation``Approximate``WebSearchOptions``OpenaiChatRequest` 等类型定义 |
| `src/llm/types/mod.rs` | 删除 `pub use request::{OpenaiChatRequest, OpenaiTool, StreamOptions}`;删除 `pub mod request;` |
| `src/llm/provider/openai.rs` import 调整 | 原 `use crate::llm::types::request::{...}` 改为从同级 `use super::super::types::...` 或直接使用本文件内类型 |
**注意**`OpenaiTool` 引用 `OpenaiToolDefinition`(定义在 `tool.rs`),移入 `openai.rs` 后需通过 `crate::llm::types::tool::OpenaiToolDefinition` 引用。`OpenaiChatRequest.messages` 字段引用 `OpenaiChatMessage`(定义在 `openai_message.rs`),路径不变。
**设计决策**:搬入 `openai.rs` 后的类型可见性可降级为 `pub(crate)`。它们是与 OpenAI wire-format 绑定的内部序列化类型,公共 API 消费者不应直接接触。
**验证**`cargo build --all-targets` + `cargo test` + `cargo clippy`
### 3.5 Step 13.2 — response.rs 类型移入 openai.rs
#### 变更文件
| 文件 | 变更 |
|------|------|
| `src/llm/types/response.rs` | **整文件删除**177 行) |
| `src/llm/provider/openai.rs` | 新增 `TokenLogprob``TopLogprob``Logprobs``URLCitation``Annotation``OpenaiAudio``Choice``OpenaiChatResponse``Delta``ChunkChoice``OpenaiChatChunk` + `From<OpenaiChatMessage> for Delta` + `From<OpenaiChatResponse> for OpenaiChatChunk` |
| `src/llm/types/mod.rs` | 删除 `pub use response::{...}`;删除 `pub mod response;` |
| `src/llm/stream.rs:26` | 将 `use crate::llm::types::{OpenaiChatChunk, OpenaiToolCall}` 中的 `OpenaiChatChunk` 路径改为 `crate::llm::provider::openai::OpenaiChatChunk``OpenaiToolCall` 保持从 `tool.rs` |
**验证**`cargo build --all-targets` + `cargo test` + `cargo clippy`
### 3.6 Step 13.3 — 旧文件清理 + ChatResponse 删除
#### 13.3a — 删除 `old_stream.rs`
> **前置验证**:实施前执行 `grep -rn 'parse_chunk_stream\|map_legacy_to_ir\|LegacyToIrEventStream\|ChunkToLegacyEventStream' src/` 确认零外部调用方,记录结果到实施 commit。
| 文件 | 变更 |
|------|------|
| `src/llm/types/old_stream.rs` | **整文件删除**45 行,`LegacyStreamEvent` |
| `src/llm/types/mod.rs` | 删除 `pub mod old_stream;` |
| `src/llm/stream.rs` | 删除 `use crate::llm::types::old_stream::LegacyStreamEvent`;删除 `parse_chunk_stream``parse_chunk_stream_legacy``ChunkToLegacyEventStream``LegacyToIrEventStream``map_legacy_to_ir``empty_message_response`~160 行死代码) |
**stream.rs 最终形态**
```rust
//! 流式事件系统 —— 重导出 StreamEvent 供向后兼容。
pub use crate::llm::types::response_v2::StreamEvent;
```
**为什么不全删 stream.rs**`cycle.rs``session.rs``use crate::llm::stream::StreamEvent` 路径保持不变。全删 + 改所有 import 路径的改动量 > 收益。保留 1 行重导出就够。
#### 13.3b — 删除 `ChatResponse`
| 文件 | 变更 |
|------|------|
| `src/llm/types/mod.rs` | 删除 `ChatResponse` 结构体定义 + 两个 `#[allow(deprecated)]` `From` impl`From<OpenaiChatResponse> for ChatResponse``From<ChatResponse> for OpenaiChatChunk` |
`ChatResponse` 自 v0.1.0 起标记 `#[deprecated]`v0.2.0-rc.1 阶段直接删除即可。删除前运行 `cargo doc --no-deps 2>&1 | grep -i 'ChatResponse'` 确认零文档引用。
**验证**`cargo build --all-targets` + `cargo test` + `cargo clippy` + `cargo doc --no-deps`
---
## 4. 实现计划
### 4.1 实施顺序总览
```
Step 13.5 ──→ Step 13.4 ──→ Step 13.1 ──→ Step 13.2 ──→ Step 13.3
(fork/merge) (ToolChoice) (request) (response) (cleanup)
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
+60 行净增 -0 净增 -0 净增 -0 净增 -260 删除
+9 个测试 import 路径 纯类型搬移 纯类型搬移 +1 行重导出
变更
```
### 4.2 各 Step 文件变更清单
#### Step 13.5 — ContextSlot fork/merge
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 新增 | `src/agent/context.rs` | `MergeStrategy` 枚举 + `ContextSlot::fork()` + `ContextSlot::merge()` |
| 重构 | `src/agent/session.rs` | `derive_slot` 改为调用 `parent.fork()` |
| 新增 | 内联测试 | 9 个新测试(fork, merge, 边界) |
#### Step 13.4 — ToolChoice 移动
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 删除 | `src/llm/types/request.rs` | 移除 `ToolChoice` 枚举 + serde impl |
| 新增 | `src/llm/types/tool.rs` | 增加 `ToolChoice` 枚举 + serde impl |
| 修改 | `src/llm/types/mod.rs` | 更新 re-export 路径 |
| 修改 | `src/llm/types/request_v2.rs` | 更新 import 路径 |
#### Step 13.1 — request 类型搬移
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 删除 | `src/llm/types/request.rs` | 整文件删除(187 行) |
| 新增 | `src/llm/provider/openai.rs` | 增加所有 OpenAI wire-format 类型 |
| 修改 | `src/llm/types/mod.rs` | 删除 re-export + mod 声明 |
#### Step 13.2 — response 类型搬移
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 删除 | `src/llm/types/response.rs` | 整文件删除(177 行) |
| 新增 | `src/llm/provider/openai.rs` | 增加所有 OpenAI wire-format 类型 + From impl |
| 修改 | `src/llm/types/mod.rs` | 删除 re-export + mod 声明 |
| 修改 | `src/llm/stream.rs` | 更新 `OpenaiChatChunk` import 路径 |
#### Step 13.3 — 旧文件清理
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 删除 | `src/llm/types/old_stream.rs` | 整文件删除(45 行) |
| 修改 | `src/llm/types/mod.rs` | 删除 `pub mod old_stream;` + 删除 `ChatResponse` 结构体 + `From` impl |
| 修改 | `src/llm/stream.rs` | 删除所有死代码,仅保留 `pub use` 重导出 |
### 4.3 回滚策略
所有 Step 通过 git commit 管理,回退时 `git revert <commit>` 即可。每个 Step 独立编译,回滚不会级联依赖。若 Step 13.3(`ChatResponse` 删除)导致外部编译失败,单独 revert 该 commit 即可恢复 `ChatResponse` + `old_stream.rs`
### 4.4 CHANGELOG 条目
```markdown
## [0.3.0] - 未发布
### Breaking Changes
**类型路径变更(0.3.0):**
- `agcore::llm::types::request::ToolChoice``agcore::llm::types::tool::ToolChoice`(公共 re-export 路径 `agcore::llm::types::ToolChoice` 保持不变)
- `agcore::llm::types::request::StreamOptions``agcore::llm::provider::openai::StreamOptions`
- `agcore::llm::types::request::OpenaiChatRequest``agcore::llm::provider::openai::OpenaiChatRequest`
- `agcore::llm::types::response::OpenaiChatResponse``agcore::llm::provider::openai::OpenaiChatResponse`
- `agcore::llm::types::response::OpenaiChatChunk``agcore::llm::provider::openai::OpenaiChatChunk`
- 其余 `request.rs`/`response.rs` 中的 wire-format 类型(`OpenaiTool``AudioParam``Choice``Delta` 等)同步移入 `agcore::llm::provider::openai` 模块
**类型删除:**
- `agcore::llm::types::ChatResponse` 已删除(自 v0.1.0 标记 `#[deprecated]`,请改用 `MessageResponse`
- `agcore::llm::types::old_stream::LegacyStreamEvent` 已删除(内部死代码)
### Features
- `ContextSlot::fork(child_id, strategy)` — 从父槽派生独立的子槽(数据层操作)
- `ContextSlot::merge(child, strategy)` — 将子槽消息合并回父槽(支持 Append/Replace
- `MergeStrategy` 枚举(`#[non_exhaustive]`Phase 16 可扩展 Summarize
```
---
## 5. 风险评估
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| `ChatResponse` 被外部 crate 引用 | 编译 break | 中 — `#[deprecated]` 仅产生编译警告,外部 crate 可能通过 `#[allow(deprecated)]` 静默依赖 | CHANGELOG 明确标注语义版本(0.3.0)和迁移指引;Step 13.3 验收加入 `cargo doc --no-deps \| grep ChatResponse` 确认零引用 |
| `StreamOptions` 等 wire-format 类型路径变更影响直接引用消费者 | 编译 break | 低(v0.2.0-rc.1,极少外部消费者使用内部类型) | CHANGELOG 完整列出所有路径变更;编译错误立即可发现 |
| `parse_chunk_stream` 有隐藏调用方 | 编译 break | 极低(实施前执行 `grep -rn 'parse_chunk_stream\|map_legacy_to_ir\|LegacyToIrEventStream' src/` 前置验证) | Step 13.3 前运行 grep 验证并记录结果;`cargo build --all-targets` 可 100% 捕获 |
| `#[allow(deprecated)]` 遗漏 | clippy 警告 | 低 | `cargo clippy --all-targets -- -D warnings` 验证 |
| Step 顺序错误导致编译中间态 | 开发者体验差 | 中 | 严格按 13.5→13.4→13.1→13.2→13.3 执行;每步 `cargo build` 验证 |
| `stream.rs` 简化后 import 断链 | 编译 break | 极低 | 保留 `pub use` 重导出路径,`cycle.rs`/`session.rs` import 不变 |
---
## 6. 验收标准
### M9 里程碑(Phase 13 完成条件)
| # | 条件 | 验证方法 |
|---|------|---------|
| 1 | `request.rs``response.rs``old_stream.rs` 三个旧文件不存在 | `ls src/llm/types/` 确认 |
| 2 | `ChatResponse` 结构体不存在 | 全局搜索 `ChatResponse` 仅保留 `openai.rs``OpenaiChatResponse` 引用 |
| 3 | `ToolChoice``tool.rs` 中定义,公共路径 `agcore::llm::types::ToolChoice` 保持不变 | `cargo doc --no-deps` 确认类型文档 |
| 4 | `OpenaiChatRequest`/`Response`/`Chunk``provider/openai.rs` 中定义 | 编译通过 |
| 5 | `ContextSlot::fork()` 单元测试通过(P0 条件全部满足) | `cargo test` |
| 6 | `ContextSlot::merge()` 单元测试通过(P0 条件全部满足) | `cargo test` |
| 7 | `stream.rs` 只保留 `pub use` 重导出 | 文件内容确认 |
| 8 | `cargo build --all-targets` 编译通过 | 编译验证 |
| 9 | `cargo test --all-targets` 全绿(预期 283~285 测试) | 测试验证 |
| 10 | `cargo clippy --all-targets -- -D warnings` 0 警告 | clippy 验证 |
| 11 | CHANGELOG 包含 Phase 13 的 Breaking Changes 和 Features 条目 | 文件确认 |
### fork/merge 详细验收 P0 项
**fork 的 5 项 P0 条件:**
| # | 条件 | 优先级 |
|---|------|--------|
| 1 | `fork("child", Full)` 创建新 slot,消息在 fork 时刻 == 父 slot | P0 |
| 2 | 子 slot 获得独立消息列表——父 slot 后续追加不影响子 slot | P0 |
| 3 | 子 slot 的 source 标记为 `Derived { parent_id, strategy }` | P0 |
| 4 | 子 slot 可独立持久化(fork + save + load roundtrip | P0 |
| 5 | fork 不允许重复 id(返回 `SlotAlreadyExists`)(由 `derive_slot` 编排层保证) | P0 |
**merge 的 5 项 P0 条件:**
| # | 条件 | 优先级 |
|---|------|--------|
| 1 | `parent.merge(child, Append)` 子消息追加到父末尾 | P0 |
| 2 | `parent.merge(child, Replace)` 子消息替换父全量消息 | P0 |
| 3 | merge 后父 slot 的 `meta.message_count` 正确更新 | P0 |
| 4 | merge 不允许合并到 Readonly 目标 slot | P0 |
| 5 | merge 不允许 self-mergechild.id == parent.id | P0 |
---
## 参考来源
- Roadmap`docs/roadmap.md` §Phase 13
- ContextSlot 设计:`docs/17-phase10-contextslot.md`
- 旧 StreamEvent 设计:`src/llm/stream.rs` 文件注释
- 当前代码库:`src/llm/types/request.rs``src/llm/types/response.rs``src/llm/types/old_stream.rs``src/llm/types/mod.rs``src/llm/provider/openai.rs``src/agent/context.rs``src/agent/session.rs`
---
## 7. 实施计划
### 全局说明
**commit 策略**:每个 Step 一个独立 commit。commit message 格式:
```
<type>(<scope>): <中文描述>
```
- Step 13.5 → `feat(agent): 实现 ContextSlot fork/merge`
- Step 13.4 → `refactor(types): ToolChoice 移入 tool.rs`
- Step 13.1 → `refactor(types): request.rs 类型移入 provider/openai.rs`
- Step 13.2 → `refactor(types): response.rs 类型移入 provider/openai.rs`
- Step 13.3 → `refactor(types): 删除旧类型文件和 ChatResponse`
**验证命令(每步通用)**
```bash
cargo build --all-targets && cargo test && cargo clippy --all-targets -- -D warnings
```
**预计测试数量变化**
- 当前基线:277 测试(每个 Step 开始时 `cargo test` 确认)
- Step 13.5 后:286+9
- Step 13.4-13.2 后:286(无变化)
- Step 13.3 后:285-1`ChatResponse``From` impl 无测试直接引用,删除后仅 `types/mod.rs` 中的 `deprecated` 注释行减少,不影响测试计数。实施前执行 `grep -rn 'ChatResponse' src/ --include='*test*' --include='*tests*'` 确认零测试引用)
- 最终范围:285 测试
### Step 13.5 — ContextSlot fork/merge
**前置依赖**:无(纯新增,不依赖前序 Step
**任务描述**:在 `agent/context.rs` 中新增 `MergeStrategy` 枚举、`ContextSlot::fork()` 方法和 `ContextSlot::merge()` 方法;重构 `agent/session.rs` 中的 `derive_slot` 改为调用 `parent.fork()`;新增 9 个内联测试覆盖 fork/merge 的 happy path 和 error path。
**涉及文件**
- `src/agent/context.rs` — 新增枚举和方法
- `src/agent/session.rs` — 重构 derive_slot
- `src/agent.rs` — 追加 `MergeStrategy` re-export
**具体操作**
1.`context.rs` 中新增 `MergeStrategy` 枚举(Append / Replace`#[non_exhaustive]`
2.`context.rs``impl ContextSlot` 块内新增 `fork(&self, child_id: String, strategy: DeriveStrategy) -> ContextSlot` 方法
3.`context.rs``impl ContextSlot` 块内新增 `merge(&mut self, child: ContextSlot, strategy: MergeStrategy) -> Result<(), AgentError>` 方法(含 self-merge/cross-session/Readonly 三项防御检查 + `tracing::debug!` 日志)
4.`session.rs``derive_slot` 方法中将手工消息复制代码替换为 `parent.fork(slot_id, strategy)`
5.`agent.rs``pub use context::{...}` 列表中追加 `MergeStrategy`
6.`context.rs``#[cfg(test)] mod tests` 中新增 9 个测试用例
**注意**:重构后 `derive_slot` 的子 slot `budget``ContextBudget::default()` 变为继承父 slot`compact``true` 变为继承父 slot。由于 `ContextBudget` 在 v0.2 无消费逻辑且父 slot 的 `compact` 默认也为 `true`,此变化无实际影响。验收条件中"行为不变"指对外功能行为不变(slot 消息内容、血缘关系不变)。
**预估工作量**M1-4h
**风险等级**:低(纯新增,不修改已有逻辑路径)
**验收条件**
- `MergeStrategy` 枚举存在,`Append``Replace` 两个变体可用,且通过 `agcore::agent::MergeStrategy` 路径可访问
- `ContextSlot::fork` 返回的 child 在 fork 时刻消息等于父 slot
- fork Focused 策略按 `FocusedConfig` 过滤消息
- 父 slot 后续追加消息不影响子 slot
- 子 slot 的 source 正确记录 `Derived { parent_id, strategy }`
- `parent.merge(child, Append)` 追加到父末尾,message_count 正确
- `parent.merge(child, Replace)` 替换父全量消息,message_count 正确
- self-merge 返回 `Err(AgentError::Config)`
- merge 到 Readonly slot 返回 `Err(AgentError::SlotReadonly)`
- 跨 session merge 返回 `Err(AgentError::Config)`
- `derive_slot` 对外行为不变(slot 消息内容、血缘关系、持久化行为均不变;内部 budget/compact 继承差异无实际影响),测试全绿
- `cargo doc --no-deps` 无 warning(验证新增公开 API 的文档注释完整)
**回退方式**`git revert` 该 commit
### Step 13.4 — ToolChoice 移入 tool.rs
**前置依赖**:Step 13.5(顺序约束:必须早于 Step 13.1——若 Step 13.1 先执行会将 `ToolChoice``request.rs` 一同删除,导致本 Step 无可搬移的源)
**任务描述**:将 `ToolChoice` 枚举及其 serde 实现从 `types/request.rs` 搬移到 `types/tool.rs`,更新所有 import/path 引用。公共 re-export 路径 `agcore::llm::types::ToolChoice` 保持不变。
**涉及文件**
- `src/llm/types/request.rs` — 删除 ToolChoice~28-99 行)
- `src/llm/types/tool.rs` — 新增 ToolChoice 枚举 + serde impl
- `src/llm/types/mod.rs` — re-export 路径从 `request` 改为 `tool`
- `src/llm/types/request_v2.rs` — import 路径从 `request::` 改为 `tool::`
**具体操作**
1.`request.rs` 复制 `ToolChoice` 枚举 + `Serialize`/`Deserialize` impl 到 `tool.rs`
2.`request.rs` 中删除 `ToolChoice` 定义
3.`mod.rs` 中将 `pub use request::{..., ToolChoice}` 改为 `pub use tool::ToolChoice`
4.`request_v2.rs` 中将 `use crate::llm::types::request::ToolChoice` 改为 `use crate::llm::types::tool::ToolChoice`
5. 验证 `cycle.rs``use crate::llm::types::ToolChoice`(通过 re-export)路径不变
**预估工作量**S<1h
**风险等级**:低(有限的 import 路径变更,编译立即可发现)
**验收条件**
- `ToolChoice``tool.rs` 中定义
- `pub use tool::ToolChoice``mod.rs`
- `request_v2.rs` 编译通过
- `cycle.rs` 路径不变
- `cargo build --all-targets` + `cargo test` + `cargo clippy` 全绿
**回退方式**`git revert` 该 commit
### Step 13.1 — request.rs 类型移入 openai.rs
**前置依赖**Step 13.4ToolChoice 已移走,request.rs 剩余内容全是 OpenAI wire-format 专有类型)
**任务描述**:删除 `types/request.rs` 整文件,将所有剩余类型(`OpenaiChatRequest``StreamOptions``OpenaiTool``AudioParam``PredictionContent``UserLocation``Approximate``WebSearchOptions`)搬入 `provider/openai.rs`,更新 `mod.rs` re-export。
**涉及文件**
- `src/llm/types/request.rs` — 整文件删除
- `src/llm/provider/openai.rs` — 新增所有类型定义
- `src/llm/types/mod.rs` — 删除 re-export + mod 声明
**具体操作**
1.`request.rs` 复制所有剩余类型定义到 `openai.rs`,可见性设为 `pub(crate)`
2. `OpenaiTool` 内引用 `OpenaiToolDefinition`(定义在 `tool.rs`),路径改为 `crate::llm::types::tool::OpenaiToolDefinition`
3. 删除 `openai.rs` 中原 `use crate::llm::types::request::{...}` import
4.`mod.rs` 删除 `pub use request::{OpenaiChatRequest, OpenaiTool, StreamOptions}``pub mod request;`
5. 删除 `types/request.rs` 文件
**预估工作量**M1-4h
**风险等级**:低(纯搬移 + 删除,文件内无逻辑变更)
**验收条件**
- `request.rs` 文件不存在
- `OpenaiChatRequest` 等类型在 `openai.rs` 中定义,编译通过
- `OpenaiTool` 通过 `crate::llm::types::tool::OpenaiToolDefinition` 正确引用
- `cargo build --all-targets` + `cargo test` + `cargo clippy` 全绿
**回退方式**`git revert` 该 commit。若 Step 13.2 也已提交,单独 revert 本 Step 可能因 `provider/openai.rs` 并发修改产生合并冲突。安全回退顺序为逆序:先 revert 13.2,再 revert 13.1。
### Step 13.2 — response.rs 类型移入 openai.rs
**前置依赖**:无(与 Step 13.1 共享 `provider/openai.rs``types/mod.rs`,但本 Step 仅追加类型定义,无覆盖操作;建议在 13.1 之后顺序执行以避免并行时的合并冲突)
**任务描述**:删除 `types/response.rs` 整文件,将所有类型(`OpenaiChatResponse``OpenaiChatChunk``Choice``Delta``ChunkChoice` 等 + 两个 `From` impl)搬入 `provider/openai.rs`,更新 `mod.rs``stream.rs` 的 import 路径。
**涉及文件**
- `src/llm/types/response.rs` — 整文件删除
- `src/llm/provider/openai.rs` — 新增所有类型定义 + From impl
- `src/llm/types/mod.rs` — 删除 re-export + mod 声明
- `src/llm/stream.rs``OpenaiChatChunk` import 路径改为 `provider::openai`
**具体操作**
1.`response.rs` 复制所有类型定义(含 `From` impl)到 `openai.rs`,可见性设为 `pub(crate)`
2. 删除 `openai.rs` 中原 `use crate::llm::types::response::{...}` import
3.`mod.rs` 删除 `pub use response::{...}``pub mod response;`
4.`stream.rs:26``OpenaiChatChunk` 的 import 路径改为 `crate::llm::provider::openai::OpenaiChatChunk``OpenaiToolCall` 路径不变)
5. 删除 `types/response.rs` 文件
**预估工作量**M1-4h
**风险等级**:低(与 Step 13.1 模式完全相同)
**验收条件**
- `response.rs` 文件不存在
- `OpenaiChatResponse`/`Chunk` 等类型在 `openai.rs` 中定义,编译通过
- `stream.rs` import 路径正确
- `cargo build --all-targets` + `cargo test` + `cargo clippy` 全绿
**回退方式**`git revert` 该 commit。若 Step 13.1 和本 Step 均已提交,安全回退顺序为逆序:先 revert 本 Step,再 revert 13.1。
### Step 13.3 — 旧文件清理 + ChatResponse 删除
**前置依赖**Step 13.1`request.rs` 已删)、Step 13.2`response.rs` 已删)
**任务描述**:删除 `old_stream.rs``ChatResponse`,简化 `stream.rs` 为仅保留 `pub use` 重导出。这是 Phase 13 技术风险最高的 Step。
**涉及文件**
- `src/llm/types/old_stream.rs` — 整文件删除
- `src/llm/types/mod.rs` — 删除 `pub mod old_stream;` + 删除 `ChatResponse` 结构体和两个 `From` impl
- `src/llm/stream.rs` — 删除死代码(约 160 行),仅保留 `pub use` 重导出
**具体操作**
1. **前置验证 A**:执行 `grep -rn 'parse_chunk_stream\|map_legacy_to_ir\|LegacyToIrEventStream\|ChunkToLegacyEventStream' src/` 确认零外部调用方,记录结果到 commit message
2. **前置验证 B**:执行 `cargo doc --no-deps 2>&1 | grep -i 'ChatResponse'` 确认零文档引用,记录结果
3.`mod.rs` 删除 `pub mod old_stream;`
4.`mod.rs` 删除 `ChatResponse` 结构体定义 + `#[allow(deprecated)]` `From<OpenaiChatResponse> for ChatResponse` + `From<ChatResponse> for OpenaiChatChunk`
5. 删除 `old_stream.rs` 文件
6.`stream.rs` 删除:`use crate::llm::types::old_stream::LegacyStreamEvent``parse_chunk_stream``parse_chunk_stream_legacy``ChunkToLegacyEventStream``LegacyToIrEventStream``map_legacy_to_ir``empty_message_response`
7. `stream.rs` 最终只保留 module doc comment + `pub use crate::llm::types::response_v2::StreamEvent;`
8. 检查 `cycle.rs:88``#[allow(deprecated)]` 属性是否仍与 `ChatResponse` 相关——若不相关则无需改动;若因 `ChatResponse` 删除而变脏,清理该属性
**预估工作量**S<1hcleanup+ M(需验证过程)
**风险等级**:中(`ChatResponse` 删除是 Breaking Change,外部可能静默依赖)
**验收条件**
- `old_stream.rs` 文件不存在
- `ChatResponse` 结构体不存在(全局搜索仅保留 `OpenaiChatResponse` 引用)
- `stream.rs` 只保留 `pub use` 重导出
- `cargo build --all-targets` 编译通过
- `cargo test --all-targets` 全绿(预期 285 测试)
- `cargo clippy --all-targets -- -D warnings` 0 警告
- `cargo doc --no-deps` 无 warning
**回退方式**`git revert` 该 commit(单独 revert 即可恢复 `ChatResponse` + `old_stream.rs`
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,471 @@
# Phase 16 — 摘要自动生成
## 背景与目标
### 问题
长对话场景中,用户与 Agent 交互 30+ 轮后,消息历史长度远超模型上下文窗口,导致:
- LLM 被迫丢弃早期上下文,对话丧失连贯性
- 开发者需要手动管理摘要逻辑(调 LLM → 写 SessionMemory → 注入 FocusedConfig
- v0.2 的 `FocusedConfig.summary_override` 消费端已就绪,但生产端是空的——用户只能手动设字符串
### 目标
闭环长对话的"上下文压缩"链路:
```
[消费端 v0.2 已就绪] FocusedConfig.summary_override → filter_focused() 注入摘要
[生产端 v0.3 补齐] token 水位检测 → LLM 摘要生成 → 自动写入 summary_override
```
### 成功标准
1. 开发者只需在 `AgentBuilder` 中链式调用 `.summary_config(cfg)` 即可启用
2. 长对话(如 30+ 轮或 token 水位超过 `max_context_tokens * trigger_token_ratio`)自动触发摘要,下轮 `load_messages()` 返回值包含 `[上下文摘要] {summary}`
3. 摘要生成不改变 `submit_turn` 行为(opt-in、静默失败、不阻断主流程)
4. 零新外部依赖
---
## 需求分析
### 功能需求
| # | 需求 | 优先级 | 说明 |
|---|------|--------|------|
| F1 | `SummaryConfig` 配置结构体 | P0 | `trigger_token_ratio` / `max_context_tokens` / `summary_prompt` / `debounce_turns` / `summary_model` / `max_tool_result_chars` |
| F2 | Token 水位自动检测 | P0 | 每轮 OnTurnEnd 之后检查 `cost_so_far` 是否超过 `max * ratio` |
| F3 | LLM 摘要生成 | P0 | 复用 `self.bundle.provider`,单次无工具 LLM 调用 |
| F4 | 摘要写入 FocusedConfig | P0 | 更新 `summary_override` + `slot.save()` 持久化 |
| F5 | 摘要全局快照 | P0 | 同步写入 `SessionMemory::set("conversation_summary", summary)` |
| F6 | 防抖机制 | P0 | 两次摘要之间至少间隔 `debounce_turns` 轮(默认 3 |
| F7 | 流式路径对称支持 | P0 | `finalize_turn` 中插入相同检查点 |
| F8 | 公开 API`get_conversation_summary()` | P1 | 读取 SessionMemory 中最新的摘要 |
### 非功能需求
| # | 需求 | 指标 |
|---|------|------|
| N1 | 零外部依赖 | 不修改 `Cargo.toml` |
| N2 | 向后兼容 | 未设置 `SummaryConfig` 时行为零变化 |
| N3 | 静默失败 | 摘要 LLM 调用失败不阻断 `submit_turn` |
| N4 | 摘要延迟 | 首次摘要 LLM 调用 ≤ 3s(依赖 provider 响应速度) |
---
## 当前状态分析
### 消费端已就绪
`FocusedConfig.summary_override``src/agent/context.rs`)已在 Phase 10 实现,当前消费逻辑:
```
filter_focused() → 若 cfg.summary_override = Some(text) → 在消息列表末尾插入
Message::system("[上下文摘要] {text}")
```
文档注释明确标注:`// v0.3 将支持 Hook 驱动的自动摘要生成`
### 代码上下文
| 模块 | 文件 | 状态 | 与 Phase 16 的关系 |
|------|------|------|-------------------|
| FocusedConfig | `agent/context.rs` | ✅ 消费端 | 摘要写入 `summary_override` 即生效 |
| OnTurnEnd | `agent/session.rs:345` | ✅ 触发点 | 摘要检查点插在此之后 |
| CostTracker | `llm/cycle/usage.rs` | ✅ 累计 token | 水位检测的数据源 |
| SessionMemory | `agent/session_memory.rs` | ✅ set/get | 摘要全局快照存储 |
| AgentBuilder | `agent/builder.rs` | ✅ 链式构造 | 新增 `.summary_config()` |
| AgentConfig | `agent/runtime.rs` | ✅ 配置结构 | 新增 `summary_config` 字段 |
| LlmProvider | `llm/provider.rs` | ✅ Trait | 摘要 LLM 调用复用 provider |
| ContextSlot.save | `agent/context.rs:251` | ✅ 持久化 | 更新 config 后写回 |
---
## 可选方案推演
### 方案 A(推荐):内联检查点
**做法**:在 `submit_turn``finalize_turn` 中,OnTurnEnd 触发之后、`turn_index` 递增之前,插入以下逻辑:
```rust
if let Some(ref sc) = self.bundle.config.summary_config
&& self.should_summarize(sc)
{
// clone 所需数据(释放 &self 借用)
let provider = Arc::clone(&self.bundle.provider);
let messages = self.slots.get(&self.current_slot_id)
.map(|s| s.messages.clone()).unwrap_or_default();
let prompt = sc.summary_prompt.clone();
let model = sc.summary_model.clone();
// 调关联函数(不持有 &self)
match Self::generate_summary(&provider, &messages, &prompt, model.as_deref(), sc.max_tool_result_chars).await {
Ok(text) => {
// 更新 FocusedConfig + 持久化
if let Some(slot) = self.slots.get_mut(&self.current_slot_id) {
if let SlotMode::Focused(ref mut cfg) = slot.config.mode {
cfg.summary_override = Some(text.clone());
}
let _ = slot.save(&*self.resolve_store()).await;
}
// 全局快照
let _ = self.session_memory.set("conversation_summary", &text).await;
self.last_summary_turn = self.turn_index;
}
Err(e) => tracing::error!("摘要自动生成失败 (turn={}): {}", self.turn_index, e),
}
}
```
**优点**
- 代码路径最短最清晰(~50 行核心逻辑)
- 直接访问所有需要的数据(`cost_so_far``slots``provider``session_memory`
- 流式和同步版本统一处理
- `Option<SummaryConfig>` 本身已提供 opt-in/opt-out
- 不改变 Hook 系统签名
**缺点**
- 摘要 LLM 调用延长了 `submit_turn` 的延迟(约 1-3s
- 违反"Hook 哲学"(但 `Option` 配置已足够提供可插拔性)
### 方案 B(否决):扩展 HookContext
**做法**:在 `HookContext` 中增加 `messages: &[Message]``usage: &Usage``provider: Arc<dyn LlmProvider>` 字段,让 OnTurnEnd Hook 实现者自行做摘要。
**否决原因**
1. **生命周期冲突**`&[Message]` 要求 Hook 调用点消息已就绪但未被 `&mut self` 借用——在 `submit_turn` 第 7 步(slot.save)后消息已就绪,但 to pass `&[Message]` 到 HookContext 需要与 `slot.messages` 的不可变引用共存,而 `submit_turn` 流程中后续步骤需要 `&mut self`
2. **流式路径不可行**`finalize_turn` 触发 OnTurnEnd 时 cycle 已销毁,消息只能从 slot 获取,但 slot 在 `append_messages` 后已被 `&mut` 借用
3. **`Arc<dyn LlmProvider>``'static` 需求**与 `HookContext<'a>` 的设计冲突
### 方案 C(否决):后台 spawn 异步摘要
**做法**token 检测通过后,`tokio::spawn` 后台任务做摘要生成和写入。
**否决原因**
1. **写入冲突**:后台任务无法获取 `&mut AgentSession` 来更新 slot config
2. **绕过方式增加复杂度**:后台任务需要直接操作 `Arc<dyn MemoryStore>` 的原始 key`slot_config:{session_id}:{slot_id}`),绕过了 `ContextSlot::save()` 的封装
3. **并发风险**:如果前一轮摘要尚未完成而下一轮 `finalize_turn` 又触发,可能导致覆盖写
---
## 推荐方案(内联检查点)
### 架构图
```
submit_turn(user_input)
├─ 1. Readonly 检查
├─ 2. OnTurnStart hook
├─ 3. slot.load_messages() ← 历史摘要已注入(如有)
├─ 4. LlmCycle.submit_with_tools
├─ 5. cost_so_far.add(usage)
├─ 6. slot.append_messages + save
├─ 7. OnTurnEnd hook ← 纯通知,不做摘要
├─ [8.5] 摘要检查点 ──────────────────────────────┐
│ ├─ should_summarize(cfg) │
│ │ ├─ cost_so_far >= max * ratio? │
│ │ └─ turn - last_summary >= debounce? │
│ │ │
│ ├─ generate_summary() ← 新 LlmCycle │
│ │ ├─ format_messages_as_text() │
│ │ ├─ replace {messages} │
│ │ └─ submit_messages(无 tools) │
│ │ │
│ └─ 成功 → 更新 summary_override + save │
│ → SessionMemory.set() │
│ → last_summary_turn = turn_index │
│ (流式路径用 saturating_sub(1) 修正) │
│ 失败 → tracing::error! 静默 │
│ │
├─ 9. turn_index++
└─ 10. return Ok(response)
```
### 模块划分
**新增文件**`src/agent/summary.rs`
```
src/agent/summary.rs
├── SummaryConfig // 摘要自动生成配置
├── format_messages_as_text() // 消息 → 纯文本(简洁版)
└── DEFAULT_SUMMARY_PROMPT // 默认 prompt 模板
```
**修改文件**
| 文件 | 改动 |
|------|------|
| `agent/runtime.rs` | `AgentConfig` 新增 `summary_config: Option<SummaryConfig>` |
| `agent/builder.rs` | 新增 `summary_config(cfg)` 方法 |
| `agent/session.rs` | 新增 `last_summary_turn` 字段;`submit_turn` / `finalize_turn` 插入检查点;关联函数 `generate_summary``get_conversation_summary()` |
| `agent.rs` | `pub mod summary` + re-export |
**不变的文件**(无需改动):
| 文件 | 原因 |
|------|------|
| `llm/hooks.rs` | 内联方案不扩展 HookContext |
| `llm/cycle.rs` | 摘要调用通过 `submit_messages` 独立使用 |
| `agent/context.rs` | `FocusedConfig` 消费端已在 Phase 10 就绪 |
| `Cargo.toml` | 零新外部依赖 |
### 核心接口定义
**`SummaryConfig`**`agent/summary.rs`):
```rust
#[derive(Debug, Clone)]
pub struct SummaryConfig {
/// Token 水位触发比例(0.0 ~ 1.0)。默认 0.75。
pub trigger_token_ratio: f64,
/// 模型上下文窗口大小(token)。默认 32_000,覆盖大部分开源模型。
/// 修改为匹配实际使用模型的上下文窗口。
/// ⚠️ 设置为超过模型窗口的值会导致摘要永远不触发。
pub max_context_tokens: u32,
/// 摘要 prompt 模板。`{messages}` 将被替换为对话历史文本。
pub summary_prompt: String,
/// 防抖轮次。默认 3。
pub debounce_turns: u32,
/// 摘要生成模型(None = 沿用主 provider 默认模型)。
/// 默认 None。推荐设为便宜模型(如 "gpt-4o-mini")以节省成本。
pub summary_model: Option<String>,
/// 单个 ToolResult 在格式化时保留的最大字符数。默认 500。
/// 超过此值从尾部截断。字符级安全(`chars().take()`)。
pub max_tool_result_chars: usize,
}
```
**`generate_summary`**`AgentSession` 关联函数):
```rust
impl AgentSession {
async fn generate_summary(
provider: &Arc<dyn LlmProvider>,
messages: &[Message],
prompt_template: &str,
summary_model: Option<&str>,
max_tool_result_chars: usize,
) -> Result<String, LlmError> { ... }
}
```
**`should_summarize`**`AgentSession` 方法):
```rust
fn should_summarize(&self, cfg: &SummaryConfig) -> bool {
self.turn_index - self.last_summary_turn >= cfg.debounce_turns
&& self.cost_so_far.total().total_tokens as f64
>= cfg.max_context_tokens as f64 * cfg.trigger_token_ratio
}
```
### 消息格式化(简洁版)
`format_messages_as_text` 输出格式:
```
System: 你是一个翻译助手
User: 把这段英文翻译成中文
Assistant: 请提供英文文本 [Tool: translate]
Tool Result: 这是中文翻译
User: 谢谢
Assistant: 不客气
```
处理规则:
- `ContentBlock::Text { text }` → 直接拼接
- `ContentBlock::ToolUse { name, .. }``[Tool: {name}]`(不显示参数 JSON
- `Message::ToolResult { content, is_error, tool_call_id }``Tool Result [{tool_call_id}]:` / `Tool Error [{tool_call_id}]:`,便于多工具场景下关联调用的返回
- ToolResult 文本截断到前 `max_tool_result_chars` 个 Unicode 字符(`chars().take(n)`,字符级安全,避免多字节截断)
- 整段对话若超过 30K 字符,从前面截断(优先保留最新消息)
- `Message::UserImage { .. }``User: [image]`
- 非 Text blockImage / Audio / File 等)统一标记为 `[{kind}]`
- 每条消息一行,空行分隔
---
## 实现计划
### Step 16.1 — `SummaryConfig` 结构体
**文件**:新增 `src/agent/summary.rs`
**内容**
- `SummaryConfig` 结构体定义(6 个字段 + doc comments
- `DEFAULT_SUMMARY_PROMPT` 常量(约 100 字中文 prompt,含 `{messages}` 占位符)
- `impl Default for SummaryConfig`
- `format_messages_as_text(messages: &[Message]) -> String` 辅助函数
**验证**`cargo build`
### Step 16.2 — `AgentConfig` 扩展 + `AgentBuilder` 方法
**文件**`src/agent/runtime.rs` + `src/agent/builder.rs`
**改动**
- `AgentConfig` 新增字段:`pub summary_config: Option<SummaryConfig>`
- `AgentBuilder` 新增方法:
```rust
pub fn summary_config(mut self, cfg: SummaryConfig) -> Self {
let mut config = self.config.take().unwrap_or_default();
config.summary_config = Some(cfg);
self.config = Some(config);
self
}
```
**验证**`AgentBuilder` 单元测试 + `cargo test`
### Step 16.3 — `AgentSession` 新字段 + 检查点
**文件**`src/agent/session.rs`
**改动**
1. `AgentSession` 新增字段:`last_summary_turn: u32`(初始化 0
2. `submit_turn` 中 OnTurnEnd 之后、turn_index 之前插入检查点
3. `finalize_turn` 中 OnTurnEnd 之后插入对称检查点。注意:流式路径中 `turn_index` 已在 `submit_turn_stream` 中递增,检查点赋值使用 `self.turn_index.saturating_sub(1)`(与 `OnTurnEnd` hook 保持一致)。
4. 关联函数 `generate_summary`
- 接收 `provider`、`messages`、`prompt_template`、`summary_model`、`max_tool_result_chars`
- 入口守卫:`messages.is_empty()` 时直接返回 `Ok(String::new())`
- 构造 `LlmCycle``max_tokens = Some(1024)`
- 调 `cycle.submit_messages(vec![Message::user_text(prompt)], vec![])`
- 提取 text 返回
5. 公开 API`get_conversation_summary()` → `self.session_memory.get("conversation_summary")`
**验证**`cargo build --all-targets`
### Step 16.4 — re-export
**文件**`src/agent.rs`
**改动**
```rust
pub mod summary;
pub use summary::SummaryConfig;
```
**验证**`cargo test --all-targets`
### Step 16.5 — 测试
| 测试 | 验证点 | 方式 |
|------|--------|------|
| `summary_config_defaults` | 默认值正确 | 单元测试 |
| `summary_not_generated_below_threshold` | token < 阈值时不触发 | `MockProvider` + `Usage::from_input_output(10, 5)` |
| `summary_generated_above_threshold` | token ≥ 阈值时触发 | 设置 `max_context_tokens=20` + `trigger_token_ratio=0.5` |
| `summary_debounce_works` | debounce 内不重复 | 强行触发摘要后验证 3 轮内不触发 |
| `summary_injected_into_focused` | Focused 模式 `load_messages()` 含 `[上下文摘要]` | 检查 Message 内容 |
| `summary_written_to_session_memory` | `get_session_data("conversation_summary")` 有值 | 集成测试 |
| `summary_not_injected_in_full_mode` | Full 模式不改 slot config | 验证 `summary_override` 为 None |
| `summary_failure_does_not_block` | LLM error 不阻断 `submit_turn` | MockProvider 返回错误 |
| `summary_stream_path` | 流式路径 `finalize_turn` 正确触发 | `submit_turn_stream` 端到端 |
| `summary_format_messages` | 格式化输出结构正确 | 单元测试验证格式 |
| `summary_skipped_for_empty_messages` | 空消息不调用 LLM | `generate_summary` 直接返回 `""` |
| `summary_not_generated_if_max_context_unreachable` | `max_context_tokens` 过大时不触发 | 验证条件不满足 |
**验证**`cargo test --all-targets` 全绿
---
## 规模估算
| 组件 | 纯实现 | 测试 | 合计 |
|------|--------|------|------|
| `agent/summary.rs`SummaryConfig + format_messages + 默认 prompt + 截断守卫) | 60 | 10 | 70 |
| `agent/runtime.rs`1 个字段) | 3 | — | 3 |
| `agent/builder.rs`1 个方法) | 8 | 3 | 11 |
| `agent/session.rs`(检查点 + generate_summary + get_conversation_summary | 40 | 100 | 140 |
| `agent.rs`module 声明 + re-export | 3 | — | 3 |
| **合计** | **109** | **113** | **~222** |
---
## 风险评估
### 已知风险
| 风险 | 概率 | 影响 | 缓解措施 |
|------|------|------|---------|
| **同步阻塞**:摘要 LLM 调用延长 submit_turn 延迟 | 高 | 长对话用户多等 1-3s | 对于已达 75% 水位的长对话,用户感知可接受;所有错误静默处理 |
| **默认模型不兼容**:非 OpenAI 用户未设置 `summary_model` 但默认 `None` 沿用主模型 | 低 | 无影响 | `summary_model` 默认 `None`,沿用主 provider 默认模型,零兼容问题 |
| **无限循环**:摘要不减少 cost_so_far,每轮都超阈值 | 中 | 频繁 LLM 调用浪费 token | `debounce_turns=3` 强制隔断;`last_summary_turn` 记录确保了间隔。注意:摘要 token 不计入 `cost_so_far`(独立 LlmCycle),阈值不会因摘要本身加速膨胀 |
| **Focusd 模式摘要位置**:注入为 `system` 消息排在列表末尾 | 低 | LLM 近因效应,摘要可能过度受关注 | 这是 v0.2 消费端的设计选择,Phase 16 不改变 |
| **SessionMemory key 冲突**:用户手动写入 `"conversation_summary"` 会被覆盖 | 低 | 数据被摘要覆盖 | 文档建议用户自定义 key;或未来使用 namespaced key |
| **可观测性盲区**`tracing::warn!` 依赖用户配置了 tracing subscriber | 中 | 失败静默不可见 | 提升到 `tracing::error!` 级别,或加 `eprintln!` fallback |
### 不做的事
- ❌ 不扩展 `HookContext`
- ❌ 不引入 `tokio::spawn` 后台摘要
- ❌ 不做增量摘要(`SummaryStrategy::Incremental` 留待 v0.4
- ❌ 不改 `filter_focused()` 的摘要注入位置
- ❌ 不追踪摘要 token 消耗(`summary_cost_so_far`
- ❌ 不添加运行时 prompt 校验(不检查 `{messages}` 是否存在)
- ❌ 不添加 `MergeStrategy::Summarize` 变体(`context.rs:108` 预占注释将在实施时同步移除或更新)
---
## 验收标准
### 编译与测试
| 检查项 | 指标 |
|--------|------|
| `cargo build --all-targets` | ✅ 通过 |
| `cargo test --all-targets` | ✅ 全量通过(预计 335 → ~345,新增 ~10 测试) |
| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 |
| 测试覆盖范围 | F1-F8、N1-N4 |
### 功能验收场景
**场景 1:启用摘要后的长对话**
```rust
let session = AgentSession::new(agent, "session-1", Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(registry)
.hook_executor(executor)
.summary_config(SummaryConfig {
max_context_tokens: 100,
trigger_token_ratio: 0.5,
debounce_turns: 2,
..Default::default()
})
.build()?
));
session.submit_turn("msg 1").await?;
// ... submit_turn 多次直到 token 超 50 ...
// 第 N 轮:摘要自动生成
let summary = session.get_session_data("conversation_summary").await?;
assert!(summary.is_some());
// Focused 模式下 load_messages 包含摘要
```
**场景 2:不启用时零影响**
```rust
let session = AgentSession::new(agent, "session-2", bundle); // 无 summary_config
for i in 0..50 {
session.submit_turn(&format!("msg {}", i)).await?;
}
// 没有摘要产生,没有额外的 LLM 调用
```
---
## 参考来源
- Phase 10 方案文档:`docs/17-phase10-contextslot.md`(§5 FocusedConfig 消费端设计)
- Phase 14 方案文档:`docs/20-phase14-document-and-embedding.md`Provider 复用模式)
- 当前代码:`src/agent/session.rs`submit_turn 流程,OnTurnEnd 位置)
- 当前代码:`src/llm/cycle.rs`submit_messages 签名)
- 当前代码:`src/agent/context.rs`FocusedConfig.summary_override + filter_focused 消费逻辑)
- 当前代码:`src/agent/runtime.rs`AgentConfig 结构)
- 当前代码:`src/agent/builder.rs`Builder 链式模式)
- 当前代码:`src/agent/session_memory.rs`set/get API
@@ -0,0 +1,775 @@
# Phase 17 — Agent 执行引擎
- **文档编号**23
- **标题**Phase 17 — Agent 执行引擎(Engine
- **日期**2026-07-15
- **状态**:**审查修复完成,待第二轮复审**
- **涉及模块**`engine/`(新建,含 `session_manager` / `checkpointer` / `snapshot` / `error`)、`agent/session``agent/context``llm/types/usage`
- **关联文档**`docs/17-phase10-contextslot.md``docs/22-phase16-summary-auto-generation.md``docs/roadmap.md`
- **审查记录**:第 1 轮 PM Director + SA Director 审查 → 6 🔴 阻塞问题,全部修复。详见 §变更记录。
---
## 背景与目标
### 问题
agcore v0.3.0 开发中,已完成 Phase 13-16Phase 0-12 全部完成)。当前测试 353 个,全部通过,clippy 0 警告。
当前 `AgentSession` 存在以下空白:
1. **Session 在变量中**`AgentSession` 实例仅在内存中存在,无法通过 session ID 从存储恢复
2. **无父子关系**:session 之间相互独立,无法表达"子会话继承父会话"的树形关系
3. **无检查点**:无法在任意时刻给 session 拍快照,出错后无法回滚到历史状态
4. **不可序列化**`AgentSession` 持有 `Arc<dyn Agent>``Arc<RuntimeBundle>`,无法直接序列化持久化
### 目标
建立 `engine/` 模块,补齐 session 生命周期的管理能力。具体包括:
1. **Session 工厂 + 按 ID 恢复**`SessionManager::create()` / `get()`session 创建后可通过 ID 从存储重建
2. **父子 session 树形关系**`create_child()` / `children()` / `parent()`,支持树形会话拓扑
3. **生命周期管理**`destroy()` 清理 session 及其存储记录
4. **Time-travel Checkpointer**`checkpoint()` / `rollback()` / `list_checkpoints()`,支持任意时刻状态快照与回滚
5. **序列化支持**:通过 `SessionSnapshot` 独立 struct 间接实现 `AgentSession` 的快照持久化
### 成功标准
1. Session 创建后可通 ID 从存储恢复(`get()` 返回完整状态的 `AgentSession`
2. 父子 session 关系可查询(`children()` / `parent()`),数据正确隔离
3. Checkpoint 拍快照后可完全恢复到该时刻状态(turn_index、cost_so_far、slots 一致)
4. 零新外部依赖,全量测试 353 → ~385-390
5. `cargo test --all-targets` 全绿,`cargo clippy` 0 警告
---
## 当前状态分析
### 模块现状
| 模块 | 文件 | 状态 | 与 Phase 17 的关系 |
|------|------|------|-------------------|
| `AgentSession` | `agent/session.rs` | ✅ 已实现 | 需扩展 `to_snapshot()` / `from_snapshot()` |
| `ContextSlot` | `agent/context.rs` | ✅ 已实现(持久化、fork/merge/save/load | 需加 `Serialize` / `Deserialize` derive |
| `CostTracker` | `llm/types/usage.rs` | ✅ 已实现 | 需加 `Clone` + `Serialize` / `Deserialize` derive |
| `MergeStrategy` | `agent/context.rs` | ✅ 已实现 | 需加 `Serialize` / `Deserialize` derive |
| `MemoryStore` trait | `memory/store.rs` | ✅ 已实现 | Checkpointer 的存储后端 |
| `RuntimeBundle` | `agent/runtime.rs` | ✅ 已实现(依赖注入容器) | `from_snapshot()` 需注入 `agent``bundle` |
| `InMemoryStore` | `memory/store.rs` | ✅ 已实现 | 测试用存储后端 |
| `SqliteStore` | `memory/sqlite_store.rs` | ✅ 已实现(Phase 7) | 生产环境存储后端 |
| `Message` | `llm/types/message.rs` | ✅ 已有 `Serialize` / `Deserialize` | 可直接序列化 |
### AgentSession 关键字段
```rust
pub struct AgentSession {
pub session_id: String,
pub agent: Arc<dyn Agent>, // ❌ 不可序列化
bundle: Arc<RuntimeBundle>, // ❌ 不可序列化
turn_index: u32, // ✅ 可序列化
cost_so_far: CostTracker, // ⚠️ 需加 derive
pub session_memory: SessionMemory, // ⚠️ 间接序列化
slots: HashMap<String, ContextSlot>, // ⚠️ 需加 derive
current_slot_id: String, // ✅ 可序列化
last_summary_turn: Option<u32>, // ✅ 可序列化
}
```
核心制约:`Arc<dyn Agent>``Arc<RuntimeBundle>` 无法 `Serialize` / `Deserialize`,必须通过独立 snapshot struct + 外部注入重建。
### 关键假设(设计分析 — 需实施后验证)
以下假设在方案设计中做出,标注验证方式。实施 Step 1-3 后应逐项确认。
| # | 假设 | 验证方式 |
|---|------|---------|
| 1 | `submit_turn_stream` 内部 `tokio::spawn` 不持有 `&mut self` → 可通过 `Arc<Mutex<AgentSession>>` 安全共享 | 代码审查覆盖 `submit_with_tools_stream``run_tool_loop` 的 spawn 捕获列表;确认所有捕获变量为 owned 数据 |
| 2 | `CostTracker``Clone` 不破坏现有代码 | 编译验证(`cargo build --all-targets`);检查 `CostTracker` 的所有消费方(`session.rs` 中只读引用) |
| 3 | `ContextSlot``Serialize` / `Deserialize` 不影响现有 `save` / `load` 路径 | 现有 `save()` 直接序列化 `self.messages` / `self.meta` / `self.config`,不走 `ContextSlot` 整体 serde → 两组路径可共存 |
| 4 | `Message` 已有 `Serialize` / `Deserialize` → 可直接嵌套序列化 | 代码确认(`message.rs` L21 已有 derive |
| 5 | `EngineError` 不需要 `derive Serialize` → 纯运行时错误类型 | Checkpoint 只存 `SessionSnapshot`,不存错误枚举 |
| 6 | `MemoryStore` 操作是可靠的——失败时返回 `EngineError::Memory` 透传错误 | 当前不内置 store 重试逻辑;调用方负责 retry 或 failover |
| 7 | session_id 使用 UUID v4 自动生成,冲突概率可忽略 | 实施确定 ID 生成方案(`uuid::Uuid::new_v4()` 或 时间戳+计数器无依赖方案) |
| 8 | session_memory 当前只支持字符串值;未来支持复杂类型时 `SessionMemoryEntry``value` 字段需改用 `serde_json::Value` | 已预留在注释中 |
---
## 调研发现
### 可选方案对比
#### 方案 A(推荐):SessionSnapshot + 组合式架构
**做法**:用一个独立 `SessionSnapshot` struct 存储可序列化状态,避开 `Arc<dyn Agent>` 的序列化限制。`Checkpointer` 作为独立 struct`SessionManager` 组合持有 `Checkpointer`
**优点**
- 不污染 `AgentSession` 主类型,序列化逻辑与运行逻辑分离
- `Checkpointer` 独立可测,不依赖 `SessionManager`
- 组合关系清晰:`SessionManager` 持有 `Checkpointer`
- 所有字段使用 `#[serde(default)]` 宽松反序列化,前向兼容
**缺点**
- 需要额外同步逻辑:`to_snapshot()` / `from_snapshot()` 双向转换
#### 方案 B(已否决):直接给 AgentSession derive Serialize
**做法**:给 `AgentSession``#[derive(Serialize)]`,用 `#[serde(skip)]` 跳过 `agent``bundle`
**否决原因**
1. `#[serde(skip)]` 跳过了 2 个核心字段,序列化后的结果名不副实
2. 技术债重:主类型获得"跳过一半字段"的诡异 serde 行为,未来维护者可能误以为 `AgentSession` 可整体序列化/反序列化
3. 反序列化时 `agent``bundle` 缺失,仍需外部注入 → 不如直接使用独立的 snapshot struct
#### 方案 C(已否决):Checkpointer 作为 SessionManager 内部方法
**做法**:将 `checkpoint` / `rollback` 直接作为 `SessionManager` 的方法。
**否决原因**
1. 违反单一职责原则(SRP):`SessionManager` 承担 session 生命周期 + 检查点管理双重责任
2. 破坏独立可测试性:检查点逻辑与 `SessionManager` 耦合
3. `rollback` 返回后自动注册到 `SessionManager`,但调用方可能不需要注册
4. 应返回 `AgentSession` 让调用方决定如何处理
### 技术决策清单
| 编号 | 决策项 | 选择 | 理由 |
|------|--------|------|------|
| D1 | 序列化方式 | `SessionSnapshot` 独立 struct | 不污染 `AgentSession`,序列化逻辑与运行逻辑分离 |
| D2 | 并发模型 | `tokio::sync::Mutex` | 安全跨 `.await`,与 `AgentSession` 现有模式一致 |
| D3 | 模块拆分 | `Checkpointer` 独立 + `SessionManager` 组合 | 独立可测,SRP 合规 |
| D4 | 存储格式 | 全量 JSON | 简洁可靠,ponytail>500 轮再优化为增量 |
| D5 | Key 命名 | `session:{id}:meta` / `ckpt:{id}:{ckpt_id}` | 与 `slot_data:` 风格一致,prefix 查询友好 |
| D6 | Checkpoint 触发 | `SessionManager` 封装方法中自动;同步写入 + `tracing::error!` 记录失败 | `AgentSession` 保持纯净;不提供强持久化保证(显式调 `checkpointer.checkpoint()` 确认) |
| D7 | 序列化兼容 | `#[serde(default)]` 宽松 | 防前向破坏,新增字段自动兼容旧快照 |
| D8 | 流式 checkpoint 时序 | 仅在 `finalize_turn` 时创建 checkpoint | `submit_turn_stream` 返回流时不做 checkpoint;客户端断开后不留下半成品 checkpoint 污染 |
| D9 | `SessionManager` trait | 不需要 | YAGNI,无多后端需求 |
| D10 | `CostTracker` / `ContextSlot` / `MergeStrategy` derive | 加 `Clone` + `Serialize` / `Deserialize` | 共约 7 行改动,支持快照序列化 |
### MVP 范围
| 做(Phase 17 首批) | 推迟 |
|---------------------|------|
| ① `SessionManager`: `create` / `get` / `create_child` / `children` / `parent` / `destroy` / `replace` / `recover` | ① `destroy_subtree` — 首次只做单节点 `destroy`。父被销毁后子 session 的 `parent()` 返回 `None`(允许孤儿)。调用方如需级联删除应自行遍历。 |
| ② `Checkpointer`: `checkpoint` / `rollback` / `list_checkpoints` / `delete_all` | ② `tree()``children()` + `parent()` 组合查询在 v0.3 够用;Phase 18 SubAgent Dispatch 需要全量树快照时再补。 |
| ③ `SessionSnapshot` + `to_snapshot()` / `from_snapshot()`(位于 `engine/snapshot.rs`+ `restore_memory()` | ③ `Checkpointer::fork` — 推迟理由:`fork` 底层可拆解为 `rollback` + `create_child`,当前 Checkpointer + SessionManager 已提供原始能力。`fork` 作为高层 API 等价于约 30 行组合代码,风险可控延后到 Phase 18。若产品认为 fork 是 time-travel MVP 的必要项,可重新划入 Phase 17。 |
| ④ `EngineError`(含 `MemoryError` 透传) | |
| ⑤ 涉及的 derive 改动(`CostTracker` + `ContextSlot` + `MergeStrategy` | |
**变更记录**(审查修复):
- `create()` / `create_child()` 返回类型改为 `Result<String, EngineError>`
- `get()` 改为仅内存查询,新增 `recover()` 显式恢复方法
- 新增 `replace()` 方法支持 rollback 后无缝切换
- MVP 推迟列补充 `tree()`(含推迟理由)、完善 `destroy_subtree`(定义孤儿语义)、
补充 `fork` 推迟理由(含技术拆解和产品权衡)
---
## 推荐方案
### 架构概览
```
┌──────────────────────────────────────────────┐
│ Engine │
│ ┌────────────────┐ ┌──────────────────┐ │
│ │ SessionManager │──│ Checkpointer │ │
│ │ │ │ │ │
│ │ create() │ │ checkpoint() │ │
│ │ get() │ │ rollback() │ │
│ │ create_child() │ │ list_checkpoints│ │
│ │ children() │ │ │ │
│ │ parent() │ └──────────────────┘ │
│ │ destroy() │ │
│ └────────┬───────┘ │
│ │ 组合 │
│ │ 持有 │
│ ▼ │
│ ┌────────────────┐ │
│ │ MemoryStore │ ── 存储后端 │
│ └────────────────┘ │
└──────────────────────────────────────────────┘
┌──────────────────┐
│ SessionSnapshot │ ── 可序列化的状态快照
│ (to/from │
│ AgentSession) │
└──────────────────┘
```
### 模块划分
**新增文件**5 个):
```
src/engine/
├── mod.rs # 约 30 行:模块根 + pub use 重导出
├── session_manager.rs # 约 300 行:SessionManager 实现(含 replace/recover
├── checkpointer.rs # 约 220 行:Checkpointer 实现
├── snapshot.rs # 约 50 行:SessionSnapshot + SessionMemoryEntry 定义
└── error.rs # 约 70 行:EngineError 枚举
```
**修改文件**5 个):
| 文件 | 改动量 | 内容 |
|------|--------|------|
| `src/agent/session.rs` | +~80 行 | `to_snapshot()` / `from_snapshot()` / `restore_memory()` |
| `src/agent/context.rs` | +4 行 | `ContextSlot` + `MergeStrategy``Serialize` / `Deserialize` |
| `src/llm/types/usage.rs` | +3 行 | `CostTracker``Clone` + `Serialize` / `Deserialize` |
| `src/lib.rs` | +2 行 | `pub mod engine` 声明 |
| `examples/engine_demo.rs` | +~100 行(新增) | 端到端示例(含 rollback + replace 流程) |
### SessionSnapshot(位于 `engine/snapshot.rs`
设计决策:`SessionSnapshot` 是 engine 层为持久化引入的序列化 DTO,定义在 `engine/snapshot.rs` 而非 `agent/session.rs`,保持依赖方向为 `engine → agent`
```rust
/// SessionMemory 条目的可序列化形式(保留元数据与时间戳)。
#[derive(Serialize, Deserialize, Clone)]
struct SessionMemoryEntry {
pub value: String,
#[serde(default)]
pub metadata: serde_json::Value,
#[serde(default)]
pub created_at: Option<i64>, // Unix 时间戳秒;Option 兼容旧快照
}
/// AgentSession 的可序列化快照。
///
/// 不持有 `Arc<dyn Agent>` 和 `Arc<RuntimeBundle>` —— 这两个由调用方在
/// `from_snapshot()` 时注入。所有字段使用 `#[serde(default)]` 确保前向兼容。
///
/// **变更记录**(审查修复):
/// - 位置从 `agent/session.rs` 移至 `engine/snapshot.rs`
/// - `session_memory_data` 从 `HashMap<String, String>` 改为 `HashMap<String, SessionMemoryEntry>`
/// 保留 metadata 和 created_at,避免恢复后时间戳丢失
#[derive(Serialize, Deserialize, Clone)]
pub(crate) struct SessionSnapshot {
pub session_id: String,
pub agent_name: String,
pub turn_index: u32,
#[serde(default)]
pub cost_so_far: CostTracker,
#[serde(default)]
pub slots: HashMap<String, ContextSlot>,
pub current_slot_id: String,
pub last_summary_turn: Option<u32>,
#[serde(default)]
pub session_memory_data: HashMap<String, SessionMemoryEntry>,
}
```
### AgentSession 扩展方法
```rust
impl AgentSession {
/// 将当前状态拍平为 SessionSnapshot。
///
/// **需要 async**:因为 session_memory 的数据存储在 `MemoryStore` 中,读取需要异步 I/O。
/// 可通过 `SessionMemory::list_entries()` 获取完整条目(含 metadata/created_at):
///
/// ```ignore
/// let entries = self.session_memory.list_entries().await?;
/// for (key, value, metadata, created_at) in entries {
/// map.insert(key, SessionMemoryEntry { value, metadata, created_at: Some(created_at) });
/// }
/// ```
/// `from_snapshot` 保持同步(构造器不应做 I/O),`to_snapshot` 做 async(快照输出可 I/O)—
/// 两个方向不矛盾,设计上各自成立。
pub async fn to_snapshot(&self) -> SessionSnapshot {
// 拍平 session_memory → HashMap<String, SessionMemoryEntry>(通过 list_entries
// 复制 slots / cost_so_far / turn_index 等可序列化字段
}
/// 从 SessionSnapshot + agent + bundle 重建 AgentSession。
///
/// **纯同步重建**:只做内存数据结构恢复(slots/turn_index/cost_so_far 等),
/// 不执行任何 I/O。session_memory 的持久层恢复由 `restore_memory()` 完成。
///
/// 调用方负责:
/// - 提供与 `agent_name` 对应的 `Arc<dyn Agent>`
/// - 提供合法的 `Arc<RuntimeBundle>`
///
/// 返回 `Result` 以传播序列化反序列化错误(如 JSON 格式不兼容)。
pub fn from_snapshot(
snapshot: SessionSnapshot,
agent: Arc<dyn Agent>,
bundle: Arc<RuntimeBundle>,
) -> Result<Self, EngineError> {
// session_memory_data 存入临时字段(不写 store)
// 重建 slots HashMap
// 恢复 turn_index / cost_so_far / last_summary_turn
}
/// 将 snapshot 中的 session_memory_data 写回持久层。
/// 从 `from_snapshot()` 中剥离的异步操作,调用方显式 await。
/// 放置在 `restore_memory` 而非构造函数中,确保构造函数是纯同步的。
///
/// **错误处理**:逐条写入,某条失败时返回 Err 但不回滚已写入的条目。
/// 调用方可选择重试或忽略(不影响 AgentSession 内存状态)。
pub async fn restore_memory(&self) -> Result<(), EngineError>;
}
```
**标准使用流程**
```rust
// rollback:四步走
let snapshot = cp.rollback_load(session_id, ckpt_id).await?; // ① 从存储读
let session = AgentSession::from_snapshot(snapshot, agent, bundle)?; // ② 同步重建
session.restore_memory().await?; // ③ 恢复持久层
sm.replace(session_id, session).await?; // ④ 注册到 Manager
```
**checkpoint 流程**(自动或显式调用):
```rust
// checkpoint 内部:
let snapshot = session.to_snapshot().await; // async:从 MemoryStore 读取 session_memory
cp.save(snapshot).await?;
```
### Checkpointer 公开 API
```rust
/// 检查点元数据。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CkptMeta {
pub ckpt_id: String,
pub session_id: String,
pub turn_index: u32,
pub created_at: u64, // Unix 时间戳,秒
}
/// Time-travel 检查点管理器。
///
/// **不依赖 SessionManager**,可独立使用。直接操作 MemoryStore。
/// 存储 key 格式:`ckpt:{session_id}:{ckpt_id}` → SessionSnapshot JSON
pub struct Checkpointer {
store: Arc<dyn MemoryStore>,
}
impl Checkpointer {
/// 创建新检查点。返回 ckpt_id。
pub async fn checkpoint(&self, session: &AgentSession) -> Result<String, EngineError>;
/// 回滚到指定检查点。返回恢复后的 AgentSession。
///
/// 调用方需提供 `agent` 和 `bundle`(与 SessionSnapshot 反序列化的要求一致)。
/// rollback 不自动注册到任何 SessionManager——调用方决定如何处理返回的 session。
pub async fn rollback(
&self,
session_id: &str,
ckpt_id: &str,
agent: Arc<dyn Agent>,
bundle: Arc<RuntimeBundle>,
) -> Result<AgentSession, EngineError>;
/// 列出某 session 的所有检查点(按创建时间降序)。
pub async fn list_checkpoints(&self, session_id: &str)
-> Result<Vec<CkptMeta>, EngineError>;
/// 删除某 session 的所有检查点(session 被 destroy 时调用)。
pub async fn delete_all(&self, session_id: &str) -> Result<(), EngineError>;
}
```
**注意**`Checkpointer::fork()` 推迟到 Phase 18(详见 MVP 范围表)。
**关于 Checkpointer 的独立可用性**Snapshot 数据的读写(`checkpoint` / `list_checkpoints`)不依赖 SessionManager,可直接用 `Checkpointer` 操作 MemoryStore。但 `rollback()` 重建 AgentSession 需要调用方提供与 session_id 匹配的 `Arc<dyn Agent>``Arc<RuntimeBundle>`——调用方需自行管理 agent→session 的映射(或通过 `SessionMeta.agent_name` 查询注册表)。
### SessionManager 公开 API
```rust
/// SessionManager 配置。
pub struct SessionManagerConfig {
/// 每次 submit_turn 后是否自动 checkpoint(默认 true)。
pub auto_checkpoint: bool,
/// 默认 RuntimeBundle,用于从存储重建 session 时的 bundle 注入。
/// 如果为 None`recover()` 需要调用方手动传入 bundle。
pub default_bundle: Option<Arc<RuntimeBundle>>,
}
impl Default for SessionManagerConfig {
fn default() -> Self {
Self {
auto_checkpoint: true,
default_bundle: None,
}
}
}
/// Session 生命周期管理器。
///
/// 组合持有 Checkpointer,提供 session 的 CRUD、树形关系查询和自动检查点。
/// 内部用 `HashMap<String, Arc<tokio::sync::Mutex<AgentSession>>>` 管理活跃 session。
/// 存储 key 格式:`session:{session_id}:meta` → SessionMeta JSON
///
/// **锁契约**
/// - 所有写操作(create/destroy/replace)内部先完成 HashMap 操作,释放 RwLock 后再调用
/// Checkpointer/MemoryStore 的异步 I/O。调用方不应假设某个操作持有跨 .await 点的锁。
/// - `get()` 返回 `Arc<Mutex<AgentSession>>` 后立即释放 RwLock 读锁,调用方持有的是
/// session 级别的 Mutex 锁而非管理器级别的锁。
pub struct SessionManager {
sessions: RwLock<HashMap<String, Arc<tokio::sync::Mutex<AgentSession>>>>,
checkpointer: Checkpointer,
store: Arc<dyn MemoryStore>,
config: SessionManagerConfig,
}
impl SessionManager {
/// 创建新 session。session_id 由内部自动生成(UUID v4)。
/// 持久化 SessionMeta 后注册到 sessions HashMap。
pub async fn create(
&self,
agent: Arc<dyn Agent>,
bundle: Arc<RuntimeBundle>,
) -> Result<String, EngineError>;
/// 从父 session 创建子 session(继承父的 RuntimeBundleArc::clone 共享引用)。
/// session_id 由内部自动生成(UUID v4)。
/// 如果 `parent_id` 不存在,返回 `EngineError::SessionNotFound(parent_id)`。
pub async fn create_child(
&self,
parent_id: &str,
agent: Arc<dyn Agent>,
) -> Result<String, EngineError>;
/// 按 ID 获取 session(仅查内存,不自动从存储恢复)。
/// 冷启动时 `get()` 未命中返回 `EngineError::SessionNotFound`。
/// 如需从存储恢复,使用 `recover()` 方法。
pub async fn get(
&self,
session_id: &str,
) -> Result<Arc<tokio::sync::Mutex<AgentSession>>, EngineError>;
/// 从存储恢复 session。需要调用方提供 agent 和 bundle(与 SessionSnapshot
/// 反序列化的要求一致)。
/// 恢复后自动注册到 sessions HashMap(与 create 的行为一致)。
pub async fn recover(
&self,
session_id: &str,
agent: Arc<dyn Agent>,
bundle: Arc<RuntimeBundle>,
) -> Result<Arc<tokio::sync::Mutex<AgentSession>>, EngineError>;
/// 替换 SessionManager 中指定 session_id 的 AgentSession 实例。
/// 用于 Checkpointer::rollback() 后的无缝切换:
/// ```ignore
/// let rolled_back = cp.rollback(sid, ckpt_id, agent.clone(), bundle.clone()).await?;
/// sm.replace(sid, rolled_back).await?;
/// ```
/// 内部执行:内存替换 + 写回 SessionMeta。
pub async fn replace(
&self,
session_id: &str,
session: AgentSession,
) -> Result<(), EngineError>;
/// 查询某 parent 的所有直接子 session 的 ID 列表。
pub async fn children(&self, parent_id: &str) -> Result<Vec<String>, EngineError>;
/// 查询某 child session 的 parent ID。
/// 如果 parent 已被销毁,返回 `Ok(None)`(允许孤儿 session 存在)。
pub async fn parent(&self, child_id: &str) -> Result<Option<String>, EngineError>;
/// 销毁 session:从内存移除 + 清理 SessionMeta + 清理检查点。
///
/// **父子关系处理**:允许孤儿 session 存在(子 session 的 parent_id 仍指向已删除的父,
/// 但 `parent()` 返回 `None`)。不递归删除子 session——调用方如需级联删除应自行遍历。
pub async fn destroy(&self, session_id: &str) -> Result<(), EngineError>;
/// 暴露 Checkpointer 引用(调用方可直接操作检查点)。
pub fn checkpointer(&self) -> &Checkpointer;
}
```
**变更记录**(审查修复):
- `create()` 返回类型从 `String` 改为 `Result<String, EngineError>`
- `create()` / `create_child()` session_id 统一为内部自动生成(UUID v4)
- `get()` 改为"仅查内存",新增 `recover()` 显式恢复方法
- 新增 `replace()` 方法支持 rollback 后的无缝替换
- `destroy()` 明确孤儿策略:允许孤儿存在,不递归删除
- `create_child()` 不再接受 `child_id` 参数(统一自动生成)
- 锁契约明确化为 struct doc comment
- `SessionManagerConfig` 新增 `default_bundle` 字段为后续扩展预留
### SessionMeta
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct SessionMeta {
pub session_id: String,
pub agent_name: String,
pub parent_id: Option<String>,
pub created_at: u64, // Unix 时间戳,秒
pub turn_count: u32,
}
```
### 存储 Key 命名
| Key 模式 | 内容 | 说明 |
|----------|------|------|
| `session:{session_id}:meta` | `SessionMeta` JSON | session 元数据,含 parent_id |
| `ckpt:{session_id}:{ckpt_id}` | `SessionSnapshot` JSON | 全量检查点,含 slots |
风格与 `ContextSlot``slot_data:{session_id}:{slot_id}` 一致:`前缀:session_id:后缀`
**关于两种持久化路径共存**`ContextSlot::save()`(增量消息持久化)和 `Checkpointer::checkpoint()`(全量快照)是互补的"增量基线 vs 全量备份"关系:
- `ContextSlot::save()` 每轮追加消息到 slot 存储(增量),是进程重启后消息不丢的基线
- `Checkpointer::checkpoint()` 全量序列化 session 状态(含所有 slot 消息),是 time-travel 回滚的快照
- rollback 时优先使用 checkpoint 的 snapshot 数据(一致性保证),不依赖 slot 持久化中的消息状态
### EngineError
```rust
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum EngineError {
/// 指定 session_id 不存在。
/// 适用场景:get() 内存未命中、create_child() parent 不存在、destroy() 操作不存在的 session。
#[error("Session not found: {0}")]
SessionNotFound(String),
/// 创建 session 时 ID 已存在(自动生成 ID 时通常不会触发)。
#[error("Session already exists: {0}")]
SessionAlreadyExists(String),
/// 指定 ckpt_id 不存在。
#[error("Checkpoint not found: {0}")]
CheckpointNotFound(String),
/// 存储错误(透传 MemoryError)。
/// Checkpointer 和 SessionManager 的所有 MemoryStore 操作通过此变体传播错误。
/// 与项目既有模式一致(对比 AgentError:直接 #[from] LlmError/ToolError/MemoryError)。
#[from]
#[error("存储错误: {0}")]
Memory(#[from] MemoryError),
/// 序列化/反序列化失败(serde_json/snapshot 格式错误)。
#[error("序列化错误: {0}")]
Serialization(String),
/// Agent 错误(透传 AgentError)。
#[from]
#[error("Agent 错误: {0}")]
Agent(#[from] AgentError),
}
```
### 并发模型
`SessionManager` 内部使用 `tokio::sync::RwLock` 保护 `sessions: HashMap`
```rust
pub struct SessionManager {
sessions: RwLock<HashMap<String, Arc<tokio::sync::Mutex<AgentSession>>>>,
// ... 其他字段
}
```
- `RwLock` 适合读多写少的场景(`get()` 高频 > `create()` / `destroy()`
- `get()` 返回 `Arc<Mutex<AgentSession>>` 后立即释放 RwLock 读锁,调用方持有的是 session 级别的 Mutex 锁而非管理器级别的锁。**不持有 RwLock 跨越 .await**
- 所有写操作(`create`/`destroy`/`replace`)先完成 HashMap 操作(持有写锁),释放 RwLock 后再调用 Checkpointer/MemoryStore 的异步 I/O
- 返回的 `AgentSession``Arc<tokio::sync::Mutex<AgentSession>>` 包裹,支持跨 `.await` 的安全可变访问
- `Checkpointer` 无锁(纯函数式操作 MemoryStore,依赖其内部实现)
---
## 实施建议
### 阶段划分(共 7 步)
```
Step 1: 前置 derive 改动 → step-1-branch
Step 2: EngineError + 模块骨架 → step-2-branch
Step 3: SessionSnapshot + 扩展 → step-3-branch
Step 4: Checkpointer → step-4-branch
Step 5: SessionManager → step-5-branch
Step 6: 自动 checkpoint 集成 → step-6-branch
Step 7: 示例 + 测试补强 → step-7-branch
```
#### Step 1:前置 derive 改动
- **文件**`src/llm/types/usage.rs``src/agent/context.rs`(×2
- **内容**
- `CostTracker``#[derive(Debug, Default)]``#[derive(Debug, Default, Clone, Serialize, Deserialize)]`
- `ContextSlot``#[derive(Debug, Clone)]``#[derive(Debug, Clone, Serialize, Deserialize)]`
- `MergeStrategy``#[derive(Debug, Clone)]``#[derive(Debug, Clone, Serialize, Deserialize)]`
- **验证**`cargo build --all-targets` 编译通过
#### Step 2EngineError + 模块骨架
- **文件**
- `src/engine/error.rs`(新增):`EngineError` 枚举定义
- `src/engine/mod.rs`(新增):模块根声明 + `pub use` 重导出 `EngineError` / `SessionManager` / `Checkpointer` / `CkptMeta`
- `src/lib.rs`(修改):加 `pub mod engine;`
- **验证**`cargo build --all-targets && cargo clippy --all-targets -- -D warnings`
#### Step 3SessionSnapshot + AgentSession 扩展
- **文件**`src/engine/snapshot.rs`(新增,来自 SA 审查建议)、`src/agent/session.rs`
- **内容**
- `src/engine/snapshot.rs``SessionMemoryEntry` 结构体(含 `value`/`metadata`/`created_at`)、`SessionSnapshot` 结构体定义(`pub(crate)`
- `src/agent/session.rs``pub async fn to_snapshot(&self) -> SessionSnapshot`**异步**,通过 `SessionMemory::list_entries()` 读取完整 session_memory 条目,复制 slots/cost_so_far/各标量字段)
- `pub fn from_snapshot(snapshot, agent, bundle) -> Result<Self, EngineError>`**纯同步**,不写 storesession_memory_data 暂存于内存,不写入持久层)
- `pub async fn restore_memory(&self) -> Result<(), EngineError>`(异步,将 from_snapshot 暂存的 session_memory_data 写回持久层;逐条写入,失败时记录 error 但不回滚已写入条目)
- `SessionMemory` 新增 `list_entries()` 方法返回 `Vec<(String, String, serde_json::Value, i64)>`(含 value/metadata/created_at),供 `to_snapshot` 消费
- **验证**:单元测试 roundtrip`to_snapshot().await``from_snapshot()` → 关键字段一致);`restore_memory` 幂等性测试
#### Step 4Checkpointer
- **文件**`src/engine/checkpointer.rs`(新增)
- **内容**
- `Checkpointer` 结构体(持有 `Arc<dyn MemoryStore>`
- `CkptMeta` 结构体
- `checkpoint()`:生成 ckpt_id(时间戳+计数器方案优先,ponytail;`uuid` 备选,需加依赖),`session.to_snapshot()` → JSON → 存 `ckpt:{session_id}:{ckpt_id}`
- `rollback_load()`(两阶段 rollback 的第一阶段):读取 JSON → 反序列化为 `SessionSnapshot` → 返回 `SessionSnapshot`
- 调用方拿到 `SessionSnapshot` 后,自行调用 `AgentSession::from_snapshot()`(纯同步)+ `restore_memory()`(异步)+ `SessionManager::replace()`(注册)
- `list_checkpoints()`prefix 查询 `ckpt:{session_id}:` → 反序列化 `CkptMeta`(从 snapshot JSON 中提取 `turn_index` / `created_at`)→ 按时间降序
- `delete_all()`prefix 查询 + 逐个删除
- **验证**3-5 个单元测试(checkpoint roundtrip / rollback_load 反序列化正确 / list 排序 / delete_all 幂等性)
#### Step 5SessionManager
- **文件**`src/engine/session_manager.rs`(新增)
- **内容**
- `SessionManagerConfig` 结构体(含 `auto_checkpoint: bool` + `default_bundle: Option<Arc<RuntimeBundle>>`
- `SessionMeta` 结构体(`pub(crate)`
- `SessionManager` 结构体(`RwLock<HashMap<...>>` + `Checkpointer` + `store` + `config`
- `create()`:内部自动生成 session_idUUID v4),`AgentSession::new()` → 存 `SessionMeta` → 注册到 `sessions` HashMap → `Ok(session_id)`
- `create_child()`:验证 parent 存在 → 自动生成 child session_id → 设置 `parent_id``create()` 流程
- `get()`**仅查内存**,未命中返回 `SessionNotFound`(不自动从存储恢复)
- `recover(session_id, agent, bundle)`:从存储读取 `SessionMeta` + 调 `Checkpointer` 最近 checkpoint → 重建 `AgentSession` → 注册到 HashMap
- `replace(session_id, session)`:内存替换(覆盖 Mutex 中的 AgentSession+ 写回 SessionMeta
- `children(parent_id)`prefix 查询 `session:{parent_id}:` → 过滤 `parent_id` 匹配 → 返回 child_id 列表
- `parent(child_id)`:读 `SessionMeta.parent_id`,父已被销毁时返回 `Ok(None)`
- `destroy(session_id)`:移除内存记录 → 删除 `SessionMeta` → 调 `Checkpointer::delete_all()`。**允许孤儿 session 存在**(不递归删除子 session)
- **验证**8-10 个单元测试(CRUD / recover 恢复 / replace 替换 / 树形关系 / session 隔离 / destroy 后 get 失败 / 孤儿 parent 返回 None
#### Step 6:自动 checkpoint 集成
- **文件**`src/engine/session_manager.rs`(扩展)
- **内容**
-`SessionManager` 上添加封装方法 `submit_turn(session_id, user_input)`,内部:
1. `get(session_id)` 获取 session
2. `session.lock().await.submit_turn(user_input).await`
3. 如果 `config.auto_checkpoint == true`,同步调用 `checkpointer.checkpoint(&session).await`
- checkpoint 失败时通过 `tracing::error!` 记录,不阻断 `submit_turn``Ok` 返回
- 调用方如需强持久化保证,应显式调用 `checkpointer.checkpoint()` 并处理其 `Result`
- 流式路径:仅在 `finalize_turn` 时创建 checkpoint`submit_turn_stream` 返回流时不做 checkpoint
- 客户端断开连接导致 `finalize_turn` 未被调用时,保持上一个 checkpoint 的状态,不留下半成品 checkpoint 污染
- `auto_checkpoint` 配置控制开关
- **验证**:集成测试(`submit_turn``list_checkpoints` 中可查到新 checkpoint);关闭 `auto_checkpoint` 时不产生 checkpoint
#### Step 7:示例 + 测试补强 + Tracing 埋点
- **文件**`examples/engine_demo.rs`(新增,~100 行)
- **示例流程**
1. `SessionManager::create` → submit_turn
2. `Checkpointer::checkpoint` → list_checkpoints
3. `Checkpointer::rollback` + `AgentSession::restore_memory` + `SessionManager::replace`
4. 验证回滚后 turn_index 和 cost 恢复到 checkpoint 时刻
- **Tracing 埋点**(每个关键操作添加 `tracing` 日志,与项目既有风格一致):
- `Checkpointer::checkpoint()` 成功时:`tracing::info!(ckpt_id, turn_index, snapshot_size, "checkpoint created")`
- `Checkpointer::rollback()` 成功时:`tracing::info!(ckpt_id, session_id, turn_index, "rolled back")`
- `Checkpointer::list_checkpoints``tracing::debug!(session_id, count)`
- `SessionManager::create``tracing::info!(session_id, agent_name, "session created")`
- `SessionManager::destroy``tracing::info!(session_id, "session destroyed")`
- `SessionManager::get` / `recover` / `replace``tracing::debug!(session_id, ...)`
- 序列化错误 / 存储错误 → `tracing::error!(session_id, error, ...)`
- **补充测试**12-15 个):
- 空 slot checkpoint → rollback 后消息为空
- Destroy 后再 checkpoint → 返回 `SessionNotFound`
- 跨 session 检查点隔离(session A checkpoint 不影响 session B
- 序列化版本兼容(`#[serde(default)]` 兜底:缺少新字段的旧 snapshot 可正常反序列化)
- 10 并发 session 创建/销毁(RwLock 写锁争用验证)
- 父子 session 消息隔离(子 session 写数据不污染父 session
- `restore_memory` 幂等性(重复调用不产生重复数据)
- `from_snapshot` 纯同步验证(检查构造过程中无 async 调用路径)
- **验证**`cargo test --all-targets` 全绿 + `cargo clippy` 0 警告
### 高层建议
1. **Step 1 应先行独立提交**:derive 改动可能触发整个 crate 的重新编译,与其他步骤分开可减少冲突
2. **`get()` 只查内存,`recover()` 用于存储恢复**`get()` 不自动从存储重建(因无 `agent`/`bundle` 通道)。冷启动后先 `create()``get()`,或显式调用 `recover(session_id, agent, bundle)`
3. **ckpt_id 生成**:使用 `uuid::Uuid::new_v4()`(需在 `Cargo.toml` `[dependencies]` 中添加 `uuid = { version = "1", features = ["v4"] }`),或走无新增依赖方案:`format!("{}_{}", session_id, timestamp_nanos)` 结合单调计数器。建议优先走无新增依赖方案(ponytail)
4. **SessionManager 的 RwLock 粒度**:避免持写锁时调 `checkpointer`(涉及 I/O),锁范围应仅限于 HashMap 操作;`get()` 返回 `Arc` 后立即释放读锁
5. **自动 checkpoint 的持久化语义**:自动 checkpoint 采用`同步写入 + tracing::error! 记录失败` 模式(与 Phase 16 `maybe_summarize` 的静默模式一致)。**不提供强持久化保证**——调用方如需确保 checkpoint 成功,应显式调用 `checkpointer.checkpoint()` 并处理其 `Result`
6. **ContextSlot 持久化与 Checkpointer 快照的关系**:两者是"增量基线 vs 全量备份"的互补关系。`ContextSlot::save()` 负责每轮追加消息到 slot 存储(增量),`Checkpointer::checkpoint()` 负责全量序列化 session 状态(快照)。rollback 时优先使用 checkpoint 数据(一致性),不依赖 slot 持久化的消息状态
7. **`from_snapshot` 后调用 `restore_memory`**`AgentSession::from_snapshot()` 是纯同步的,不写 store;写回 session_memory 需要显式 `await session.restore_memory()`。三步全流程:`from_snapshot → restore_memory → replace`
### @Chart 提示
```
flowchart TD
subgraph "engine/"
SM[SessionManager]
CP[Checkpointer]
EE[EngineError]
end
subgraph "现有模块"
AS[AgentSession]
CS[ContextSlot]
CT[CostTracker]
MS[MemoryStore]
end
SM -->|组合持有| CP
SM -->|RwLock 保护| HM[(sessions HashMap)]
CP -->|持久化| MS
AS -->|to_snapshot| SS[SessionSnapshot]
SS -->|from_snapshot| AS
SM -->|get / create / destroy| AS
CP -->|checkpoint / rollback| AS
```
---
## 变更记录(审查修复)
| 日期 | 变更 | 触发 |
|------|------|------|
| 2026-07-15 | **🔴 `to_snapshot(&self)` 从同步改为 `pub async fn`** | SA 第 2 轮审查:同步方法无法 async 读 MemoryStore;需通过 `SessionMemory::list_entries()` 获取完整条目 |
| 2026-07-15 | **🔴 `docs/roadmap.md` Phase 17 交付物列表同步更新** | PM 第 2 轮审查:Roadmap 仍使用旧版范围(`tree()`/`fork()`/`destroy_subtree()` 未推迟,`create()` 签名未更新,缺 `recover()`/`replace()` |
| 2026-07-15 | **`SessionMemory::list_entries()` 新增方法** | SA 第 2 轮审查:`to_snapshot` 需要读取完整 entry 数据,现有 API 只返回 `Option<String>` |
| 2026-07-15 | **`to_snapshot` 注释清理:移除错误的 Cell/RefCell 方案** | SA 第 2 轮审查:同步方法中无法通过 Cell/RefCell 绕开 async |
| 日期 | 变更 | 触发 |
|------|------|------|
| 2026-07-15 | **🔴 `SessionManager::get()` 改为仅查内存,新增 `recover()` 显式恢复方法** | SA 审查:get() "从存储恢复"不可实现(无 agent/bundle 通道) |
| 2026-07-15 | **🔴 `from_snapshot()` 改为纯同步构造 + 分离 `restore_memory()` 异步方法;返回 `Result`** | SA 审查:异步 I/O + 返回 Self 导致脏数据 |
| 2026-07-15 | **🔴 `session_memory_data``HashMap<String, String>` 改为 `HashMap<String, SessionMemoryEntry>`** | SA 审查:拍平丢失 metadata/created_at |
| 2026-07-15 | **🔴 `EngineError` 新增 `Memory(#[from] MemoryError)` 透传变体** | SA 审查:缺少 MemoryError 透传 |
| 2026-07-15 | **🔴 `tree()` 在 MVP 推迟列补充(含推迟理由)** | PM 审查:Roadmap L781 需求完全未提及 |
| 2026-07-15 | **🔴 `fork()` 推迟理由补充(技术拆解 + 产品权衡)** | PM 审查:推迟理由不充分 |
| 2026-07-15 | **🔴 新增 `SessionManager::replace()` API 支持 rollback 后无缝切换** | PM 审查:rollback 后 session 无法替换到 Manager |
| 2026-07-15 | **`SessionSnapshot` 移至 `engine/snapshot.rs`** | SA 审查:DTO 应放在 engine 层,保持依赖方向 engine→agent |
| 2026-07-15 | **uuid 依赖修正:改为"时间戳+计数器优先,uuid 备选"** | SA 审查:文档声称"已有依赖"但 Cargo.toml 不含 |
| 2026-07-15 | **160KB 具体数字删除(替换为保守上限描述)** | SA 审查:无测量依据 |
| 2026-07-15 | **"关键假设(已验证)"改为"设计分析" + 验证方式** | PM 审查:"已验证"字面与实际不符 |
| 2026-07-15 | **流式 checkpoint 时序明确定义:仅在 `finalize_turn` 时创建** | PM 审查:时序未定义 |
| 2026-07-15 | **自动 checkpoint 语义:`tracing::error!` 模式,非强持久化** | SA 审查:fire-and-forget 不可靠 |
| 2026-07-15 | **`create()` 返回 `Result<String, EngineError>` + 统一自动生成 ID** | PM 审查:返回 String 不能表达错误 |
| 2026-07-15 | **`destroy()` 明确孤儿策略:允许孤儿,不递归删除,parent() 返回 None** | PM+SA 审查:孤儿语义未定义 |
| 2026-07-15 | **`create_child()` 不再接受 `child_id`(统一自动生成)** | PM 审查:ID 策略不一致 |
| 2026-07-15 | **`RuntimeBundle` 继承语义补充(`Arc::clone` 共享引用)** | PM 审查:继承语义未定义 |
| 2026-07-15 | **并发模型补充 RwLock 锁范围注释** | SA 审查:跨 await 风险缺文档 |
| 2026-07-15 | **Checkpointer 独立可用性约束标注** | SA 审查:rollback 重建需要 agent+bundle |
| 2026-07-15 | **ContextSlot 与 Checkpointer 两种持久化路径关系补充说明** | SA 审查:共存缺说明 |
| 2026-07-15 | **Step 7 扩充:示例流程 + 12-15 个边界测试 + Tracing 埋点规划** | SA 审查:缺 tracing 规划 |
| 2026-07-15 | **`SessionManagerConfig` 新增 `default_bundle` 字段** | PM 审查:未来扩展预留 |
| 2026-07-15 | **项目文件新增/修改数量同步更新(5 新增 + 5 修改,~725 行)** | 全部审查修复导致文件范围变化 |
## 参考来源
- Phase 10 方案文档:`docs/17-phase10-contextslot.md`ContextSlot 持久化设计,Phase 17 的前置依赖)
- Phase 16 方案文档:`docs/22-phase16-summary-auto-generation.md`(上一 Phase 的实施风格参考)
- 当前代码:`src/agent/session.rs`AgentSession 当前实现,`to_snapshot` / `from_snapshot` 扩展点)
- 当前代码:`src/agent/context.rs`ContextSlot 当前实现,derive 改动点)
- 当前代码:`src/llm/types/usage.rs`CostTracker 当前实现,derive 改动点)
- 当前代码:`src/lib.rs`(模块注册点)
- 当前代码:`src/agent.rs`(模块组织风格参考)
@@ -0,0 +1,700 @@
# Phase 18Agent 角色热切换与子代理调度
## 背景与目标
### 问题空间
agcore 已完整交付 Phase 0-17,具备 SessionManager 会话生命周期管理、会话树(父子层次)、Checkpointer 检查点、流式输出、ContextSlot 上下文分区、MemoryStore 持久化、摘要自动生成等能力。当前 Session 在 `create()` 时绑定一个 `Arc<dyn Agent>`,此后无法变更角色;会话间调度仅通过 `create_child()` + `submit_turn()` 手动编排,缺乏内建的子代理派发机制。
Phase 18 要解决两个正交但关联的问题:
1. **Agent 角色热切换**:运行时替换 session 绑定的 Agent,保留上下文(slot 历史、turn_index、session_memory、cost_so_far
2. **子代理调度**:在 SessionManager 上提供声明式的 `dispatch` / `dispatch_stream` / `dispatch_all` API,支持父子 session 间的 Memory 继承、bridge_keys 注入、并发控制、结构化回传
### 目标
- 提供 `SessionManager::switch_agent(session_id, new_agent)`,替换 `Arc<dyn Agent>`,全量保留 slot / turn_index / session_memory
- 提供 `DispatchConfig` / `SubTaskResult` / `SubTaskStreamEvent` 类型以及 `dispatch` / `dispatch_stream` / `dispatch_all` 三个核心方法
- 实现父转子三层级交互:父->子(Memory 快照继承 + bridge_keys)、子->父(SubTaskResult 结构化回传 + result_summary)、子<->子(shared namespace
- 产出 4 个端到端示例:`agent_switch_demo` / `sub_agent_dispatch_demo` / `bridge_keys_demo` / `dispatch_stream_demo`
### 依赖与优先级
- **依赖**Phase 17SessionManager + 会话树 + Checkpointer[高]
- **优先级**P0
- **预估规模**:约 720 行核心 + 210 行测试 + 450 行示例
- **审查修复**:第 1 轮审查修复(Finalize 方案重写 + 4 个 🔴 阻塞 + 8 个 🟡 改进)
---
## 当前状态分析
### 现有架构中的关键接入点
| 接入点 | 位置 | 可用性 | 分析 |
|--------|------|--------|------|
| `AgentSession.agent` | `src/agent/session.rs:53` | `pub` 字段 | 可直接替换,无需新增 setter [高] |
| `AgentSession.session_id` | `src/agent/session.rs:51` | `pub` 字段 | 子 session 创建后可读取 [高] |
| `AgentSession.session_memory` | `src/agent/session.rs:58` | `pub` 字段 | dispatch 后父可读子 memory [高] |
| `SessionManager::create_child(parent_id, agent)` | `src/engine/session_manager.rs:202-239` | `pub async` | dispatch 可直接复用,bundle 继承避免重复构造 [高] |
| `SessionMemory::list_entries()` | `src/agent/session_memory.rs:81-110` | `pub async` | 可获取全量条目用于父子继承 / bridge_keys 过滤 [高] |
| `SessionMemory::set_with_meta()` | `src/agent/session_memory.rs:61-79` | `pub async` | 子 session 写入继承数据时保留原始 metadata [高] |
| `CostTracker` | `src/llm/cycle.rs` | derive `Clone` | SubTaskResult 可直接 clone usage [高] |
| `SessionMeta` 持久化 | `src/engine/session_manager.rs:32-67` | `pub(crate)` | 以 `session:{id}:meta` key 存到 MemoryStoreswitch 后需更新 agent_name [高] |
| `SessionManager::save_session_meta()` | `src/engine/session_manager.rs:131-142` | `async fn` (非 pub) | switch_agent 需要类似的 meta 更新能力;考虑提取为 `pub(crate)` [高] |
| `SessionManager::destroy()` | `src/engine/session_manager.rs:495-512` | `pub async` | dispatch 失败时清理子 session 可直接复用 [高] |
| `SessionManager.sessions` | `src/engine/session_manager.rs:92` | `pub(crate)` RwLock<HashMap> | switch_agent 和 dispatch 的 get / replace 操作均依赖此字段 [高] |
| `EngineError` 枚举 | `src/engine/error.rs:16-46` | `#[non_exhaustive]` | 已有 6 个变体,需追加 DispatchFailed / SwitchFailed / SubAgentStreamError [中] |
| `futures-core` / `futures-util` | `Cargo.toml:17-18` | 已引入 | dispatch_stream 返回 `Pin<Box<dyn Stream>>` 所需依赖已就绪,无需新增 [高] |
### Agent trait 与 SessionManager 之间的关系
```
AgentSession {
agent: Arc<dyn Agent>, // 可替换
session_memory: SessionMemory, // 可继承(clone backend
slots: HashMap<String, ContextSlot>,
turn_index: u32,
cost_so_far: CostTracker,
// ... 其余内部字段
}
SessionManager {
sessions: RwLock<HashMap<String, Arc<Mutex<AgentSession>>>>,
checkpointer: Checkpointer,
store: Arc<dyn MemoryStore>,
config: SessionManagerConfig,
}
```
`AgentSession.agent``pub` 字段,这意味着 `switch_agent` 只需 `get()``lock()` → 替换 `agent` → 写回 meta。Route 明确、无架构阻力 [高]。
### 锁契约(需格外注意)
`src/engine/session_manager.rs:4-12` 记录了锁契约:**不持有 RwLock 跨越 `.await`**。所有 `.await` 点必须在 RwLock guard drop 之后。这意味着:
- `switch_agent`:读锁 `get()` 返回 `Arc<Mutex<AgentSession>>` 后释放,然后 lock session 级别的 Mutex → 替换 agent → 释放 Mutex → save_session_metaI/O[高]
- `dispatch`:读锁 `get()` 父 session → 释放 → `create_child`(内部写锁)→ lock 子 session → inherit memory → submit_turn → 释放 [高]
- 不会引入新的死锁风险 [高]
### 现有测试覆盖
SessionManager 已有 906 行(含 12 个测试),覆盖 create/get/destroy/create_child/replace/recover/children/parent/并发创建等场景:`src/engine/session_manager.rs:527-906`。Phase 18 新增测试不修改这些已有测试。
---
## 调研发现
### 1. bridge_keys 注入位置
**问题**bridge_keys 本质是父 session 想注入到子 agent prompt 中的上下文数据。它应该放在哪里?
**调研来源**
- `docs/note-opencode-subagent-dispatch.md` — 明确反对修改 Agent 的 `system_prompt()` [高]
- `src/agent/agent.rs:21``system_prompt()` 返回 `&str`,无状态变更能力 [高]
- `src/agent/session_memory.rs``SessionMemory::set()` 提供 key-value 写入,子 agent 可读 [高]
**结论**bridge_keys 通过 SessionMemory 副本继承 + 过滤注入,不碰 `system_prompt()`。子 agent 通过 `get_session_data(key)` 读取桥接数据 [高]。
### 2. SessionMemory 继承策略
**问题**:子 agent 启动时,父 session 的 SessionMemory 如何传递?
**方案 A —— 引用共享**:父子共享同一 `SessionMemory` 实例(Arc clone 后端)。优点是零拷贝,缺点是父子隔离被破坏 [中]。
**方案 B —— 快照副本**:父调用 `list_entries()` 获取全量条目,子通过 `set_with_meta()` 写入自己的 namespace。优点是隔离性强,缺点是 O(n) 拷贝开销 [高]。
**来源**
- `src/agent/session.rs:503-524``to_snapshot()` 已实现类似的 list_entries → HashMap 拍平 [高]
- `src/agent/session_memory.rs:61-79``set_with_meta()` 可保留原始 metadata [高]
- `docs/note-opencode-subagent-dispatch.md` — SA 建议副本策略 [中]
**结论**:采用方案 B(快照副本),隔离性优先。`inherit_session_memory` 内部使用 `list_entries()` → 按 `bridge_keys` 过滤 → `set_with_meta()` 写入子 namespace [高]。
### 3. dispatch_all 部分成功语义
**问题**:当一批子代理中部分失败时,dispatch_all 应该整体失败还是返回部分成功的 `Vec`
**来源**`docs/note-opencode-subagent-dispatch.md` — PM 和 SA 一致认为应返回部分成功语义 [高]。
**结论**:返回 `Vec<Result<SubTaskResult, EngineError>>`。调用方可迭代检查每个结果,失败条目保留 checkpoint 以便审计 [高]。
### 4. dispatch_stream 生命周期
**问题**`dispatch_stream` 需要返回一个流,流内部要做 `submit_turn_stream` + `finalize_active_stream`。session 所有权和生命周期如何管理?
**来源**
- `src/engine/session_manager.rs:390-412``submit_turn_stream` 的锁模式:短持锁获取流后立即释放 [高]
- `src/agent/session.rs:384-445``submit_turn_stream` 自身不持有跨 await 的锁 [高]
- `docs/note-opencode-subagent-dispatch.md` — SA 建议在 AgentSession 新增 `finalize_active_stream()` 内部方法 [中]
**结论**`dispatch_stream` 使用 `&Arc<Self>` 签名 + `tokio::spawn`。内部流管道:create_child → inherit_memory → submit_turn_stream → mpsc channel 转发事件 → 流消费完毕后调用 `finalize_active_stream()`。session 通过 `Arc<Mutex<AgentSession>>` 在 spawned task 中持有 [高]。
### 5. Cargo.toml 依赖分析
**来源**
- `Cargo.toml:17``futures-util = "0.3"` 已在依赖中 [高]
- `Cargo.toml:15``tokio-stream = "0.1"` 已在依赖中 [高]
- `Cargo.toml:16``futures = "0.3"` 已在依赖中 [高]
**结论**dispatch_stream 所需的 `StreamExt` / `ReceiverStream` 所需的基础设施已全部就绪,无需新增任何依赖 [高]。
---
## 可选方案
### 方案 Aswitch_agent 作为 AgentSession 方法 vs SessionManager 方法
| 维度 | A1: AgentSession 方法 | A2: SessionManager 方法 |
|------|-----------------------|------------------------|
| 实现位置 | `agent/session.rs` | `engine/switch.rs` |
| 职责归属 | session 实例级 | 管理器级 |
| 能否更新 SessionMeta | 不能(无 store 引用) | 能(有 store + checkpointer |
| 能否做自动 checkpoint | 不能(无 checkpointer | 能 |
| 与 create_child / replace 对齐 | 不对齐(create 在 SM) | 对齐(都在 SM |
**来源**
- `src/agent/session.rs:49-71` — AgentSession 不持有 store / checkpointer 引用 [高]
- `src/engine/session_manager.rs:325-347``replace()` 是 SM 方法,涉及 meta 持久化 [高]
- roadmap lines 838 — `switch_agent(session_id, new_agent)` 签名暗示 SM 方法 [中]
**结论**:采用 A2SessionManager 方法)。AgentSession 没有 store 引用,无法更新 SessionMeta。独立文件 `engine/switch.rs` 作为 SessionManager 的 impl 块。
### 方案 Bbridge_keys 注入方式
| 维度 | B1: SessionMemory 副本继承 + 过滤 | B2: 修改 Agent trait |
|------|------------------------------------|----------------------|
| 系统 prompt 侵入性 | 无 | 需新增 `set_bridge_data()` 方法 |
| switch_agent 兼容性 | 天然兼容(与 agent 解耦) | switch 后需重新注入 |
| 实现复杂度 | 一个私有辅助函数 | 需改 Agent trait + 所有实现 |
| 测试增量 | 小(只测 `inherit_session_memory` | 大(需测所有 Agent impl |
**来源**
- `src/agent/agent.rs:16-30` — Agent trait 当前仅 3 个方法,简洁 [高]
- `docs/note-opencode-subagent-dispatch.md` — "bridge_keys 通过 slot 注入,不修改 Agent system_prompt" [高]
**结论**:采用 B1。隔离关注点:Agent 负责"角色"SessionMemory 负责"桥接数据"。
### 方案 Cdispatch_stream 返回类型
| 维度 | C1: `Pin<Box<dyn Stream<Item=SubTaskStreamEvent>+Send>>` | C2: 自定义 struct 包装 |
|------|----------------------------------------------------------|------------------------|
| 与现有 API 一致性 | 与 `submit_turn_stream` 一致 [高] | 不一致 |
| 调用方灵活性 | 直接 `.next()` + StreamExt | 需解包装 |
| 实现复杂度 | 直接返回 stream | 需额外 struct + 方法 |
| 可组合性 | 高(可直接 map/filter/collect | 低 |
**来源**
- `src/engine/session_manager.rs:390-412``submit_turn_stream` 返回 `Pin<Box<dyn Stream<Item=StreamEvent>+Send>>` [高]
**结论**:采用 C1。保持一致的模式,调用方可以 `StreamExt::collect` / `map` 等。
### 否决方案
| 方案 | 否决原因 |
|------|----------|
| switch_agent 做自动 checkpoint | 与 auto_checkpoint 语义不一致(submit_turn 才触发),用户可手动 checkpoint。来源:`src/engine/session_manager.rs:357-384` auto_checkpoint 仅在 submit_turn/finalize_turn 触发 |
| AgentSession 中的 `Agent``Box<dyn Agent>` | 与现有 `Arc<dyn Agent>` 不一致,且 SessionSnapshot 不序列化 agent`src/agent/session.rs:502`)。来源:`src/agent/session.rs:53` |
| dispatch_all 返回所有成功再返回 | 需要调用方等待全部完成才能拿到第一个结果。Rust 已有 `JoinSet` / `FuturesUnordered` 可选,但 v0.3 先保持简单 |
| child_memory 加额外权限控制 | 子 session 是父创建的,父天然有 destroy / read 权限。来源:`docs/note-opencode-subagent-dispatch.md` PM 明确"不做额外权限控制" |
| 子 session 失败时保留 checkpoint | `destroy()``checkpointer.delete_all``session_manager.rs:508`),不保留持久化残留。失败路径的调试信息通过 `tracing::error!` 日志记录 |
---
## 推荐方案
### 整体架构
```
SessionManager (existing)
├── switch_agent(id, new_agent) → engine/switch.rs
├── dispatch(parent, agent, task, cfg) → engine/sub_agent.rs
├── dispatch_stream(parent, agent, task, cfg) → engine/sub_agent.rs
└── dispatch_all(parent, tasks, cfg) → engine/sub_agent.rs
```
`switch.rs``sub_agent.rs` 均为 SessionManager 的 `impl` 块文件,通过 `pub mod``engine/mod.rs` 中注册 [高]。
### 决策清单
| # | 决策 | 结论 | 理由 |
|---|------|------|------|
| D1 | switch_agent 位置 | SessionManager 方法,在 `engine/switch.rs` | 需 store 更新 SessionMetaAgentSession 无 store 引用 |
| D2 | bridge_keys 注入方式 | SessionMemory 副本继承 + 过滤,不碰 system_prompt | 概念正交,switch_agent 友好 |
| D3 | Memory 继承策略 | 快照副本(list_entries → set_with_meta | 父子隔离优先,O(n) 拷贝可接受 |
| D4 | dispatch_all 返回类型 | `Vec<Result<SubTaskResult, EngineError>>` | 部分成功语义,Rust-idiomatic |
| D5 | dispatch_stream 返回类型 | `Pin<Box<dyn Stream<Item=SubTaskStreamEvent>+Send>>` | 与 `submit_turn_stream` 一致 |
| D6 | switch_agent 的 lock 策略 | get() 读锁立即释放 → Mutex lock → 替换 → 释放 Mutex → I/O | 严格遵循已有锁契约 |
| D7 | switch checkpoint | 不自动 checkpoint | 与 `auto_checkpoint` 语义一致(仅 submit_turn/finalize_turn 触发) |
| D8 | dispatch 失败清理 | destroy 子 session | 不留僵尸 session |
| D9 | dispatch 失败时 checkpoint | destroy 清理全部(含 checkpoint | `destroy()` 内部调 `checkpointer.delete_all`,不留持久化垃圾 |
| D10 | dispatch_all 并发控制 | `tokio::sync::Semaphore` | 轻量、内建、语义清晰 |
| D11 | dispatch_all / dispatch_stream 签名 | `self: &Arc<Self>` | 满足 `tokio::spawn` `'static` 约束 |
| D12 | 子 session 创建时 bundle | 从父 session 的 `RuntimeBundle` clone | 复用 `create_child` 已有逻辑 |
### 设计理由详述
**D1 为什么 switch_agent 必须放在 SessionManager 下**:因为 switch 后需要更新持久化的 SessionMeta`agent_name` 变化),而 `save_session_meta` 需要 `&self.store`。AgentSession 不持有 store 引用(纯内存对象)。如果放在 AgentSession 上,要么给它加 store 引用(开历史倒车),要么让调用方手动调 `save_session_meta`(容易遗漏)[高]。
**D3 为什么选副本而非引用**:隔离性优先原则。父 session 可能在子运行期间 `set` 新数据,引用共享会导致子看到父的运行时中间状态;副本确保子看到的是 dispatch 时刻的稳定快照。性能方面,SessionMemory 条目数通常 < 100O(n) 拷贝可忽略 [高]。
**D5 dispatch_stream 方案**:核心挑战是 session 生命周期管理和消息 finalize。方案使用 spawn task + `tokio::sync::mpsc::unbounded_channel`。spawn task 通过事件追踪重建消息列表:记录 `user_input` 作为首条 `UserMessage`,从 `StreamEvent::ToolExecutionCompleted` 事件提取工具结果,从 `StreamEvent::MessageComplete` 提取完整响应。流结束后直接调用 `AgentSession::finalize_turn(response, new_messages).await`。当 receiver 端 drop 时 sender 侧的 `send()` 错误会被捕获,task 内清理。`dispatch_stream` 返回的 stream 发出 `SubTaskStreamEvent::ChildCreated`(先导)+ `Stream(StreamEvent)`(中间,透传)+ `Completed(SubTaskResult)`(最终),消费者无需额外调 finalize [高]。
## 实施建议
### 阶段划分
共 9 个步骤,建议依次实施,不可并行。总预估时间由实现者在实施时评估。
#### Step 1 — `error.rs` 扩展
**文件**`src/engine/error.rs`
**内容**EngineError 追加 3 个变体
- `DispatchFailed(String)` — 子代理调度通用失败
- `SwitchFailed(String)` — 角色切换失败
- `SubAgentStreamError { child_id: String, detail: String }` — 流式调度中的子代理错误
**验证**`cargo build` 成功。
**注意**:已存在的 `#[non_exhaustive]` 属性确保这不是 breaking change [高]。
#### Step 2 — `session.rs` 无变更
**文件**`src/agent/session.rs` — 不修改现有 API。
**审查发现**:第一轮审查确认 `finalize_active_stream()` 假设不成立。`submit_turn_stream``session.rs:384-445`)返回 stream 后 `LlmCycle` 即被 drop`cycle.rs:647` 通过 `std::mem::take` 移出消息),不存在"active stream 内部状态"可读取。
**结论**:改为在 `dispatch_stream` 的 spawn task 中**从 StreamEvent 序列重建消息列表**,直接调用已有的 `finalize_turn(response, new_messages).await`。详见 Step 7 第 3 项。
**验证**:不修改 `session.rs`Step 7 实施前 `cargo build` 可通过。
#### Step 3 — `switch.rs`
**文件**`src/engine/switch.rs`
**内容**
```rust
impl SessionManager {
/// 热切换指定 session 的 Agent 角色。
///
/// - 保留 slot 历史 / turn_index / session_memory / cost_so_far
/// - 自动更新 SessionMeta 中的 agent_name(保持原始 created_at / parent_id
/// - 不自动 checkpoint(与 `auto_checkpoint` 语义一致:仅 submit_turn 触发)
/// - **注意**: 切换后新的 system_prompt 将与已有对话历史共存。
/// 建议在切换后发送一条明确的上下文过渡提示
/// (如"你现在以新角色 X 的身份继续对话")作为切换后的首条输入。
/// - **安全提示**: `AgentSession.agent` 是 `pub` 字段可直接访问,
/// 绕过 `switch_agent` 直接修改会导致 SessionMeta 中的 agent_name
/// 与内存状态不一致,请始终使用此方法。
pub async fn switch_agent(
&self,
session_id: &str,
new_agent: Arc<dyn Agent>,
) -> Result<(), EngineError> {
// 1. get sessionRwLock 读锁,返回后释放)
let session = self.get(session_id).await?;
// 2. lock Mutex,替换 agent,读 name + turn_index
let (agent_name, turn_index) = {
let mut guard = session.lock().await;
guard.agent = new_agent;
(guard.agent.name().to_string(), guard.turn_index())
}; // 释放 Mutex
// 3. 读取原始 SessionMeta(用于保留 created_at / parent_id
let existing_meta = self
.load_session_meta(session_id)
.await?
.ok_or_else(|| EngineError::SessionNotFound(session_id.to_string()))?;
// 4. 构造新 meta 并持久化(I/O,无锁)
let meta = SessionMeta {
session_id: session_id.to_string(),
agent_name,
parent_id: existing_meta.parent_id,
created_at: existing_meta.created_at,
turn_count: turn_index,
};
self.save_session_meta(&meta).await?;
tracing::info!(
session_id = %session_id,
agent_name = %meta.agent_name,
previous_agent = %existing_meta.agent_name,
"agent switched"
);
Ok(())
}
}
```
**测试**(预计 4 个):
1. 基本切换:switch 后 `agent.name()` 返回新 name
2. 上下文保留:turn_index / session_memory / slot 历史均不变
3. SessionMeta 持久化:`load_session_meta` 验证 agent_name 已更新
4. 不存在的 session:返回 `SessionNotFound`
**验证**`cargo test --all-targets` + clippy
#### Step 4 — `sub_agent.rs` 类型
**文件**`src/engine/sub_agent.rs`
**内容**3 个类型定义
```rust
/// 子代理调度配置。
#[derive(Debug, Clone)]
pub struct DispatchConfig {
/// 最大并发数(dispatch_all 用)。默认 10。
pub max_concurrency: usize,
/// 是否继承父 SessionMemory。默认 true。
pub inherit_session_memory: bool,
/// 桥接 key 列表:
/// - `None` = 不继承任何父 SessionMemory
/// - `Some(vec![])` = 继承全部父 SessionMemory
/// - `Some(keys)` = 仅继承指定的 keys
/// 默认 `None`(零继承),显式选择加入。
pub bridge_keys: Option<Vec<String>>,
/// 子↔子共享 namespace。如果为 `Some(prefix)`
/// 子 agent 可通过 `session.get_session_data(key)` 访问
/// `shared:{prefix}:{key}` 命名空间的数据。
/// 默认 `Some(parent_session_id)`。
pub shared_namespace: Option<String>,
}
impl Default for DispatchConfig {
fn default() -> Self {
Self {
max_concurrency: 10,
inherit_session_memory: true,
bridge_keys: None,
shared_namespace: None,
}
}
}
/// 子代理执行结果。
///
/// dispatch 成功后子 session **保留在 SessionManager 中**,调用方可
/// 通过 `sm.get(&result.child_id)` 获取子 session 引用,进而通过
/// `session_memory()` 读取子 SessionMemory(如 "result_summary")。
#[derive(Debug)]
pub struct SubTaskResult {
/// 子 session ID。可通过此 ID 在 SessionManager 中读取子 session。
pub child_id: String,
/// LLM 最终响应。
pub response: MessageResponse,
/// 本次调用的 token 用量。
pub usage: CostTracker,
/// 可选摘要(读取子 session_memory 中的 "result_summary")。
pub summary: Option<String>,
}
```
```rust
/// 流式子代理调度事件。
#[derive(Debug)]
pub enum SubTaskStreamEvent {
/// 子 session 已创建(携带 child_id)。
ChildCreated { child_id: String },
/// LLM 流事件(透传)。
Stream(StreamEvent),
/// 执行完成(携带完整结果)。
Completed(SubTaskResult),
}
```
`SubTaskStreamEvent` 需实现 `Display``std::error::Error``Completed``ChildCreated` 不触发错误路径,`Display` 仅用于调试日志)[中]。
**验证**`cargo build`
#### Step 5 — `sub_agent.rs dispatch` 核心
**文件**`src/engine/sub_agent.rs`
**内容**
```rust
impl SessionManager {
/// 私有辅助:从父 session memory 继承条目到子 session。
///
/// **一致性模型**:捕获的是调用时刻的父 session_memory 快照。
/// 即使在 `list_entries()` 返回后、`set_with_meta()` 写入前
/// 父 session 被并发写入新数据,子 session 也**不会**看到这些
/// 新数据(快照副本的内生特征)。[审查确认]
async fn inherit_session_memory(
&self,
parent_id: &str,
child_id: &str,
config: &DispatchConfig,
) -> Result<(), EngineError> { /* ... */ }
/// 派发一个子任务,返回结构化结果。
pub async fn dispatch(
&self,
parent_id: &str,
sub_agent: Arc<dyn Agent>,
task: impl Into<String>,
config: DispatchConfig,
) -> Result<SubTaskResult, EngineError> { /* ... */ }
}
```
**dispatch 流程**
1. `create_child(parent_id, sub_agent)` → 获取 child_id
2.`config.inherit_session_memory == true``inherit_session_memory(parent_id, child_id, config)`
3. `submit_turn(child_id, task)` → 获取 response
4. 读取 `"result_summary"`(可选)
5. 返回 `SubTaskResult`(子 session 保留在 SessionManager 中,可通过 `sm.get(&child_id)` 读取 child_memory
6. 失败路径:`let _ = self.destroy(&child_id).await; tracing::error!(...)`(静默吞掉清理错误,**原始 EngineError 优先**`destroy` 会清理子 session 的 SessionMeta + checkpoint 条目,不留僵尸)
**测试**(预计 5 个):
1. 基本调度:子 agent 返回预期响应
2. bridge_keys 过滤:仅指定的 key 被继承
3. memory 继承:父 set 的值子可读到
4. submit_turn 失败:错误传播 + 子 session 被销毁
5. 无效 parent_id:返回 `SessionNotFound`
**验证**`cargo test --all-targets`
#### Step 6 — `sub_agent.rs dispatch_all`
**文件**`src/engine/sub_agent.rs`
**内容**
```rust
impl SessionManager {
/// 并行派发一批子任务。
pub async fn dispatch_all(
self: &Arc<Self>,
parent_id: &str,
tasks: Vec<(Arc<dyn Agent>, String)>,
config: DispatchConfig,
) -> Vec<Result<SubTaskResult, EngineError>> { /* ... */ }
}
```
**设计要点**
- 使用 `tokio::sync::Semaphore` 限制并发数(默认 `config.max_concurrency`
- **Semaphore acquire 在 spawn 内**`let permit = semaphore.clone().acquire_owned().await;` — permit 所有权转移到 spawned task。避免 spawn N 个 task 时全量分配 Future 内存 [审查修复]
- 每个 task `tokio::spawn` + `Arc<Self>` clone
- 内部调用 `dispatch` 的同类逻辑(create_child → inherit → submit_turn
- **indexed 收集**:预分配 `Vec<Option<Result<...>>>``tasks` 索引填入,维持输入顺序。不使用排序(排序需等所有 child_id 生成后)[审查修复]
- 每个结果独立:`Ok(SubTaskResult)``Err(EngineError)`
**测试**(预计 4 个):
1. 并行 3 个全部成功
2. 部分失败(MockProvider 对特定 task 返回错误)
3. Semaphore 上限验证(max_concurrency=1 时串行执行)
4. 空 tasks 列表
**验证**`cargo test --all-targets`
#### Step 7 — `sub_agent.rs dispatch_stream`
**文件**`src/engine/sub_agent.rs`
**内容**
```rust
impl SessionManager {
pub async fn dispatch_stream(
self: &Arc<Self>,
parent_id: &str,
sub_agent: Arc<dyn Agent>,
task: impl Into<String>,
config: DispatchConfig,
) -> Result<
Pin<Box<dyn Stream<Item = SubTaskStreamEvent> + Send>>,
EngineError,
> { /* ... */ }
}
```
**设计要点**
- 同步部分(lock 外):create_child + inherit_memory
- 获取 Stream 后通过 `tokio::sync::mpsc::unbounded_channel` 转发事件(与 LLM stream 内部背压策略一致,避免有界 channel 的 sender 阻塞风险)[审查修复]
- spawn task 持有 `Arc<Mutex<AgentSession>>` 消费 LLM stream
- **消息重建机制**(替代已移除的 `finalize_active_stream()`):[审查修复]
```
// 在 spawn task 中:
let mut new_messages: Vec<Message> = vec![Message::user_text(&task)];
let mut final_response: Option<MessageResponse> = None;
while let Some(event) = llm_stream.next().await {
// 转发事件到输出 channel
tx.send(SubTaskStreamEvent::Stream(event.clone()))?;
// 从 ToolExecutionCompleted 构造 ToolResult 消息
if let StreamEvent::ToolExecutionCompleted { tool_name, tool_call_id, input, output } = &event {
new_messages.push(Message::tool_result(tool_call_id, tool_name, output));
}
// 捕获最终响应
if let StreamEvent::MessageComplete(ref resp) = event {
final_response = Some(resp.clone());
}
}
// 流结束后,追加 assistant 消息并 finalize
if let Some(response) = &final_response {
new_messages.push(response.message.clone());
child_session.lock().await
.finalize_turn(response, new_messages).await?;
}
```
- 事件序列:`ChildCreated` → `Stream(StreamEvent)` × N → `Completed(SubTaskResult)`
- 消费者 drop receiver → unbounded channel sender 错误 → task 自动退出
**测试**(预计 4 个):
1. 事件序列验证:收到 ChildCreated → 至少一个 Stream → Completed
2. 错误传播:LLM 内部错误 → 正确映射到 error 事件
3. receiver droppeddrop receiver 后 task 正确退出,不 panic
4. finalize 正确性:Completed 中的 usage / summary 正确
**验证**`cargo test --all-targets`
#### Step 8 — `mod.rs` + 集成验证
**文件**`src/engine/mod.rs`
**内容**:追加 `pub mod switch;` 和 `pub mod sub_agent;` + `pub use`
```rust
pub mod checkpointer;
pub mod error;
pub mod session_manager;
pub mod snapshot;
pub mod switch; // <-- 新增
pub mod sub_agent; // <-- 新增
```
**验证**
1. `cargo test --all-targets` — 374+ 测试全部通过
2. `cargo clippy --all-targets -- -D warnings` — 0 警告
3. `cargo doc --no-deps` — 0 warning
#### Step 9 — 示例
**文件 1**`examples/agent_switch_demo.rs`(约 80 行)
- 创建 session → submit_turn(角色 A)→ switch_agent(角色 B)→ submit_turn(角色 B)→ 验证上下文保留
- 演示目的:证明 switch_agent 保留 slot 历史 / turn_index / session_memory
**文件 2**`examples/sub_agent_dispatch_demo.rs`(约 150 行)
- 父 session → dispatch_all 3 个子 agent(研究、写作、审校)→ 收集结果 → 父汇总
- 树形验证:`children(parent_id)` 返回 3 个子 ID
- 演示目的:多 agent 协作完整链路
**文件 3**`examples/bridge_keys_demo.rs`(约 140 行)
- 父设置 SessionMemorykey: "project_goal", "constraints")→ dispatch + bridge_keys → 子 agent 通过 `get_session_data` 读取
- 子↔子交互:父通过 `DispatchConfig.shared_namespace` 设定共享命名空间,子 A 写入 `shared:{parent_id}:fact_x`,子 B 通过约定 key 读取
- 演示目的:bridge_keys 过滤机制 + 父子数据桥接 + 子↔子共享 namespace
**文件 4 — 新增**`examples/dispatch_stream_demo.rs`(约 100 行)
- 父 session → dispatch_stream 单个子 agent → 消费 `SubTaskStreamEvent` 序列
- 验证收到 `ChildCreated` + 至少一个 `Stream` + `Completed` 事件
- 输出 `SubTaskResult.child_id` / `usage` / `summary`,验证消息重建和 finalize 正确性
- 演示 `receiver dropped` 场景:中途 drop receiver 后 task 正确退出不 panic
- 演示目的:dispatch_stream 的事件序列 + finalize 完整性验证
**验证**4 个示例全部 `cargo run --example` exit 0
### 高层实施建议
1. **Step 1 优先于所有步骤**:Error 扩展是所有后续步骤的基础,无依赖可并行 [高]
2. **Step 3 独立性强**switch_agent 不依赖 dispatch 的任何类型,可单独实施和测试 [高]
3. **Step 4 是 Step 5-7 的前置**:类型定义不依赖其他逻辑,建议在 Step 3 完成后立即实施 [高]
4. **Step 5-7 按复杂度递增**dispatch → dispatch_all → dispatch_stream。dispatch_all 复用 dispatch 的核心逻辑;dispatch_stream 是最复杂的,建议最后实施 [高]
5. **Step 8 集成验证不可跳过**clippy + doc 全量验证确保无回归 [高]
6. **Step 9 在所有核心完成后实施**:示例是验收标准的一部分,PM 确认 3 个递进示例 [中]
7. **全量测试密码**:实施过程中持续 `cargo test --all-targets`,不在最后统一修复 [高]
### 风险矩阵
| # | 风险 | 等级 | 可能性 | 对策 |
|---|------|------|--------|------|
| R1 | dispatch_stream 的消息重建:从 StreamEvent 序列重建 `new_messages` 的完整性 | 🟡 中 | 低 | 已移除 `finalize_active_stream()` 方案。spawn task 通过追踪 `ToolExecutionCompleted` / `MessageComplete` 事件重建消息列表。完整性由 `MessageComplete` 事件保证 |
| R2 | `tokio::spawn` `'static` + SessionManager 引用 | 🟡 中 | 低 | dispatch_all 和 dispatch_stream 签名已明确用 `&Arc<Self>`;调用方包装 `Arc<SessionManager>` |
| R3 | 子 session 创建成功但后续 submit_turn 失败 | 🟡 中 | 中 | dispatch 内 `destroy(child_id)` 放在 `?` 前确保清理;通过 `let child_id = ...;` 先绑定,再 `let r = submit_turn(...).await`,失败时 `destroy(&child_id).await?` 清理 |
| R4 | 部分失败时孤儿 checkpoint 数据 | 🟢 低 | 必然 | **正向利用**:保留用于调试审计,存储开销可忽略 |
| R5 | 并发 dispatch_all 中任务 panic | 🟡 中 | 低 | `tokio::spawn` 的 `JoinHandle` 通过 `.await` 捕获 panicpanic 传播到 `dispatch_all` 内作为 `Err` 返回 |
| R6 | bridge_keys 中不存在的 key | 🟢 低 | 中 | 静默跳过(与 `List_entries` 返回全量后再过滤,不存在的 key 自然不会出现在结果中) |
### 架构图(文本示意)
```
SessionManager
/ | \
/ | \
switch_agent dispatch dispatch_stream
| | |
v v v
AgentSession create_child create_child
.agent = new inherit_mem inherit_mem
SessionMeta submit_turn submit_turn_stream
更新 返回结果 mpsc 转发事件
finalize_on_complete
交互层次:
父 -> 子: SessionMemory snapshot + bridge_keys 过滤
子 -> 父: SubTaskResult { child_id, response, usage, summary }
子 <-> 子: shared:{parent_session_id} namespace
```
---
## 参考来源
### 代码路径
| 文件 | 用途 |
|------|------|
| `src/agent/session.rs` | AgentSession 定义、`agent` pub 字段(L53)、`session_memory` pub 字段(L58)、`submit_turn_stream`L384)、`finalize_turn`L456 |
| `src/agent/agent.rs` | Agent trait 定义(3 个方法) |
| `src/agent/session_memory.rs` | SessionMemory: `set`L50)、`set_with_meta`L61)、`list_entries`L81 |
| `src/engine/session_manager.rs` | SessionManager: `create_child`L202)、`get`L244)、`destroy`L495)、`save_session_meta`L131)、`load_session_meta`L144)、SessionMetaL32)、锁契约(L4-12 |
| `src/engine/error.rs` | EngineError 枚举(当前 6 变体,`#[non_exhaustive]` |
| `src/engine/mod.rs` | 模块注册 |
| `src/engine/snapshot.rs` | SessionSnapshotfrom_snapshot / to_snapshot 所需) |
| `src/llm/stream.rs` | StreamEvent 枚举 |
| `Cargo.toml` | 依赖声明(`futures-util` L17、`tokio-stream` L15、`futures-core` L18 |
### 文档路径
| 文档 | 用途 |
|------|------|
| `docs/roadmap-v0.3.0.md` §Phase 18 | Phase 18 原始需求(交付物、交互层级、优先级) |
| `docs/note-opencode-agent-switching.md` | Agent 热切换调研笔记(桥接方案分析、生命周期讨论) |
| `docs/note-opencode-subagent-dispatch.md` | SubAgent Dispatch 调研笔记(PM/SA 建议、设计推演) |
| `docs/23-phase17-agent-execution-engine.md` | Phase 17 方案文档(SessionManager 设计背景) |
| `docs/7-agent-runtime.md` | Agent 运行时设计文档(Session 与 Agent 的关系) |
| `docs/17-phase10-contextslot.md` | ContextSlot 上下文管理(Phase 10 |
| `docs/24-phase18-agent-switch-and-dispatch.md` | 本文档 — 第 1 轮审查修复记录 |
### 决策轨迹
| 决策 | 参考来源 | 置信度 |
|------|----------|--------|
| switch_agent 在 SessionManager 而非 AgentSession | `src/agent/session.rs` AgentSession 无 store 引用 | 高 |
| bridge_keys 通过 SessionMemory 副本,不碰 system_prompt | `docs/note-opencode-subagent-dispatch.md` PM/SA 建议 | 高 |
| dispatch_all 返回 `Vec<Result<..>>` 部分成功 | `docs/note-opencode-subagent-dispatch.md` PM/SA 一致 | 高 |
| dispatch_stream 返回 `Pin<Box<dyn Stream>>` | `src/engine/session_manager.rs` `submit_turn_stream` 签名一致 | 高 |
| 失败时 destroy 子 session 清理全部(含 checkpoint | `session_manager.rs:508` destroy 调 `checkpointer.delete_all` | 高 |
| dispatch_all 用 `&Arc<Self>` 签名 | `tokio::spawn` `'static` 约束 | 高 |
| child_memory 不做额外权限控制 | `docs/note-opencode-subagent-dispatch.md` PM 明确 | 高 |
| `#[non_exhaustive]` 已存在 -> 新增 EngineError 变体不是 breaking change | `src/engine/error.rs:15` | 高 |
| `futures-util` 已存在,无需新增依赖 | `Cargo.toml:17` | 高 |
### 审查修复轨迹(第 1 轮)
| # | 问题 | 🔴/🟡 | 修复内容 |
|---|------|--------|---------|
| F1 | `finalize_active_stream()` 假设不成立:`submit_turn_stream` 返回后 cycle 被 drop | 🔴 | 移除 Step 2 的 `finalize_active_stream()`,改为 spawn task 内从 StreamEvent 重建消息列表直接调 `finalize_turn()` |
| F2 | `SubTaskResult` 缺 child_memory 访问路径 | 🔴 | `SubTaskResult.child_id` 可经由 `sm.get()` 读取子 session。构型 doc comment 增加说明 |
| F3 | dispatch 失败路径 `destroy` 自身 I/O 可能失败,覆盖原始错误 | 🔴 | 改用 `let _ = destroy` + `tracing::error!`,原始 `EngineError` 优先 |
| F4 | switch_agent SessionMeta 构造中 `created_at`/`parent_id` 用占位符 | 🔴 | 改用 `load_session_meta` 读取原始值,`turn_count` 从 `guard.turn_index()` 读取 |
| F5 | D9 与 `destroy()` 实现矛盾(方案说保留,代码说删除) | 🟡 | D9 修正为"destroy 清理全部",否决条目同步更新 |
| F6 | `bridge_keys` 默认值安全反直觉(空=全量继承) | 🟡 | 类型改为 `Option<Vec<String>>``None` = 不继承(默认),`Some(vec![])` = 全量 |
| F7 | Semaphore acquire 位置未指定 | 🟡 | 指定 `acquire_owned()` 在 spawn 内 + indexed 收集维持输入顺序 |
| F8 | dispatch_stream 缺少独立示例 | 🟡 | Step 9 追加 `dispatch_stream_demo` |
| F9 | mpsc channel 背压策略未指定 | 🟡 | 改用 `unbounded_channel`,与 LLM stream 内部模式一致 |
| F10 | 子↔子交互层缺少实现细节 | 🟡 | `DispatchConfig.shared_namespace` 字段 + 示例 3 演示 |
| F11 | inherit_session_memory 竞态窗口未文档化 | 🟡 | doc comment 声明快照一致性模型 |
| F12 | switch_agent system_prompt 断裂风险未说明 | 🟡 | doc comment 增加使用建议 + 安全提示 |
---
*本文档对应的实施步骤记录在 `docs/roadmap-v0.3.0.md` §Phase 18,实施完成后同步更新 roadmap 状态。*
@@ -0,0 +1,652 @@
# Phase 19:知识图谱 + 双通道检索
## 背景与目标
### 问题空间
agcore v0.3.0 已交付 Phase 0-18,记忆系统具备 `KnowledgeStore`(页面级内容检索)和 `VectorStore`(向量语义检索),但缺少实体-关系维度的关联检索能力。用户搜索"X 与什么相关"时,现有系统无法返回实体间的拓扑关系。
`docs/note-knowledge-graph-design.md` 已记录完整的知识图谱设计,Phase 19 将其落地为可编译、可测试的模块。
### 目标
- 新增 `memory/graph.rs`,实现 `KnowledgeGraph` trait + `InMemoryGraph` 内存实现
- 扩展 `MemoryRetriever` 为双通道:KnowledgeStore(内容)+ KnowledgeGraph(实体关系)
- 通过 `RetrievalStrategy` 枚举控制通道选择(Hybrid / KnowledgeOnly / GraphOnly
- 统一 `RetrievalResult.items``Vec<RetrievalItem>`enum 变体区分类别
- 标签管理 API 预留(无自动提取流程,Agent 层显式写入)
- Phase 19 仅提供底层 CRUD 接口,实体/关系的写入由 Agent 层(如 LLM 提取)在后续 Phase 中接入。当前无自动填充流程,需 Agent 显式调用 `add_entity`/`add_relation`
### 与现有模块的定位关系
```
KnowledgeStore: 页面级内容("什么是 X" ← Phase 6 已有
VectorStore: 向量语义(相似度检索) ← Phase 15 已有
KnowledgeGraph: 实体级关系("X 与什么相关" ← Phase 19 新增
MemoryRetriever: 统一检索入口 ← Phase 19 扩展为双通道
```
### 依赖与优先级
- **依赖**Phase 6KnowledgeStore[高]、Phase 15VectorStore 模式参考)[低]
- **优先级**P0v0.3.0 最后一个 Phase
- **预估规模**:约 600 行核心 + 200 行测试
---
## 需求分析
### 功能需求
| ID | 需求 | 优先级 |
|----|------|--------|
| F1 | `GraphEntity` / `GraphRelation` / `RelationDirection` 类型定义 | P0 |
| F2 | `KnowledgeGraph` trait10 个 async 方法) | P0 |
| F3 | `InMemoryGraph` 实现(HashMap + Vec + tag_index | P0 |
| F4 | BFS 图遍历(防环、权重衰减、方向过滤) | P0 |
| F5 | `RetrievalItem` / `RetrievalStrategy` / `RetrievalResult` 扩展 | P0 |
| F6 | `MemoryRetriever` 双通道(`tokio::join!` 并行) | P0 |
| F7 | 标签管理(set_entity_tags / find_tags / entity_count_by_tag | P1(预留) |
### 非功能需求
| ID | 需求 | 说明 |
|----|------|------|
| NF1 | 零新依赖 | 纯 std + tokio + 已有 crate |
| NF2 | 异步安全 | `InMemoryGraph` 内部 Mutex 保护,trait 方法 async |
| NF3 | 类型安全 | 不新增 `MemoryError` 变体,复用现有 5 个 |
| NF4 | 向后兼容 | `MemoryRetriever::new()` 签名不变,可选链式注入 graph |
| NF5 | Breaking change 受控 | `RetrievalResult.items` 类型变化,需在 CHANGELOG 标注 |
---
## 方案设计
### 3.1 数据模型
#### GraphEntity
```rust
/// 图谱实体 —— 表示一个可被关联检索的节点。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphEntity {
/// 唯一标识(如 "person:rust-dev-01")。
pub id: String,
/// 实体名称(用于展示和关键词匹配)。
pub name: String,
/// 实体类型("person" | "concept" | "project" | ...)。
pub entity_type: String,
/// 一句话描述。
pub description: String,
/// 检索标签(全小写,原子词,由 Agent 层显式写入)。
pub tags: Vec<String>,
/// 任意附加属性(与 PersistentVectorStore.metadata 保持一致)。
pub properties: HashMap<String, String>,
}
```
#### GraphRelation
```rust
/// 图谱关系 —— 连接两个实体的有向边。
///
/// 无 `id` 字段,用 `(source_id, target_id, relation_type)` 三元组唯一标识。
/// 提供 `composite_key()` 作为派生 id,满足未来独立 id 需求。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphRelation {
/// 源实体 ID。
pub source_id: String,
/// 目标实体 ID。
pub target_id: String,
/// 关系类型("works_on" | "part_of" | "related_to" | ...)。
pub relation_type: String,
/// 关系强度 [0.0, 1.0],用于 BFS 评分衰减。
pub weight: f32,
}
impl GraphRelation {
/// 复合键:`source_id:target_id:relation_type`,用于去重和查找。
pub fn composite_key(&self) -> String {
format!("{}:{}:{}", self.source_id, self.target_id, self.relation_type)
}
}
```
#### RelationDirection
```rust
/// 关系遍历方向。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelationDirection {
/// 仅出边:source_id → target_id(默认)。
Outgoing,
/// 仅入边:target_id → source_id。
Incoming,
/// 双向遍历。
Both,
}
impl Default for RelationDirection {
fn default() -> Self {
Self::Outgoing
}
}
```
#### ScoredEntity
```rust
/// 带评分的实体 + 路径信息。
#[derive(Debug, Clone)]
pub struct ScoredEntity {
pub entity: GraphEntity,
/// 基于图距离的评分 [0.0, 1.0],沿路径权重乘积衰减。
pub score: f32,
/// 从查询实体到当前实体的 ID 路径(用于可解释性)。
pub path: Vec<String>,
}
```
#### TagConstraints
```rust
/// 标签约束配置。
#[derive(Debug, Clone)]
pub struct TagConstraints {
/// 每个实体最多标签数(默认 8)。
pub max_tags_per_entity: usize,
}
impl Default for TagConstraints {
fn default() -> Self {
Self {
max_tags_per_entity: 8,
}
}
}
```
### 3.2 KnowledgeGraph trait
```rust
/// 知识图谱抽象 —— 实体-关系存储与图遍历检索。
///
/// 所有方法 `async + Send + Sync`,支持跨 `.await` 调用。
/// 复用 `MemoryError`,不新增变体。
#[async_trait]
pub trait KnowledgeGraph: Send + Sync {
// ── 实体管理 ──
/// 添加或更新实体(upsert 语义)。
async fn add_entity(&self, entity: GraphEntity) -> Result<(), MemoryError>;
/// 按 ID 获取实体,不存在返回 `Ok(None)`。
async fn get_entity(&self, id: &str) -> Result<Option<GraphEntity>, MemoryError>;
/// 删除实体及其所有关联关系。
async fn remove_entity(&self, id: &str) -> Result<(), MemoryError>;
// ── 关系管理 ──
/// 添加关系(若复合键已存在则覆盖 weight)。
async fn add_relation(&self, relation: GraphRelation) -> Result<(), MemoryError>;
/// 按复合键删除关系。
async fn remove_relation(
&self,
source_id: &str,
target_id: &str,
relation_type: &str,
) -> Result<(), MemoryError>;
/// 从指定实体出发,BFS 遍历 depth 层,返回关联实体(带评分)。
///
/// - `direction`:遍历方向(Outgoing / Incoming / Both
/// - `relation_types`:可选过滤,仅遍历指定关系类型
async fn get_related(
&self,
entity_id: &str,
depth: usize,
direction: RelationDirection,
relation_types: Option<&[&str]>,
) -> Result<Vec<ScoredEntity>, MemoryError>;
// ── 检索 ──
/// 按关键词子串匹配实体(不区分大小写),与 KnowledgeStore.search 一致。
async fn find_by_keywords(&self, keywords: &[String]) -> Result<Vec<GraphEntity>, MemoryError>;
// ── 标签管理(预留接口,Agent 层显式写入) ──
/// 按前缀查找已有标签(用于标签复用)。
async fn find_tags(&self, prefix: &str) -> Result<Vec<String>, MemoryError>;
/// 设置实体标签(替换式,保留前 max_tags_per_entity 个)。
/// 返回实际设置的标签数。
async fn set_entity_tags(
&self,
entity_id: &str,
tags: Vec<String>,
) -> Result<usize, MemoryError>;
/// 按标签统计实体数量。
async fn entity_count_by_tag(&self, tag: &str) -> Result<usize, MemoryError>;
/// 获取标签约束配置。
fn tag_constraints(&self) -> TagConstraints;
}
```
### 3.3 InMemoryGraph 实现
#### 内部结构
```rust
/// 内存知识图谱实现 —— 纯内存,无持久化。
///
/// 生命周期跟随实例;持久化路径参考 InMemoryVectorStore → PersistentVectorStore 演进模式。
pub struct InMemoryGraph {
/// 内部状态(单一锁结构,避免嵌套锁死锁)
inner: Mutex<GraphInner>,
/// 标签约束
constraints: TagConstraints,
}
struct GraphInner {
/// id → entity
entities: HashMap<String, GraphEntity>,
/// 所有关系(线性扫描,实测 5000 条 ≈ 1-50µs,无需邻接表索引)
relations: Vec<GraphRelation>,
/// tag → entity_ids(反向索引,用于 find_tags / entity_count_by_tag
tag_index: HashMap<String, HashSet<String>>,
}
```
#### BFS 遍历算法
```rust
async fn get_related(
&self,
entity_id: &str,
depth: usize,
direction: RelationDirection,
relation_types: Option<&[&str]>,
) -> Result<Vec<ScoredEntity>, MemoryError> {
// 1. 验证起点存在
let inner = self.inner.lock().unwrap();
if !inner.entities.contains_key(entity_id) {
return Err(MemoryError::NotFound(entity_id.to_string()));
}
// 2. BFS 初始化
let mut visited: HashSet<String> = HashSet::new();
let mut result: Vec<ScoredEntity> = Vec::new();
// 队列:(entity_id, score, path)
let mut queue: VecDeque<(String, f32, Vec<String>)> = VecDeque::new();
queue.push_back((entity_id.to_string(), 1.0, vec![entity_id.to_string()]));
visited.insert(entity_id.to_string());
// 3. BFS 逐层遍历
for _ in 0..depth {
let mut next_queue: VecDeque<(String, f32, Vec<String>)> = VecDeque::new();
while let Some((current_id, score, path)) = queue.pop_front() {
// 筛选与 current_id 相关的关系
for rel in inner.relations.iter() {
// 方向过滤
let (match_source, match_target) = match direction {
RelationDirection::Outgoing => (&rel.source_id, &rel.target_id),
RelationDirection::Incoming => (&rel.target_id, &rel.source_id),
RelationDirection::Both => {
if rel.source_id == current_id {
(&rel.source_id, &rel.target_id)
} else if rel.target_id == current_id {
(&rel.target_id, &rel.source_id)
} else {
continue;
}
}
};
if *match_source != current_id {
continue;
}
// 关系类型过滤
if let Some(types) = relation_types {
if !types.contains(&rel.relation_type.as_str()) {
continue;
}
}
let neighbor_id = match_target.clone();
if visited.contains(&neighbor_id) {
continue;
}
visited.insert(neighbor_id.clone());
// 权重乘积衰减
let new_score = score * rel.weight;
let mut new_path = path.clone();
new_path.push(neighbor_id.clone());
result.push(ScoredEntity {
entity: inner.entities.get(&neighbor_id).cloned()
.ok_or_else(|| MemoryError::NotFound(neighbor_id.clone()))?,
score: new_score,
path: new_path.clone(),
});
next_queue.push_back((neighbor_id, new_score, new_path));
}
}
queue = next_queue;
}
// 4. 按分数降序排列
result.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
Ok(result)
}
```
`depth=0` 时仅验证起点实体存在,返回空关联列表(不遍历任何边)。
**BFS 关键设计点**
| 特性 | 处理方式 |
|------|----------|
| 环路 | `visited: HashSet<String>` 已访问集合防环 |
| 评分衰减 | 沿路径 `score *= rel.weight`,权重乘积 |
| 多路径 | BFS 天然先到先得,同一实体只保留首次到达路径 |
| 关系类型过滤 | `relation_types: Option<&[&str]>``None` 表示不过滤 |
| 方向过滤 | `RelationDirection` 枚举,`Both` 时双向检查 |
> 以上性能数据为基于算法复杂度的估算值(O(R) 线性扫描,R=关系数),实际性能需通过基准测试验证。建议在实现后添加 `#[bench]` 或 criterion 基准测试。
### 3.4 检索扩展
#### RetrievalStrategy
```rust
/// 检索策略 —— 控制双通道分流。
#[derive(Debug, Clone, Default)]
pub enum RetrievalStrategy {
/// 并行 KnowledgeStore + KnowledgeGraph,合并排序(默认)。
#[default]
Hybrid,
/// 仅 KnowledgeStore。
KnowledgeOnly,
/// 仅 KnowledgeGraph。
GraphOnly,
}
```
#### RetrievalItem
```rust
/// 统一检索条目 —— enum 变体区分类别,两通道分数均在 [0,1] 区间。
#[derive(Debug, Clone)]
pub enum RetrievalItem {
/// 知识页面(来自 KnowledgeStore)。
KnowledgePage {
page: KnowledgePage,
/// TextOverlap 评分 [0.0, 1.0]。
score: f32,
},
/// 图谱实体(来自 KnowledgeGraph)。
GraphEntity {
entity: crate::memory::graph::GraphEntity,
/// 图距离评分 [0.0, 1.0]。
score: f32,
/// 从查询实体到当前实体的 ID 路径。
path: Vec<String>,
},
}
impl RetrievalItem {
/// 统一分数(用于合并排序)。
pub fn score(&self) -> f32 {
match self {
Self::KnowledgePage { score, .. } => *score,
Self::GraphEntity { score, .. } => *score,
}
}
}
```
> **注意**:两个通道的分数维度不同(TextOverlap vs 图距离),合并排序仅用于统一返回,不代表跨通道可比性。
#### 向后兼容导出(ScoredItem
```rust
// ── 向后兼容导出 ──
/// 旧版带评分的知识页面检索结果(已废弃)。
///
/// 请迁移到 `RetrievalItem::KnowledgePage { page, score }`。
#[deprecated(since = "0.3.0", note = "使用 RetrievalItem::KnowledgePage 代替")]
#[derive(Debug, Clone)]
pub struct ScoredItem {
pub page: KnowledgePage,
pub score: f32,
}
// 在 memory.rs 模块根的重导出中保留:
// #[allow(deprecated)]
// pub use retriever::ScoredItem;
```
#### RetrievalResult 更新
```rust
/// 检索结果。
#[derive(Debug, Clone)]
pub struct RetrievalResult {
/// 统一条目列表,按分数降序排列。
pub items: Vec<RetrievalItem>,
pub query: String,
/// 本次检索实际执行的策略(可能因 graph 未注入而退化),而非用户通过 `with_strategy()` 配置的值。
pub strategy: RetrievalStrategy,
}
```
#### MemoryRetriever 扩展
```rust
pub struct MemoryRetriever {
knowledge_store: KnowledgeStore,
/// 可选知识图谱(None 时退化为单通道)。
knowledge_graph: Option<Arc<dyn KnowledgeGraph>>,
/// 检索策略(默认 Hybrid)。
strategy: RetrievalStrategy,
config: RetrieverConfig,
stop_words: HashSet<String>,
}
impl MemoryRetriever {
/// 创建新的 MemoryRetriever(保持向后兼容)。
pub fn new(knowledge_store: KnowledgeStore, config: RetrieverConfig) -> Self {
Self {
knowledge_store,
knowledge_graph: None,
strategy: RetrievalStrategy::default(),
config,
stop_words: default_stop_words(),
}
}
/// 注入知识图谱,启用双通道检索。
pub fn with_knowledge_graph(mut self, graph: Arc<dyn KnowledgeGraph>) -> Self {
self.knowledge_graph = Some(graph);
self
}
/// 设置检索策略。
pub fn with_strategy(mut self, strategy: RetrievalStrategy) -> Self {
self.strategy = strategy;
self
}
/// 检索相关记忆(双通道)。
pub async fn retrieve(&self, query: &str) -> Result<RetrievalResult, MemoryError> {
if query.is_empty() {
return Ok(RetrievalResult {
items: Vec::new(),
query: query.to_string(),
strategy: self.strategy.clone(),
});
}
let keywords = extract_keywords(query, &self.stop_words);
let has_graph = self.knowledge_graph.is_some();
// 按策略分流
match (&self.strategy, has_graph) {
// 仅知识页面
(RetrievalStrategy::KnowledgeOnly, _) | (_, false) => {
let items = self.search_knowledge_store(query, &keywords).await?;
Ok(RetrievalResult {
items,
query: query.to_string(),
strategy: RetrievalStrategy::KnowledgeOnly,
})
}
// 仅图谱
(RetrievalStrategy::GraphOnly, true) => {
let graph = self.knowledge_graph.as_ref().unwrap();
let items = self.search_graph(query, &keywords, graph).await?;
Ok(RetrievalResult {
items,
query: query.to_string(),
strategy: self.strategy.clone(),
})
}
// 混合:并行执行,合并排序
(RetrievalStrategy::Hybrid, true) => {
let graph = self.knowledge_graph.as_ref().unwrap();
let (kp_items, g_items) = tokio::join!(
self.search_knowledge_store(query, &keywords),
self.search_graph(query, &keywords, graph),
);
let mut items = kp_items?;
items.extend(g_items?);
items.sort_by(|a, b| {
b.score().partial_cmp(&a.score())
.unwrap_or(std::cmp::Ordering::Equal)
});
items.truncate(self.config.max_results);
Ok(RetrievalResult {
items,
query: query.to_string(),
strategy: self.strategy.clone(),
})
}
}
}
}
```
### 3.5 标签管理
#### 标签索引维护
`tag_index: HashMap<String, HashSet<String>>` 维护 tag → entity_ids 反向映射:
- **`set_entity_tags`**:先清除旧标签的反向引用,再写入新标签。超出 `max_tags_per_entity` 时截断。
- **`find_tags`**:遍历 `tag_index.keys()`,按前缀过滤。
- **`entity_count_by_tag`**:直接返回 `tag_index.get(tag).map_or(0, |s| s.len())`
#### 实现要点
```rust
async fn set_entity_tags(
&self,
entity_id: &str,
tags: Vec<String>,
) -> Result<usize, MemoryError> {
let mut inner = self.inner.lock().unwrap();
let entity = inner.entities.get_mut(entity_id)
.ok_or_else(|| MemoryError::NotFound(entity_id.to_string()))?;
// 清除旧标签的反向引用
for old_tag in &entity.tags {
if let Some(ids) = inner.tag_index.get_mut(old_tag) {
ids.remove(entity_id);
if ids.is_empty() {
inner.tag_index.remove(old_tag);
}
}
}
// 截断到 max_tags_per_entity
let max = self.constraints.max_tags_per_entity;
let new_tags: Vec<String> = tags.into_iter().take(max).collect();
// 写入新标签的反向引用
for tag in &new_tags {
inner.tag_index.entry(tag.clone())
.or_default()
.insert(entity_id.to_string());
}
entity.tags = new_tags.clone();
Ok(new_tags.len())
}
```
#### 标签复用流程(文档说明)
```
LLM 提取候选标签 → 对每个候选:
graph.find_tags(candidate.lowercase())
├─ 命中已有标签 → 复用
└─ 无匹配 → 注册新标签
```
> **标注**:当前无自动提取流程,需 Agent 层显式调用 `set_entity_tags`。
---
## 实现计划
| Step | 内容 | 文件范围 | 验证标准 | 预估行数 |
|------|------|----------|----------|----------|
| 1 | `graph.rs` 核心类型:GraphEntity / GraphRelation / RelationDirection / ScoredEntity / TagConstraints | `src/memory/graph.rs` | `cargo check` 编译通过 | ~80 |
| 2 | `KnowledgeGraph` trait 定义(10 个 async 方法) | `src/memory/graph.rs` | trait 编译通过,无未实现方法 | ~90 |
| 3 | `InMemoryGraph` 实现 + BFS 遍历 | `src/memory/graph.rs` | 单元测试:添加实体/关系、BFS 遍历、方向过滤、类型过滤 | ~250 |
| 4 | 标签管理实现(set_entity_tags / find_tags / entity_count_by_tag | `src/memory/graph.rs` | 单元测试:标签增删查、截断、反向索引维护 | ~80 |
| 5 | `retriever.rs` 扩展:RetrievalItem / RetrievalStrategy / MemoryRetriever 改造 | `src/memory/retriever.rs` | `cargo check` + 双通道检索测试 | ~180 |
| 6 | `memory.rs` 模块根更新 + 重导出 | `src/memory.rs` | `cargo check`pub use 无编译错误 | ~10 |
| 7 | 内联测试 | `src/memory/graph.rs` + `src/memory/retriever.rs` | `cargo test --all-targets` 全绿 | ~150 |
**总预估**:约 840 行(核心 640 + 测试 200
---
## 风险评估
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| 标签 API 无消费者 | 低 — 预留接口,不影响核心功能 | 文档标注"Agent 层显式写入",后续 Phase 接入 |
| 评分不可比 | 中 — TextOverlap vs 图距离维度不同 | `RetrievalItem` enum 变体分离,合并排序仅统一返回,文档注明维度差异 |
| BFS 性能 | 低 — 5000 关系遍历 ≈ 1-50µs | 不引入邻接表索引,等实测超过 1ms 再优化 |
| Breaking change | 中 — `RetrievalResult.items` 类型变化 | CHANGELOG 标注,`ScoredItem` 保留为 `pub` 兼容导出(deprecate |
| Mutex 竞争 | 低 — InMemoryGraph 单实例场景 | 读多写少, Mutex 性能足够;后续可升级 RwLock |
---
## 验收标准
| 检查项 | 标准 | 验证命令 |
|--------|------|----------|
| 编译 | 0 error | `cargo check --all-targets` |
| 测试 | 全绿,测试数从 ~391 增至 ~410+ | `cargo test --all-targets` |
| Clippy | 0 warning | `cargo clippy --all-targets -- -D warnings` |
| 文档 | 0 warning | `cargo doc --no-deps` |
| BFS 覆盖 | 所有边界条件:空图、单实体、环路、深度 0、方向过滤、类型过滤 | 内联测试 |
| 双通道 | Hybrid / KnowledgeOnly / GraphOnly 三种策略功能正确 | 内联测试 |
| 向后兼容 | `MemoryRetriever::new()` 签名不变,现有调用无需修改 | `cargo check` 无 breaking error |
@@ -0,0 +1,694 @@
# AG Core v0.3.2 Step 1Phase 20)— Cargo Features 基础设施改造实施方案
## 1. 背景与目标
**背景**agcore 是一个 Rust 编写的智能体核心工具箱,目前约 23,718 行、66 个源文件。v0.3.0 发布后,所有模块在编译时全量捆绑,下游用户无法按需选择模块,即使只使用 LLM 对话也需要编译 sqlite / MCP / agent 引擎等全部依赖。
**目标**:通过 Cargo features 拆分让下游按需选择模块。Step 1 是基础设施变更 —— `Cargo.toml` features 定义 + 依赖 optional 化 + 必要的子模块 cfg 门控,编译通过后打 checkpoint。
**预期效果**
- `default = ["full"]` → v0.3.0 用户零迁移成本
- 最小组合(`document`)零重型外部依赖(仅依赖始终编译的轻量依赖:serde/serde_json/thiserror/async-trait/tracing
- 纯对话组合(`chat + provider-openai`)仅需 ~10 个依赖,不含 sqlite / MCP / engine
## 2. 需求分析
### 2.1 约束条件
| # | 约束 | 说明 |
|---|------|------|
| 1 | `default = ["full"]` | 保持向后兼容,v0.3.0 用户零迁移成本 |
| 2 | `document` feature 零重型外部依赖(仅依赖始终编译的轻量依赖:serde/serde_json/thiserror/async-trait/tracing | 纯 std + 始终编译的轻量依赖(serde/serde_json/thiserror/async-trait/tracing |
| 3 | tokio 从 `["full"]` 拆细 | 已验证全库无 net/fs/signal 使用,拆为 `["rt", "sync", "time", "macros", "process", "io-util"]` |
| 4 | 重型依赖全部 optional | tokio、reqwest、rusqlite、tracing-subscriber、tokio-stream、futures、futures-util、futures-core、bytes、async-stream、tokio-util、time |
| 5 | 始终编译的轻量依赖 | serde、serde_json、thiserror、async-trait、tracing |
### 2.2 关键决策
| # | 决策 | 理由 |
|---|------|------|
| 1 | `tools` feature 必须 `imply tokio` | `src/tools/registry.rs` 使用 `tokio::time::timeout` |
| 2 | `pub mod llm` 门控条件为 `any(feature = "llm-types", feature = "llm")` | `prompt → llm-types` 路径需要 llm 模块编译,但只需 types 子模块 |
| 3 | 测试 dev-dependencies 加 `tokio = { version = "1", features = ["rt", "macros"] }` | 现有 `#[tokio::test]` 需要 tokio runtime |
| 4 | 快捷组合名保持原名(chat/multi/light | 文档中说明各组合包含的 feature 约束 |
| 5 | `init_tracing()` 函数整体用 `#[cfg(feature = "tracing-init")]` 包裹 | 避免 `use tracing_subscriber` 出现在未启用 feature 时编译失败 |
## 3. 方案设计
### 3.1 Features 定义(完整 Cargo.toml `[features]` 草案)
```toml
[features]
default = ["full"]
# === 模块级 features ===
document = []
llm-types = []
prompt = ["llm-types"]
llm = ["llm-types", "tokio", "async-stream", "futures-core", "tokio-stream"]
tools = ["llm-types", "futures", "tokio-util", "tokio"]
tools-mcp = ["tools", "reqwest"]
# memory 模块依赖 llmconversation/vector_store 使用 compact/embedding)、tokioknowledge.rs 使用 Mutex)、timetypes.rs 使用 OffsetDateTime
memory = ["document", "llm", "tokio", "time"]
memory-sqlite = ["memory", "rusqlite", "time"]
agent = ["llm", "tools", "memory", "futures-util"]
engine = ["agent"]
# === Provider features ===
# Provider features — openai/anthropic 额外依赖 bytes(流式解析)和 futures-utilStream 组合)
provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
# deepseek/qwen 使用 openai_compat 适配层,不需要 bytes 和 futures-util
provider-deepseek = ["llm", "reqwest"]
provider-qwen = ["llm", "reqwest"]
provider-ollama = ["llm", "reqwest"]
# === 工具 features ===
tracing-init = ["tracing-subscriber"]
# === 快捷组合 ===
full = [
"document", "llm-types", "prompt", "llm",
"tools", "tools-mcp",
"memory", "memory-sqlite",
"agent", "engine",
"provider-openai", "provider-anthropic", "provider-deepseek",
"provider-qwen", "provider-ollama",
"tracing-init",
]
light = ["llm", "provider-openai", "tools", "tools-mcp", "memory", "agent", "engine", "prompt", "document"]
chat = ["agent", "provider-openai"]
multi = ["engine", "provider-openai"]
```
**features 依赖图(简略)**
```
document (零外部依赖)
└── memory (+llm, +tokio, +time) ─── memory-sqlite (+rusqlite, +time)
llm-types (零依赖)
├── prompt
└── llm (+tokio, +async-stream, +futures-core, +tokio-stream)
├── tools (+futures, +tokio-util) ─── tools-mcp (+reqwest)
├── provider-openai / provider-anthropic (+reqwest, +bytes, +futures-util)
├── provider-deepseek / provider-qwen / provider-ollama (+reqwest)
└── agent (+tools, +memory, +futures-util) ─── engine
```
### 3.2 依赖 optional 化方案
**始终编译(5 个,不参与门控)**:
```toml
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
async-trait = "0.1"
tracing = "0.1"
```
**12 个依赖加 `optional = true`**
| 依赖 | 原声明 | 新声明 |
|------|--------|--------|
| tokio | `{ version = "1", features = ["full"] }` | `{ version = "1", features = ["rt", "sync", "time", "macros", "process", "io-util"], optional = true }` |
| reqwest | `{ version = "0.12", features = ["json", "stream"] }` | `{ version = "0.12", features = ["json", "stream"], optional = true }` |
| rusqlite | `{ version = "0.32", features = ["bundled"] }` | `{ version = "0.32", features = ["bundled"], optional = true }` |
| tracing-subscriber | `{ version = "0.3", features = ["env-filter"] }` | `{ version = "0.3", features = ["env-filter"], optional = true }` |
| tokio-stream | `{ version = "0.1" }` | `{ version = "0.1", optional = true }` |
| futures | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
| futures-util | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
| futures-core | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
| bytes | `{ version = "1" }` | `{ version = "1", optional = true }` |
| async-stream | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
| tokio-util | `{ version = "0.7", features = ["rt", "sync"] }` | `{ version = "0.7", features = ["rt", "sync"], optional = true }` |
| time | `{ version = "0.3", features = ["serde", "parsing", "formatting", "macros"] }` | `{ version = "0.3", features = ["serde", "parsing", "formatting", "macros"], optional = true }` |
**dev-dependencies 新增**
```toml
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros"] }
```
### 3.3 源文件改动清单
共涉及 **8 个文件**(预估 ~100 行改动):`Cargo.toml``src/lib.rs``src/llm.rs``src/llm/cycle.rs``src/tools.rs``src/memory.rs``src/memory/store.rs``src/agent/session.rs`
---
#### 文件 1`Cargo.toml`
**改动 1.1** — 新增 `[features]` 表(约 45 行,插入在 `[package]` 之后、`[dependencies]` 之前)
```diff
+ [features]
+ default = ["full"]
+
+ # === 模块级 features ===
+ document = []
+ llm-types = []
+ prompt = ["llm-types"]
+ llm = ["llm-types", "tokio", "async-stream", "futures-core", "tokio-stream"]
+ tools = ["llm-types", "futures", "tokio-util", "tokio"]
+ tools-mcp = ["tools", "reqwest"]
+ # memory 模块依赖 llmconversation/vector_store 使用 compact/embedding)、tokioknowledge.rs 使用 Mutex)、timetypes.rs 使用 OffsetDateTime
+ memory = ["document", "llm", "tokio", "time"]
+ memory-sqlite = ["memory", "rusqlite", "time"]
+ agent = ["llm", "tools", "memory", "futures-util"]
+ engine = ["agent"]
+
+ # === Provider features ===
+ # Provider features — openai/anthropic 额外依赖 bytes(流式解析)和 futures-utilStream 组合)
+ provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
+ provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
+ # deepseek/qwen 使用 openai_compat 适配层,不需要 bytes 和 futures-util
+ provider-deepseek = ["llm", "reqwest"]
+ provider-qwen = ["llm", "reqwest"]
+ provider-ollama = ["llm", "reqwest"]
+
+ # === 工具 features ===
+ tracing-init = ["tracing-subscriber"]
+
+ # === 快捷组合 ===
+ full = [
+ "document", "llm-types", "prompt", "llm",
+ "tools", "tools-mcp",
+ "memory", "memory-sqlite",
+ "agent", "engine",
+ "provider-openai", "provider-anthropic", "provider-deepseek",
+ "provider-qwen", "provider-ollama",
+ "tracing-init",
+ ]
+ light = ["llm", "provider-openai", "tools", "tools-mcp", "memory", "agent", "engine", "prompt", "document"]
+ chat = ["agent", "provider-openai"]
+ multi = ["engine", "provider-openai"]
```
**改动 1.2** — tokio 依赖声明修改
```diff
- tokio = { version = "1", features = ["full"] }
+ tokio = { version = "1", features = ["rt", "sync", "time", "macros", "process", "io-util"], optional = true }
```
**改动 1.3** — 11 个重型依赖逐行加 `optional = true`
```diff
- reqwest = { version = "0.12", features = ["json", "stream"] }
+ reqwest = { version = "0.12", features = ["json", "stream"], optional = true }
- rusqlite = { version = "0.32", features = ["bundled"] }
+ rusqlite = { version = "0.32", features = ["bundled"], optional = true }
- tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+ tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
- tokio-stream = "0.1"
+ tokio-stream = { version = "0.1", optional = true }
- futures = "0.3"
+ futures = { version = "0.3", optional = true }
- futures-util = "0.3"
+ futures-util = { version = "0.3", optional = true }
- futures-core = "0.3"
+ futures-core = { version = "0.3", optional = true }
- bytes = "1"
+ bytes = { version = "1", optional = true }
- async-stream = "0.3"
+ async-stream = { version = "0.3", optional = true }
- tokio-util = { version = "0.7", features = ["rt"] }
+ tokio-util = { version = "0.7", features = ["rt", "sync"], optional = true }
- time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"] }
+ time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"], optional = true }
```
**改动 1.4** — `[dev-dependencies]` 新增 tokio
```diff
+ [dev-dependencies]
+ tokio = { version = "1", features = ["rt", "macros"] }
```
**说明**:如果原 `Cargo.toml` 已有 `[dev-dependencies]` 则追加该行;若无则新增整个 section。
---
#### 文件 2`src/lib.rs`(当前约 26 行 → 改动后约 40 行)
**当前内容(参考)**
```rust
//! agcore —— 智能体(Agent)核心工具箱。
pub mod llm;
pub mod document;
pub mod prompt;
pub mod tools;
pub mod memory;
pub mod agent;
pub mod engine;
pub use document::Document;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
static INIT: std::sync::Once = std::sync::Once::new();
pub fn init_tracing() {
INIT.call_once(|| {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("agcore=info"));
tracing_subscriber::registry()
.with(fmt::layer())
.with(filter)
.init();
});
}
```
**改动后内容**
```diff
//! agcore —— 智能体(Agent)核心工具箱。
- pub mod llm;
+ #[cfg(any(feature = "llm-types", feature = "llm"))]
+ pub mod llm;
- pub mod document;
+ #[cfg(feature = "document")]
+ pub mod document;
- pub mod prompt;
+ #[cfg(feature = "prompt")]
+ pub mod prompt;
- pub mod tools;
+ #[cfg(feature = "tools")]
+ pub mod tools;
- pub mod memory;
+ #[cfg(feature = "memory")]
+ pub mod memory;
- pub mod agent;
+ #[cfg(feature = "agent")]
+ pub mod agent;
- pub mod engine;
+ #[cfg(feature = "engine")]
+ pub mod engine;
- pub use document::Document;
+ #[cfg(feature = "document")]
+ pub use document::Document;
- use tracing_subscriber::{EnvFilter, fmt, prelude::*};
- static INIT: std::sync::Once = std::sync::Once::new();
- pub fn init_tracing() {
- INIT.call_once(|| {
- let filter = EnvFilter::try_from_default_env()
- .unwrap_or_else(|_| EnvFilter::new("agcore=info"));
- tracing_subscriber::registry()
- .with(fmt::layer())
- .with(filter)
- .init();
- });
- }
+ #[cfg(feature = "tracing-init")]
+ use tracing_subscriber::{EnvFilter, fmt, prelude::*};
+
+ #[cfg(feature = "tracing-init")]
+ static INIT: std::sync::Once = std::sync::Once::new();
+
+ #[cfg(feature = "tracing-init")]
+ pub fn init_tracing() {
+ INIT.call_once(|| {
+ let filter = EnvFilter::try_from_default_env()
+ .unwrap_or_else(|_| EnvFilter::new("agcore=info"));
+ tracing_subscriber::registry()
+ .with(fmt::layer())
+ .with(filter)
+ .init();
+ });
+ }
```
---
#### 文件 3`src/llm.rs`(当前约 12 行 → 改动后约 24 行)
**改动说明**:为每个子模块声明加 feature 门控。`types` 子模块在 `llm-types``llm` 任一 feature 启用时编译(`llm` imply `llm-types`,但 `prompt` 也 depend on `llm-types`);其余子模块(compact/convert/cycle 等)仅在 `llm` feature 启用时编译。
```diff
//! LLM 调用周期 —— 大模型基础调用周期控制。
- pub mod types;
+ #[cfg(feature = "llm-types")]
+ pub mod types;
- pub mod compact;
+ #[cfg(feature = "llm")]
+ pub mod compact;
- pub mod convert;
+ #[cfg(feature = "llm")]
+ pub mod convert;
- pub mod cycle;
+ #[cfg(feature = "llm")]
+ pub mod cycle;
- pub mod embedding;
+ #[cfg(feature = "llm")]
+ pub mod embedding;
- pub mod error;
+ #[cfg(feature = "llm")]
+ pub mod error;
- pub mod hooks;
+ #[cfg(feature = "llm")]
+ pub mod hooks;
- pub mod mock;
+ #[cfg(feature = "llm")]
+ pub mod mock;
- pub mod provider;
+ // provider 模块依赖 reqwest(通过 reqwest::Client),仅在任一 provider feature 启用时编译
+ #[cfg(any(feature = "provider-openai", feature = "provider-anthropic", feature = "provider-deepseek", feature = "provider-qwen", feature = "provider-ollama"))]
+ pub mod provider;
- pub mod stream;
+ #[cfg(feature = "llm")]
+ pub mod stream;
```
---
#### 文件 3b`src/llm/cycle.rs`(新增文件,约 35 行)
**改动说明**`cycle.rs` 中使用 `crate::tools::ToolRegistry`(第 29 行),依赖 `tools` feature。工具相关字段和方法需加 `#[cfg(feature = "tools")]` 门控。
> ⚠️ 这是 Phase 22.7 原计划的门控变更,因为编译阻塞提前到 Step 1 执行。
```diff
//! Cycle —— 多轮对话与工具调用编排。
use async_trait::async_trait;
use futures::StreamExt; // 来自 llm→tokio imply 链
+ #[cfg(feature = "tools")]
use crate::tools::ToolRegistry;
// ... struct / enum 定义 ...
// ===== CycleConfig — 工具相关字段加 cfg 门控 =====
pub struct CycleConfig {
pub max_retries: usize,
pub max_history: usize,
+ #[cfg(feature = "tools")]
pub max_tool_turns: usize,
+ #[cfg(feature = "tools")]
pub tool_timeout_secs: u64,
// ... 其他字段 ...
}
// ===== Cycle — 方法加 cfg 门控 =====
impl Cycle {
/// 仅在有 tools feature 时才有工具调用相关方法
+ #[cfg(feature = "tools")]
pub async fn submit_with_tools(&self, ...) -> Result<...> {
// ...
}
+ /// submit_with_tools_stream 方法同样需要 tools 门控,
+ /// 因参数包含 Arc<ToolRegistry> 而与 submit_with_tools 同理。
+ #[cfg(feature = "tools")]
+ pub async fn submit_with_tools_stream(
+ &self, ... // 方法签名中包含 Arc<ToolRegistry> 参数
+ ) -> Result<...> {
+ // ...
+ }
+ #[cfg(feature = "tools")]
async fn run_tool_loop(&self, ...) -> Result<...> {
// ...
}
}
+ // ===== 顶层函数 — 同样依赖 ToolRegistry =====
+ /// run_tool_loop 函数(顶层函数,非 LlmCycle 方法)同样依赖 Arc<ToolRegistry>
+ /// 参数包含 Arc<ToolRegistry>,需 #[cfg(feature = "tools")]。
+ #[cfg(feature = "tools")]
+ pub async fn run_tool_loop(
+ // ... 函数签名中包含 Arc<ToolRegistry> 参数
+ ) -> Result<...> {
+ // ...
+ }
**说明**`Cycle` 本身的 struct 定义、`submit()` 基础方法、`ResponseStream` 等不依赖 tools 的部分保持无门控,仅在 `llm` feature 下编译即可。
---
#### 文件 4`src/tools.rs`(当前约 13 行 → 改动后约 15 行)
**改动说明**`mcp` 子模块及对应的 `pub use` 仅在 `tools-mcp` feature 启用时编译。其余子模块(base/error/permission/registry)始终在 `tools` feature 下编译。
```diff
//! 工具系统 —— 工具抽象、注册、调用、权限控制与 MCP 集成。
pub mod base;
pub mod error;
- pub mod mcp;
+ #[cfg(feature = "tools-mcp")]
+ pub mod mcp;
pub mod permission;
pub mod registry;
pub use base::{BaseTool, ToolContext, ToolRef};
pub use error::ToolError;
- pub use mcp::{McpClient, McpTransport};
+ #[cfg(feature = "tools-mcp")]
+ pub use mcp::{McpClient, McpTransport};
pub use permission::{Permission, PermissionChecker, PermissionConfig};
pub use registry::{ToolInvocation, ToolRegistry};
```
---
#### 文件 5`src/memory/store.rs`(当前约 62 行 → 改动后约 64 行)
**改动说明**`sqlite_store` 子模块及其 `pub use` 仅在 `memory-sqlite` feature 启用时编译。
```diff
//! MemoryStore 抽象接口与默认实现。
use async_trait::async_trait;
use crate::memory::error::MemoryError;
use crate::memory::types::{MemoryFilter, MemoryItem};
pub mod in_memory;
- pub mod sqlite_store;
+ #[cfg(feature = "memory-sqlite")]
+ pub mod sqlite_store;
pub use in_memory::InMemoryStore;
- pub use sqlite_store::SqliteStore;
+ #[cfg(feature = "memory-sqlite")]
+ pub use sqlite_store::SqliteStore;
```
**说明**`MemoryStore` trait、`EvictionConfig``EvictionPolicy` 等定义保持不变,不需要 cfg 门控。
---
#### 文件 6`src/memory.rs`(当前约 32 行 → 改动后约 34 行)
**改动说明**`SqliteStore` 的重新导出仅在 `memory-sqlite` feature 启用时编译。其余子模块声明和 `pub use` 保持不变(`memory` feature 门控由 `src/lib.rs` 负责)。
```diff
//! 记忆系统 —— 对话消息管理、知识页面存储与关键词检索。
// 所有子模块声明保持不变:
// pub mod conversation;
// pub mod error;
// pub mod graph;
// pub mod knowledge;
// pub mod retriever;
// pub mod store;
// ...
// 高频类型
pub use conversation::{ConversationMemory, ConversationMemoryConfig};
pub use error::MemoryError;
pub use graph::{GraphEntity, GraphRelation, InMemoryGraph, KnowledgeGraph, RelationDirection, ScoredEntity};
pub use knowledge::KnowledgeStore;
pub use retriever::MemoryRetriever;
pub use store::{InMemoryStore, MemoryStore};
+ #[cfg(feature = "memory-sqlite")]
+ pub use store::SqliteStore;
// 其余 pub use 保持不变...
```
---
#### 文件 7`src/agent/session.rs`(新增文件,约 30 行)
**改动说明**`session.rs` 中引用了 `crate::engine::*`SessionMemoryEntry、SessionSnapshot、EngineError),而 `agent` feature 不含 `engine``engine = ["agent"]` 是反向依赖)。需要对 engine 相关导入和方法加门控。
> ⚠️ 阻塞 B3`src/agent/session.rs:28-29` 无条件引用 `crate::engine::*`,在 `agent` feature 下编译时因缺少 engine 而失败。
```diff
//! Session —— Agent 会话管理。
use async_trait::async_trait;
use crate::llm::types::LLMRequest;
use crate::memory::MemoryStore;
+ #[cfg(feature = "engine")]
use crate::engine::snapshot::{SessionMemoryEntry, SessionSnapshot};
+ #[cfg(feature = "engine")]
use crate::engine::EngineError;
// ===== AgentSession — pending_memory_restore 字段 =====
+ /// AgentSession 结构体中的 pending_memory_restore 字段类型来自 engine 模块,
+ /// 需要条件编译。
pub struct AgentSession {
+ // ... 其他字段 ...
+
+ #[cfg(feature = "engine")]
+ pending_memory_restore: Option<HashMap<String, SessionMemoryEntry>>,
+ // ... 其他字段 ...
+ }
impl Session {
/// to_snapshot / from_snapshot / restore_memory 仅在 engine feature 下可用
+ #[cfg(feature = "engine")]
pub fn to_snapshot(&self) -> SessionSnapshot {
// ...
}
+ #[cfg(feature = "engine")]
pub fn from_snapshot(snap: SessionSnapshot) -> Result<Self, EngineError> {
// ...
}
+ #[cfg(feature = "engine")]
async fn restore_memory(&mut self, entries: Vec<SessionMemoryEntry>) -> Result<(), MemoryError> {
// ...
}
}
```
**说明**`Session` 结构体本身以及不依赖 engine 的方法(如 `new()``add_message()``get_history()`)保持无门控,仅在 `agent` feature 下编译即可。
---
## 4. 实施步骤
**3 个 commit** 粒度执行,每个 commit 后编译验证。
### Commit 1Cargo.toml features 定义 + 依赖 optional 化
**涉及文件**:仅 `Cargo.toml`
**操作清单**
1. 在 `[package]` 之后、`[dependencies]` 之前插入 `[features]` 表(16 个 features + 4 个快捷组合,约 45 行)
2. tokio features 从 `["full"]` 改为 `["rt", "sync", "time", "macros", "process", "io-util"]` 并加 `optional = true`
3. reqwest / rusqlite / tracing-subscriber / tokio-stream / futures / futures-util / futures-core / bytes / async-stream / tokio-util / time 共 11 个依赖加 `optional = true`
4. 在 `[dependencies]` 之后新增 `[dev-dependencies]``tokio = { version = "1", features = ["rt", "macros"] }`
**验证**
```bash
cargo build --no-default-features # 不依赖任何 optional crate,应通过
cargo build -F document # 零外部依赖,应通过
```
---
### Commit 2cfg 门控(pub mod + pub use + init_tracing
**涉及文件**`src/lib.rs``src/llm.rs``src/llm/cycle.rs``src/tools.rs``src/memory/store.rs``src/memory.rs``src/agent/session.rs`
**操作清单**
按文件逐一执行:
1. `src/lib.rs` — 7 个 `pub mod``#[cfg(feature = "...")]``Document` pub use 加 cfg、`init_tracing` 整体用 `#[cfg(feature = "tracing-init")]` 包裹
2. `src/llm.rs` — 10 个子模块按 `llm-types` / `llm` / provider 分类门控
3. `src/llm/cycle.rs``ToolRegistry` 导入加 `#[cfg(feature = "tools")]`,工具字段和方法加相同门控
4. `src/tools.rs``pub mod mcp``pub use mcp::*``#[cfg(feature = "tools-mcp")]`
5. `src/memory/store.rs``pub mod sqlite_store``pub use sqlite_store::SqliteStore``#[cfg(feature = "memory-sqlite")]`
6. `src/memory.rs``pub use store::SqliteStore``#[cfg(feature = "memory-sqlite")]`
7. `src/agent/session.rs` — engine 相关导入加 `#[cfg(feature = "engine")]`to_snapshot/from_snapshot/restore_memory 加相同门控
**验证**
```bash
cargo build -F "full" # 全量回归
cargo build -F "prompt" # 验证 llm::types imply 路径
cargo build -F "tools" # 验证 tokio imply 路径
cargo build -F "memory" # 验证记忆模块不含 sqlite
cargo build -F "chat,provider-openai" # 纯对话组合
cargo build -F "chat,provider-openai,tools-mcp" # 带 MCP 对话
```
---
### Commit 3Checkpoint 全量验证
**操作清单**
1. 完整的验证矩阵执行(见第 5 节)
2. `cargo test -F "full"` 确认 427 passed
**验证**
```bash
cargo test -F "full"
cargo build -F "light"
cargo build -F "multi"
```
## 5. 验证标准
### 编译验证矩阵
| 命令 | 验证目标 | 预期结果 |
|------|---------|---------|
| `cargo build --no-default-features` | 空 crate | 编译通过(无模块) |
| `cargo build -F "full"` | 全量回归 | 编译通过,与 v0.3.0 语义一致 |
| `cargo test -F "full"` | 测试回归 | `427 passed` |
| `cargo build -F "document"` | 文档模块独立 | 编译通过,零外部依赖 |
| `cargo build -F "prompt"` | 提示词独立 | 编译通过,`llm::types` imply 路径正确 |
| `cargo build -F "tools"` | 工具独立 | 编译通过,tokio imply 路径正确 |
| `cargo build -F "memory"` | 记忆模块独立编译 | ✅ 不含 sqlite(依赖 B1/B2 修复) |
| `cargo build -F "memory-sqlite"` | 含 SQLite 的记忆模块 | ✅ 含 rusqlite |
| `cargo build -F "agent"` | Agent 独立编译 | ✅ 含 llm+tools+memory,不含 engine(依赖 B1/B3 修复) |
| `cargo build -F "engine"` | Engine 独立编译 | ✅ imply agent → llm+tools+memory |
| `cargo build -F "chat,provider-openai"` | 纯对话组合 | 编译通过,不含 MCP、sqlite |
| `cargo build -F "chat,provider-openai,tools-mcp"` | 带 MCP 对话 | 编译通过,含 reqwest 无 sqlite |
| `cargo build -F "light"` | 生产常用组合 | 编译通过 |
| `cargo build -F "multi"` | 多 provider 组合 | 编译通过 |
### 验证操作指令
每次编译验证后执行(验证编译产物不含意外符号):
```bash
# 确认空 crate 确实没有模块符号
cargo build --no-default-features 2>&1 && echo "OK"
# 确认 document 零外部依赖(无 reqwest/rusqlite 等符号)
cargo build -F "document" 2>&1 && echo "OK"
# 全量构建 + 测试
cargo build -F "full" 2>&1 && cargo test -F "full" 2>&1 | tail -5
```
### 验证通过条件
- 所有 14 条编译验证命令返回 exit code 0
- `cargo test -F "full"` 输出 `427 passed`(与 v0.3.0 基线一致,不要求测试数精确匹配,但必须全部通过且数量合理)
- 无 `unused import` / `unused variable` / `dead code` warning(由 `#[cfg]` 引起的新 warning 需逐一修复)
## 6. 风险评估
| 风险 | 等级 | 缓解措施 |
|------|------|---------|
| **tokio features 拆细遗漏**:某些代码路径用到 net/fs/signal | 低 | 已通过 SA(静态分析)验证全库无相关使用 |
| **`#[tokio::test]` 编译失败**:测试代码无 tokio runtime | 低 | `[dev-dependencies]` 添加 `tokio = { version = "1", features = ["rt", "macros"] }` |
| **下游 transitive tokio features 缩小**:依赖 agcore 的 crate 之前通过 agcore 间接获得 `full` tokio,现在范围缩小 | 中 | Phase 27 README 发布说明中明确告知迁移方案;下游如需完整 tokio 需自行添加 |
| **imply 链未闭合**:某个 feature 依赖了未 imply 的 feature | 低 | 7 种特征组合全部逐条构建验证;features 定义中有交叉引用的全部显式列出 |
| **unused cfg warning**:某些 `#[cfg]` 标记导致编译 warning | 低 | 每个 commit 后检查编译器输出,发现后立即修复 |
| **测试依赖循环**dev-deps 与普通 deps 版本冲突 | 低 | dev-deps 的 tokio 版本与主依赖保持一致 (`version = "1"`,由 cargo 自动选择兼容版本) |
| **memory 跨模块 imply 链** | **高** | memory 模块依赖 llmconversation/vector_store)、tokioknowledge)、timetypes),imply 链必须完整传递 | `memory` feature 定义已包含 `llm``tokio``time`;验证矩阵覆盖 memory 独立编译 |
| **跨模块引用未门控** | **高** | provider.rs 依赖 reqwest、cycle.rs 依赖 ToolRegistry、session.rs 依赖 engineStep 1 必须添加 cfg 门控 | provider 模块 cfg 改为 provider-xxx 条件;cycle.rs 加 tools 门控;session.rs 加 engine 门控 |
@@ -0,0 +1,424 @@
# AG Core v0.3.2 Step 3Phase 2627)— 验证固化 + 文档更新实施方案
## 1. 背景与目标
**背景**agcore v0.3.2 Step 1Phase 2025)已交付 —— Cargo features 拆分基础设施改造全部完成,所有模块 `#[cfg]` 门控注入完毕,依赖全部 optional 化,`default = ["full"]` 保持向后兼容。当前项目处于已改造完成但未经 CI 固化、无文档指引的状态。
**当前状态快照**
- v0.3.0 → v0.3.2 Step 168 个源文件,23,765 行
- 16 个 features10 模块级 + 5 provider + 1 工具)+ 4 个快捷组合
- `cargo test --features "full"`427 passed
- 7 种 feature 组合的 `cargo test --lib` 已全部通过(full / light / chat / chat+mcp / multi / multi+mcp / clippy),无需修复 cfg 遗漏
- 但 `cargo test`(不带 `--lib`)会因 18 个 example 缺少 `required-features` 而失败
- 项目无 CI/CD 配置
**目标**:通过 Step 3 将 features 体系验证固化到 CI 中,消除编译死代码警告,完成文档指引,使 v0.3.2 达到可发布状态。
**预期效果**
- 每次提交自动验证 7 种 feature 组合的编译 + 测试(零 warning)
- 18 个 example 各自标注准确的 `required-features`,外树用户可一键运行
- `cargo clippy --all-features -- -D warnings` 零告警
- README 含完整 feature 表 + `Cargo.toml` 配置示例 + 升级指南,新用户 5 分钟内可选定组合
- roadmap 同步更新
## 2. 需求分析
### 2.1 功能需求
| # | 需求 | 说明 | 对应工作 |
|---|------|------|---------|
| F1 | LlmProvider trait 不应依赖具体 provider feature | trait 自身不依赖 reqwest 或任何 provider 实现,纯 Mock 场景也应可用 | 工作 0 |
| F2 | 每个 example 通过 `cargo run --example xxx` 正确编译 | 18 个 example 各有精确的最小 features 声明 | 工作 1 |
| F3 | CI 自动验证 7 种特征组合的编译与测试 | push / PR 触发 | 工作 2 |
| F4 | 所有 feature 组合下 0 个编译器警告 | 消除 dead_code 等警告 | 工作 3 |
| F5 | README 提供完整的 feature 选择指引 | 表格 + 场景推荐 + Cargo.toml 示例 | 工作 5Phase 27 |
| F6 | example 文件顶部标注所需 features | 用户可一键复制运行命令 | 工作 5(Phase 27 |
| F7 | roadmap 状态同步 | 总入口 + v0.3.2 子文档 | 工作 5Phase 27 |
### 2.2 非功能需求
| # | 需求 | 指标 | 对应工作 |
|---|------|------|---------|
| N1 | 向后兼容 | `default = ["full"]` 行为与 v0.3.0 一致,427 tests passed | 全部 |
| N2 | CI 时效 | 全矩阵 ≤ 10 分钟 | 工作 2 |
| N3 | 最少侵入 | 不改动功能逻辑,仅 cfg / 配置 / 文档变更 | 全部 |
### 2.3 推演概要
**需求拆解**:从当前编译验证结果出发,发现三类待解决问题:
1. **架构归属问题**——`LlmProvider` trait 定义在 provider 模块门控下,语义上应归属 `llm` 基础设施。同时其返回类型 `ProviderCapabilities` / `ProviderFeatures` 也必须一并移出
2. **example 可编译性问题**——18 个 example 无 `required-features`,多组合下 `cargo test` 失败
3. **代码质量问题**——`session.rs``bundle()` 方法在 `chat` 组合下 dead_code
4. **工程缺失**——无 CI、README 无 features 说明、roadmap 未同步
**边界识别**
- 工作 0 仅移动 trait 及关联类型定义,不改变公开 API 签名
- 工作 1 的 required-features 是最小集合,不添加冗余 feature
- 工作 2 的 CI 仅验证编译 + 单元测试,不包含集成测试
- 工作 5 的文档更新不涉及新的功能描述
**非目标声明**
- 不新增 feature 组合(保持现有的 4 个快捷组合不变)
- 不重构 `ProviderConfig` / `ProviderType` / `create_provider()` 等 provider 模块创建逻辑(仅移出 trait 和元数据结构体)
- 不集成集成测试(CI 仅验证 `--lib` 单元测试 + example 编译验证)
- 不改动 `Cargo.toml``[dependencies]` 声明
- 不改变 `default = ["full"]` 的默认行为
### 2.4 需求映射矩阵
| 功能需求 | 非功能需求 | 对应工作 | 验收项 |
|---------|-----------|---------|-------|
| F1 + N1 + N3 | — | 工作 0 | A1, A2, A9 |
| F2 | — | 工作 1 | A10 |
| F3 | N2 | 工作 2 | A5, A11 |
| F4 | — | 工作 3 | A4 |
| F5 | N1 | 工作 5 | A6 |
| F6 | — | 工作 5 | A7 |
| F7 | — | 工作 5 | A8 |
| — | N3 | 全部 | A1 |
| — | R2 缓解 | 全部 | A10 |
## 3. 方案设计
### 3.1 总体架构调整
```
工作 0 — 架构修正(LlmProvider trait 及关联类型归属调整)
当前:
src/llm/provider.rs #[cfg(any(feature = "provider-openai", ...))]
├─ pub trait LlmProvider { ... }
├─ pub struct ProviderCapabilities { ... }
├─ pub struct ProviderFeatures { ... }
└─ pub fn capabilities(&self) -> ProviderCapabilities;
目标:
src/llm/provider_trait.rs #[cfg(feature = "llm")]
├─ pub trait LlmProvider { ... }
├─ pub struct ProviderCapabilities { ... }
├─ pub struct ProviderFeatures { ... }
└─ pub fn capabilities(&self) -> ProviderCapabilities;
src/llm/provider.rs #[cfg(any(feature = "provider-openai", ...))]
└─ 各 provider 实现 + ProviderConfig / ProviderType / create_provider()
src/llm.rs
└─ pub use provider_trait::{LlmProvider, ProviderCapabilities, ProviderFeatures};
影响文件(6 个源文件 + 1 个新建):
- src/llm.rs — 添加 mod provider_trait 声明 + pub use 重导出
- src/llm/provider_trait.rs — 新文件,trait + 关联类型定义移入
- src/llm/provider.rs — 移出 trait + 关联类型
- src/llm/provider/openai.rs — use super:: → use crate::llm::
- src/llm/provider/anthropic.rs — 同上
- src/llm/provider/ollama.rs — 同上
- src/llm/provider/openai_compat.rs — 同上(两个 import 合并)
```
### 3.2 各子项设计方案
#### 工作 0 — LlmProvider trait 归属修正
**设计方案**
1. 在 `src/llm/` 下新建 `provider_trait.rs`,门控为 `#[cfg(feature = "llm")]`
2. 从 `src/llm/provider.rs` 中提取以下定义到新文件:
- `pub trait LlmProvider`(含关联方法 `chat` / `chat_stream` / `capabilities`
- `pub struct ProviderCapabilities`(含字段 `features: ProviderFeatures`
- `pub struct ProviderFeatures`(含 8 个功能开关字段)
3. `src/llm.rs` 中声明 `mod provider_trait;`,并 `pub use provider_trait::{LlmProvider, ProviderCapabilities, ProviderFeatures};`
4. `src/llm/provider.rs` 移除上述定义,保留 `ProviderConfig` / `ProviderType` / `create_provider()` 等运行时代码
5. 更新所有 import 路径(详见下方清单)
**Import 路径调整清单**
现有写法 → 目标写法
| # | 文件 | 现有 import | 目标 import |
|---|------|------------|------------|
| 1 | `agent/builder.rs:16` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
| 2 | `agent/builder.rs:135`test | `use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
| 3 | `agent/session.rs:35` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
| 4 | `agent/runtime.rs:21` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
| 5 | `llm/mock.rs:46` | `use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
| 6 | `llm/cycle.rs:22` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
| 7 | `llm/cycle.rs:940,1401`test | `use crate::llm::provider::{ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{ProviderCapabilities, ProviderFeatures};` |
| 8 | `llm/provider/openai.rs:24` | `use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
| 9 | `llm/provider/anthropic.rs:21` | `use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
| 10 | `llm/provider/ollama.rs:14` | `use super::{LlmProvider, ProviderCapabilities};` | `use crate::llm::{LlmProvider, ProviderCapabilities};` |
| 11 | `llm/provider/openai_compat.rs:18,21` | `use super::ProviderCapabilities;` + `use crate::llm::provider::LlmProvider;` | `use crate::llm::{LlmProvider, ProviderCapabilities};`(合并为一行) |
| 12 | `llm/provider/registry.rs:6` | `use crate::llm::provider::{LlmProvider, ProviderConfig, ProviderType, create_provider};` | `use crate::llm::LlmProvider;` + `use crate::llm::provider::{ProviderConfig, ProviderType, create_provider};`(拆分) |
**示例文件 import 调整**
| # | 文件 | 现有 import | 目标 import |
|---|------|------------|------------|
| 13 | `examples/end_to_end.rs:21` | `use agcore::llm::provider::{create_provider, LlmProvider, ProviderConfig, ProviderType};` | `use agcore::llm::LlmProvider;` + `use agcore::llm::provider::{create_provider, ProviderConfig, ProviderType};` |
| 14 | `examples/context_slot_demo.rs:18` | `use agcore::llm::provider::LlmProvider;` | `use agcore::llm::LlmProvider;` |
| 15 | `examples/streaming_events_demo.rs:19` | `use agcore::llm::provider::LlmProvider;` | `use agcore::llm::LlmProvider;` |
| 16 | `examples/quick_start.rs:9` | `use agcore::llm::provider::LlmProvider;` | `use agcore::llm::LlmProvider;` |
**Breaking Change 声明**
工作 0 是**非兼容性变更**,现有用户可能通过以下路径引用 `LlmProvider`
| 旧路径(v0.3.0v0.3.2 Step 1 | 新路径(v0.3.2 Step 3 后) |
|--------------------------------|---------------------------|
| `agcore::llm::provider::LlmProvider` | `agcore::llm::LlmProvider` |
| `agcore::llm::provider::ProviderCapabilities` | `agcore::llm::ProviderCapabilities` |
| `agcore::llm::provider::ProviderFeatures` | `agcore::llm::ProviderFeatures` |
**向后兼容方案(可选)**:在 `src/llm/provider.rs` 中添加 `#[cfg(feature = "llm")]` 门控的类型别名,让老路径仍然可用:
```rust
#[cfg(feature = "llm")]
pub use super::provider_trait::LlmProvider;
#[cfg(feature = "llm")]
pub use super::provider_trait::ProviderCapabilities;
#[cfg(feature = "llm")]
pub use super::provider_trait::ProviderFeatures;
```
**推荐**:用户应迁移到新路径 `agcore::llm::LlmProvider``provider` 模块仅保留 `ProviderConfig` / `ProviderType` / `create_provider()` 等创建逻辑。
**验证**
- `cargo test --features "full"` 仍 427 passed
- `cargo test --no-default-features --features "llm,llm-types" --lib` 编译通过(无需任何 provider feature
- 所有 12 个内部文件 + 4 个示例文件的 import 路径正确
#### 工作 1 — examples required-features 标注
**设计方案**:在 `Cargo.toml` 中为每个 example 添加 `[[example]]` + `required-features`,精确到最小 features 集合。
```
上下文 slot 示例(context_slot_demo):
工作 0 后仅需 ["agent"](修正前需 ["agent", "provider-openai"]
因为 agent 的测试无需真实 providermock 即可
推理不变的 examplesimple_visit):
真正调用 LLM,需要 ["llm", "provider-openai", "tracing-init"]
```
完整映射关系见实施计划 §4.2。
#### 工作 2 — CI 配置
**设计方案**GitHub Actions 矩阵策略,7 个并行测试 job + 1 clippy + 1 format + 1 example 验证。
| Job | Command | 作用域 |
|-----|---------|--------|
| full | `RUSTFLAGS="-D warnings" cargo test --features "full" --lib` | 全量回归,零警告 |
| light | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "light" --lib` | 生产常用,零警告 |
| chat | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "chat,provider-openai" --lib` | 纯对话,零警告 |
| chat+mcp | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "chat,provider-openai,tools-mcp" --lib` | 对话 + 工具,零警告 |
| multi | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "multi,provider-openai" --lib` | 多 Agent,零警告 |
| multi+mcp | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "multi,provider-openai,tools-mcp" --lib` | 多 Agent + 工具,零警告 |
| clippy | `cargo clippy --all-features --lib -- -D warnings` | lint 检查 |
| format | `cargo fmt --check`stable toolchain | 格式检查 |
| examples | `cargo test --features "full"`(不加 `--lib`,编译并运行所有 example | example 编译验证 |
**关键决策**
- 矩阵中统一使用 `--lib` 而非 `--all-targets`。理由:examples 的编译由 `required-features` 独立管理,若混入矩阵会因 feature 组合不匹配导致 example 编译失败,干扰模块测试结果验证。
- 使用 `RUSTFLAGS="-D warnings"` 将警告升级为编译错误,确保 `F40 编译器警告)`被矩阵中所有 6 个测试 job 强制执行。
- format job 使用 stable toolchain`cargo fmt --check` 不需要 nightly)。
- 独立 `examples` job 使用 `cargo test --features "full"`(不加 `--lib`),验证所有 example 在完整 features 下编译并运行通过。
#### 工作 3 — bundle() 死代码警告修复
**设计方案**:在 `src/agent/session.rs:154``pub(crate) fn bundle()` 方法上添加 `#[cfg(feature = "engine")]` 条件编译。
背景:`bundle()` 仅被 `engine/session_manager.rs:219` 调用,当启用 `chat` 组合(agent 但非 engine)时产生 dead_code 警告。
**验证**`cargo test --no-default-features --features "chat,provider-openai" --lib` 0 warnings。
#### 工作 4(可选)— 编译时间基线
记录但不沉淀到代码或 CI 中,仅供性能参考:
```bash
time cargo build --features "full"
time cargo build --no-default-features --features "light"
```
注:此工作在 v0.3.2 发布前为手动执行,不纳入 CI 或验收标准。若后续版本需要编译时间回归检测,可将其提升为正式工作项。
#### 工作 5 — 文档更新
三处并行更新:
**README.md 新增 features 表格**
- 4 个快捷组合 + 推荐使用场景 + `Cargo.toml` 配置示例
- 下游用户可快速选择并复制配置
**升级指南章节(README.md 新增)**
- 针对工作 0 的 Breaking Change 提供迁移说明
- 列出旧路径 → 新路径的对照表
- 提供向后兼容的重导出方案说明
- 示例:`agcore::llm::provider::LlmProvider``agcore::llm::LlmProvider`
- 提醒用户更新 `use` 声明
**example 文件顶部注释**
- 每个 example 第一行格式:`// Required features: cargo run --example xxx --features "..."`
- 对应工作 1 的 `required-features` 声明
**roadmap 状态同步**
- `docs/roadmap.md`:补充 Phase 26-27 完成状态 + v0.3.2 链接
- `docs/roadmap-v0.3.2.md`Phase 26/27 状态从 ⏳ 改为 ✅ + 完成日期
### 3.3 ADR 记录
#### ADR-1LlmProvider trait 及关联类型归属 llm 模块
| 字段 | 内容 |
|------|------|
| 问题 | `LlmProvider` trait 定义在 provider 模块门控 `any(provider-openai, provider-anthropic, ...)` 下,纯 Mock 场景被迫引入至少一个 provider feature。其返回类型 `ProviderCapabilities` / `ProviderFeatures` 同样被困在 provider 门控中 |
| 决策 | 将 trait 定义 + `ProviderCapabilities` / `ProviderFeatures` 一并提取到 `src/llm/provider_trait.rs`,归属 `#[cfg(feature = "llm")]` |
| 备选方案 | 保持不动,在 mock provider 上添加 cfg 绕过 — 否决,因为 provider 模块整体门控错误 |
| 理由 | trait 本身是个接口定义,不依赖 reqwest 或任何 provider 实现细节;`ProviderCapabilities` / `ProviderFeatures` 是 trait 方法的返回类型,必须与 trait 同门控 |
| 影响 | 修改 6 个源文件 + 1 个新建文件 + 4 个示例文件(详见 §3.2 import 调整清单) |
| 状态 | 已采纳 |
#### ADR-2CI 使用 nightly toolchain
| 字段 | 内容 |
|------|------|
| 问题 | 项目已使用 edition 2024,是否降级到 2021 以使用 stable Rust |
| 决策 | 测试和 clippy 使用 nightlyedition 2024 目前要求 nightly);format 使用 stable |
| 备选方案 | 降级 edition 到 2021 — 否决,已迁移至 edition 2024 且编译通过 |
| 理由 | edtion 2024 是主动选择的方向,降级是倒退且涉及大量语法变更;`cargo fmt --check` 无需 nightly |
| 影响 | CI 依赖 `actions-rust-lang/setup-rust-toolchain@v1`format job 指定 `toolchain: stable` |
| 状态 | 已采纳 |
#### ADR-3CI 矩阵使用 `--lib` 而非 `--all-targets`
| 字段 | 内容 |
|------|------|
| 问题 | `cargo test --all-targets` 会编译所有 example,与矩阵中自选的 feature 组合可能冲突 |
| 决策 | 矩阵测试使用 `--lib`examples 由独立 job`cargo test --features "full"` 不加 `--lib`)验证 |
| 备选方案 | 在矩阵中也传入 `--all-targets` — 否决,example 编译失败会干扰模块测试验证 |
| 理由 | 分离关注点:矩阵验证模块级编译 + 零警告,独立 job 验证 example 编译 |
| 状态 | 已采纳 |
## 4. 实施计划
### 4.1 任务拆解与优先级
| 优先级 | 工作 | 编号 | 规模 | 依赖 |
|--------|------|------|------|------|
| P0 | LlmProvider trait 归属修正 | 工作 0 | ~30 行(含关联类型移动 + import 调整) | 无 |
| P0 | bundle() 门控修复 | 工作 3 | 1 行 | 无 |
| P0 | examples required-features | 工作 1 | ~50 行 | 工作 0context_slot_demo / quick_start 最小 features 从 agent+provider-openai 降为 agent |
| P0 | CI 配置 | 工作 2 | ~80 行 | 无 |
| P1 | 文档更新 | 工作 5 | ~150 行 | 全部 |
| P2 | 编译时间基线 | 工作 4 | 手动 | 全部 |
### 4.2 各 example 的 required-features 清单
| example 文件名 | required-features | 运行环境备注 |
|---------------|-------------------|------------|
| `prompt_composer` | `["prompt"]` | — |
| `custom_tool` | `["tools"]` | — |
| `conversation_memory_demo` | `["memory"]` | — |
| `knowledge_graph_demo` | `["memory"]` | — |
| `knowledge_search_demo` | `["memory"]` | — |
| `agent_session_demo` | `["agent"]` | — |
| `task_agent_demo` | `["agent"]` | — |
| `context_slot_demo` | `["agent"]` | 工作 0 后无需 provider |
| `quick_start` | `["agent"]` | 工作 0 后无需 provider |
| `simple_visit` | `["llm", "provider-openai", "tracing-init"]` | 需要 API key |
| `streaming_events_demo` | `["llm", "provider-openai"]` | 需要 API key |
| `agent_switch_demo` | `["engine"]` | — |
| `bridge_keys_demo` | `["engine"]` | — |
| `dispatch_stream_demo` | `["engine"]` | — |
| `engine_demo` | `["engine"]` | — |
| `sub_agent_dispatch_demo` | `["engine"]` | — |
| `document_demo` | `["memory", "tracing-init"]` | 需 sqlite 依赖(memory-sqlite feature 可选) |
| `end_to_end` | `["agent", "memory-sqlite", "provider-openai"]` | 需要 API key + sqlite 依赖 |
**验证策略**:每个 example 除 `--lib` 验证外,还需单独运行以下命令确认 required-features 精确性:
```bash
cargo test --no-default-features --features "<features>" --example <name>
```
### 4.3 Commit 策略
每个工作独立 commit,按依赖顺序排列:
| 顺序 | Scope | Type | 描述 | 依赖 |
|------|-------|------|------|------|
| 1 | `core` | `refactor` | 将 LlmProvider trait 及关联类型移出 provider 模块归属 llm | 无 |
| 2 | `agent` | `fix` | 为 session.rs bundle() 方法添加 engine feature 门控 | 无 |
| 3 | `examples` | `chore` | 为 18 个 example 添加 required-features 声明 | 工作 1context_slot 等受益于工作 0 的轻量 features |
| 4 | `ci` | `chore` | 创建 CI 测试矩阵配置 | 无 |
| 5 | `docs` | `docs` | 更新 README feature 表 + 升级指南 + 示例注释 + roadmap 状态 | 全部 |
### 4.4 参考实现:CI 配置
```yaml
name: CI
on: [push, pull_request]
env:
RUSTFLAGS: "-D warnings"
jobs:
test-matrix:
strategy:
matrix:
features:
- "full"
- "light"
- "chat,provider-openai"
- "chat,provider-openai,tools-mcp"
- "multi,provider-openai"
- "multi,provider-openai,tools-mcp"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo test --no-default-features --features "${{ matrix.features }}" --lib
clippy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo clippy --all-features --lib -- -D warnings
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
- run: cargo fmt --check
examples:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo test --features "full"
```
## 5. 风险评估
| 风险 | 概率 | 影响 | 缓解措施 | 对应验收项 |
|------|------|------|---------|-----------|
| 工作 0 重构后公开 API 被意外改变 | 低 | 高 | 重构前后分别跑 `cargo test --features "full"` 确认测试数一致(427),且 `cargo doc` 无差异 | A1 |
| required-features 标注不准确导致 example 运行时缺少 trait 实现 | 低 | 中 | 每个 example 在 `--lib` 验证后,再单独跑 `cargo test --example xxx --no-default-features --features "对应features"` 确认 | A10 |
| CI 首次在 GitHub runner 上因环境差异(OS、Toolchain 版本)失败 | 中 | 低 | 非阻塞问题,修复后重新推送即可;本地已在 macOS 验证 7 种组合 | A5 |
| edition 2024 在 GitHub runner 的特定 nightly 版本上不稳定 | 低 | 中 | 可在 `Cargo.toml` 中加 `rust-version = "1.85"` 下限约束 | A5 |
| bundle() 门控修复后 engine 组合下方法不可见 | 极低 | 中 | `cargo test --features "engine,provider-openai"` 编译通过即可验证 | A4 |
## 6. 验收标准
| # | 验收项 | 验证方式 | 对应工作 |
|---|--------|---------|---------|
| A1 | `cargo test --features "full"` 仍 427 passed | `cargo test -F full -q` | 工作 0 |
| A2 | `cargo test --no-default-features --features "llm,llm-types" --lib` 编译通过 | 无需任何 provider feature 即完成编译 | 工作 0 |
| A3 | 7 种组合下 `cargo test --no-default-features --features "组合" --lib -q` 全部通过 | 逐一验证 | 工作 1example features 正确不会干扰 --lib |
| A4 | 6 个矩阵组合(full / light / chat / chat+mcp / multi / multi+mcp)下 `RUSTFLAGS="-D warnings" cargo test --lib` 0 warnings | 所有组合均无编译器警告 | 工作 3 |
| A5 | `.github/workflows/ci.yml` 文件存在,结构包含 9 个 job6 测试 + 1 clippy + 1 format + 1 examples | 文件检查 | 工作 2 |
| A6 | README.md 包含 features 表格 + 使用场景 + Cargo.toml 配置示例 + 升级指南 | review 通过 | 工作 5 |
| A7 | 所有 18 个 example 文件首行含 `// Required features: cargo run --example xxx --features "..."` 注释 | review 通过 | 工作 5 |
| A8 | `docs/roadmap.md``docs/roadmap-v0.3.2.md` 中 Phase 26/27 状态标记为 ✅ | review 通过 | 工作 5 |
| A9 | 工作 0 后 `cargo doc --no-deps --features "llm,llm-types"` 可生成 `LlmProvider` / `ProviderCapabilities` / `ProviderFeatures` 的 API 文档(无需任何 provider feature | review 通过 | 工作 0 |
| A10 | 每个 example 单独验证:`cargo test --no-default-features --features "<对应features>" --example <name>` 编译通过 | 逐一验证 18 个 example | 工作 1 |
| A11 | 全矩阵 CI6 测试 + clippy + format + examples)从 checkout 到完成 ≤ 10 分钟 | 实测计时 | 工作 2 |
@@ -0,0 +1,790 @@
# Phase 28-30 — OpenAI Response API Provider 实施方案
> **版本**v1 | **作者**Writer Agent | **日期**2026-07-20
>
> **阅读前提**:本文档假设读者已熟悉现有的 Provider 实现模式(`AnthropicProvider` 独立实现方式)、IR 类型系统(`MessageRequest` / `MessageResponse` / `ContentBlock` / `StreamEvent` / `LlmProvider trait`)以及 Cargo features 门控机制。
>
> **前置条件**v0.3.2Phase 20-27)已发布,Cargo features 拆分完成,CI 矩阵 6 种组合全部通过。
---
## 1. 背景与目标
### 1.1 背景
OpenAI 于 2025 年下半年发布了 **Response API**`POST /responses`),作为 Chat Completions API`POST /chat/completions`)的下一代接口。Response API 不仅提供了更简洁的请求/响应结构,还将 `web_search``file_search``computer_use` 等内置工具提升为一等公民,并引入了 `previous_response_id` 多轮续写等新机制。
agcore 当前通过 `GenericOpenaiProvider` 实现了 OpenAI Chat Completions 协议。`ProviderType::OpenaiResponse` 枚举项已在 `src/llm/provider.rs` 中定义,但工厂函数返回 `Err("Phase 1 暂不实现;请使用 OpenaiChat")`
### 1.2 目标
- 实现独立的 `OpenaiResponseProvider`(不套用 `GenericOpenaiProvider`,参考 `AnthropicProvider` 模式)
- 覆盖 Response API 的核心能力:文本对话、流式输出、Vision 输入、工具调用(function calling
- 新增独立 feature `provider-openai-response`,加入 `full` 快捷组合
- 内置工具(`web_search` / `file_search` / `computer_use`)通过 `MessageRequest.extra` 逃生舱传递
- 多轮接续第一版走全量消息历史模式
### 1.3 范围
| 维度 | 包含 | 不包含 |
|------|------|--------|
| 协议端点 | `POST /responses` | `/responses/{id}/input_items` 等管理端点 |
| 输入模式 | 全量消息历史 + `previous_response_id` | 增量续写优化 |
| 内置工具 | 通过 `extra` 逃生舱透传 | 原生 ToolDef 结构改动 |
| 流式 | SSE 语义事件 → `StreamEvent` | — |
| 结构化输出 | `text.format` | 暂不专项封装 |
---
## 2. 需求分析
### 2.1 功能需求
| # | 需求 | 优先级 | 说明 |
|---|------|--------|------|
| F1 | 文本对话(非流式 + 流式) | P0 | 最基础的对话能力 |
| F2 | Vision 图片输入 | P0 | `UserImage``input_image` |
| F3 | Function Calling 工具调用 | P0 | `ToolDef``{type: "function", ...}` |
| F4 | 多轮接续 | P1 | 全量消息历史模式 |
| F5 | System 消息处理 | P0 | 多个 System 消息拼接到 `instructions` |
| F6 | 流式 SSE 事件映射 | P0 | 按 Response API SSE 事件序列映射 |
| F7 | 内置工具逃生舱 | P2 | `extra` 字段透传 `web_search` / `file_search` |
| F8 | 结构化输出逃生舱 | P2 | `extra` 字段透传 `text.format` |
### 2.2 非功能需求
| # | 需求 | 指标 |
|---|------|------|
| N1 | 编译隔离 | 新增 feature 不增加 `light` / `chat` 组合的依赖 |
| N2 | 测试覆盖 | wiremock 覆盖非流式 + 流式 + 错误路径 |
| N3 | 错误映射 | 复用 `GenericOpenaiProvider` 的错误映射逻辑 |
| N4 | Clippy 合规 | `cargo clippy --all-features --lib -- -D warnings` 通过 |
### 2.3 与 Chat Completions 的差异回顾
| 维度 | Chat Completions | Response API |
|------|-----------------|--------------|
| 端点 | `POST /chat/completions` | `POST /responses` |
| 输入 | `messages: [{role, content}]` | `input: string \| items[]` + 顶层 `instructions` |
| 输出 | `choices[n].message` | `output: []` 异构 items 数组 |
| 内置工具 | 无(仅 function calling | `web_search` / `file_search` / `computer_use` 一等公民 |
| 多轮接续 | 调用方拼接 messages | `previous_response_id` 参数 或 全量回传 |
| 流式 | SSE chunk `choices[n].delta` | SSE 语义事件:`response.text.delta` / `response.output_item.added` 等 |
| 结构化输出 | `response_format` | `text.format` |
| 认证 | `Authorization: Bearer` | 相同 |
| 错误结构 | 相同(401/429/500 | 相同 |
---
## 3. 方案设计
### 3.1 设计决策
| # | 决策 | 选项 | 选择 | 理由 |
|---|------|------|------|------|
| D1 | 实现方式 | 独立 Provider vs 套用 GenericOpenaiProvider | **独立 Provider** | Response API 请求/响应结构与 Chat Completions 差异过大,序列化/反序列化无共用价值 |
| D2 | Feature 粒度 | 合并到 `provider-openai` vs 独立 | **独立 feature** | 与 `AnthropicProvider` 对齐,避免 `full` 组合膨胀 |
| D3 | 加入快捷组合 | 加入 `full` 但不加入 `light` | **`full` 包含** | Response API 属于高级能力,`light` 保持轻量 |
| D4 | 多轮方案 | 全量历史 vs 增量 | **全量历史(模式 A** | 功能正确,无需改动 `LlmCycle` |
| D5 | 内置工具支持 | 改 ToolDef vs extra 逃生舱 | **extra 逃生舱** | 不改已有 IR 类型,最小侵入 |
### 3.2 Feature 定义
```toml
provider-openai-response = ["llm", "reqwest", "bytes", "futures-util"]
```
`provider-openai` / `provider-anthropic` 的依赖集合一致——`llm` 已包含 `tokio` / `async-stream` / `futures-core` / `futures-util` / `tokio-stream`,此处补充 `reqwest`HTTP 客户端)和 `bytes`(流式 buffer 操作)。
`full` 快捷组合追加 `"provider-openai-response"`
### 3.3 新增文件
所有实现集中在单一文件:
```
src/llm/provider/openai_response.rs ← 全部实现(Wire 类型 + Provider 结构体 + 请求转换 + 响应转换 + 流式处理 + 测试)
```
不在 `provider/` 下创建子目录。模块声明在 `src/llm.rs`,在现有 Provider features cfg 条件中追加 `feature = "provider-openai-response"`
```rust
#[cfg(any(
feature = "provider-openai",
feature = "provider-anthropic",
feature = "provider-deepseek",
feature = "provider-qwen",
feature = "provider-ollama",
feature = "provider-openai-response",
))]
pub mod provider;
```
### 3.4 架构概览
```
┌──────────────────────────────────────────────┐
│ OpenaiResponseProvider │
│ ┌──────────────────────────────────────────┐ │
│ │ convert_request() │ │
│ │ MessageRequest → OpenaiResponseRequest │ │
│ └──────────────────┬───────────────────────┘ │
│ │ │
│ ┌──────────────────▼───────────────────────┐ │
│ │ HTTP POST /responses │ │
│ │ (reqwest Client) │ │
│ └──────────────────┬───────────────────────┘ │
│ │ │
│ ┌──────────────────▼───────────────────────┐ │
│ │ convert_response() │ │
│ │ OpenaiResponseBody → MessageResponse │ │
│ └──────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────┐ │
│ │ ResponseSseEventStream │ │
│ │ SSE bytes → StreamEvent 流 │ │
│ └──────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
```
### 3.5 Wire 类型设计
#### 请求体类型
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct OpenaiResponseRequest {
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
pub input: Vec<ResponseInputItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<ResponseTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_response_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub truncation: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<Value>,
}
```
#### Input Item 枚举
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub(crate) enum ResponseInputItem {
Message {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
item_type: Option<String>, // 可选,固定为 "message"assistant 回传时使用)
role: String,
content: Vec<ResponseInputContent>,
},
FunctionCall {
#[serde(rename = "type")]
item_type: String, // 固定为 "function_call"
call_id: String,
name: String,
arguments: String,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<String>,
},
FunctionCallOutput {
#[serde(rename = "type")]
item_type: String, // 固定为 "function_call_output"
call_id: String,
output: String,
},
}
> **关于 `ResponseInputItem` 与 `ResponseOutputItem` 的职责划分**
>
> - **`ResponseInputItem`**`#[serde(untagged)]`):仅用于**请求序列化**`convert_request`),由代码控制枚举变体的生成,永远不会遇到未知的 `item_type`。因此 untagged 模式是安全的,无需 fallback。
> - **`ResponseOutputItem`**(非 untagged`item_type: String` 为必填字段):用于**响应反序列化**(`convert_response`),来自 API 响应。未知的 `item_type` 已通过 §3.7 的 `ContentBlock::Extension` fallback 处理,不会因新增 item 类型而触发 serde 反序列化失败。
/// 消息内容块(嵌套在 Message 变体的 content 数组中)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum ResponseInputContent {
InputText {
text: String,
},
InputImage {
image_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
},
}
```
> **补充说明**Response API 的 `input` 字段还支持简化格式——`input: "Hello"`(单字符串)或 `input: ["Hello", "Hi"]`(字符串数组),但这些格式只能表达纯文本消息。为支持多模态内容(文本 + 图片)和工具调用,本实现使用完整的消息对象数组格式。
#### Tool 类型
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum ResponseTool {
Function {
name: String,
description: String,
parameters: Value,
},
}
```
#### 响应体类型
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct OpenaiResponseBody {
pub id: String,
pub model: String,
pub output: Vec<ResponseOutputItem>,
pub usage: Usage,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ResponseOutputItem {
pub id: String,
#[serde(rename = "type")]
pub item_type: String,
pub status: Option<String>,
pub role: Option<String>,
pub content: Option<Vec<ResponseContentPart>>,
pub call_id: Option<String>,
pub name: Option<String>,
pub arguments: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ResponseContentPart {
#[serde(rename = "type")]
pub part_type: String,
pub text: Option<String>,
}
```
#### SSE 事件类型
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum ResponseSseEvent {
#[serde(rename = "response.created")]
ResponseCreated { response: ResponseSseMeta },
#[serde(rename = "response.completed")]
ResponseCompleted { response: ResponseSseMeta },
#[serde(rename = "response.failed")]
ResponseFailed { error: Option<serde_json::Value> },
#[serde(rename = "response.output_item.added")]
ResponseOutputItemAdded { item: ResponseOutputItem },
#[serde(rename = "response.output_item.done")]
ResponseOutputItemDone { item: ResponseOutputItem },
#[serde(rename = "response.output_text.delta")]
ResponseOutputTextDelta { delta: String, item_id: String },
#[serde(rename = "response.output_text.done")]
ResponseOutputTextDone { text: String, item_id: String },
#[serde(rename = "response.refusal.delta")]
ResponseRefusalDelta { delta: String, item_id: String },
#[serde(rename = "response.refusal.done")]
ResponseRefusalDone { refusal: String, item_id: String },
#[serde(rename = "response.function_call_arguments.delta")]
ResponseFunctionCallArgumentsDelta { delta: String, item_id: String },
#[serde(rename = "response.function_call_arguments.done")]
ResponseFunctionCallArgumentsDone { arguments: String, item_id: String },
#[serde(rename = "error")]
Error { code: String, message: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ResponseSseMeta {
pub id: String,
pub model: String,
pub status: String,
}
```
### 3.6 请求转换(convert_request
#### 消息类型映射
| 输入场景 | Message 类型 | → Response API input item |
|---------|-------------|--------------------------|
| 文本 User | `Message::User { content: [Text] }` | `{role: "user", content: [{type: "input_text", text}]}` |
| Vision | `Message::UserImage { data, mime_type, detail }` | `{role: "user", content: [{type: "input_image", image_url: "data:{mime};base64,{data}", detail}]}` |
| User 多模态 | `Message::User { content: [Text, Image, ...] }` | `{role: "user", content: [{type: "input_text", text}, {type: "input_image", image_url, detail}]}` |
| Assistant 文本 | `Message::Assistant { content: [Text] }` | User 侧:`{role: "assistant", content: [{type: "output_text", text}]}`(无 `type` 字段);回传时: `{type: "message", role: "assistant", content: [{type: "output_text", text}]}`(有 `type: "message"` |
| Assistant 工具调用 | `Message::Assistant { content: [ToolUse] }` | `FunctionCall { call_id, name, arguments }` |
| Assistant 文本+工具 | `Message::Assistant { content: [Text, ToolUse, ...] }` | 一个 `Message(assistant)` + 一个或多个 `FunctionCall` 项 |
| 工具结果 | `Message::ToolResult { tool_call_id, content, is_error }` | `FunctionCallOutput { call_id, output: content }` |
| System | `Message::System { content }` | 拼接到顶层 `instructions` 字段(非 input |
#### 字段映射
| MessageRequest 字段 | → Response API 字段 |
|---------------------|---------------------|
| `model` | `model` |
| `max_tokens` | `max_output_tokens` |
| `temperature` | `temperature` |
| `top_p` | `top_p` |
| `stop_sequences` | `stop` |
| `stream` | `stream` |
| `tools` (ToolDef) | `tools` = `[{type: "function", name, description, parameters}]` |
| `tool_choice` | `tool_choice` |
#### extra 字段映射
| `MessageRequest.extra` key | → Response API 字段 |
|---------------------------|---------------------|
| `previous_response_id` | `previous_response_id` |
| `store` | `store` |
| `metadata` | `metadata` |
| `truncation` | `truncation` |
| `reasoning.effort` | `reasoning: {effort: ...}` |
| 内置工具(`web_search` / `file_search` 等) | 追加到 `tools` 数组 |
> **备注**:当前仅支持 `reasoning.effort` 子字段(值为 `low`/`medium`/`high`),其他子字段(如 `reasoning.summary`)将在后续版本支持。
### 3.7 响应转换(convert_response
| Response API output item | → MessageResponse 中的表示 |
|-------------------------|------------------------------|
| `{type: "message", role: "assistant", content: [{type: "output_text", text}]}` | `Message::Assistant { content: [ContentBlock::Text { text }] }` |
| `{type: "function_call", name, arguments, call_id}` | `ContentBlock::ToolUse { id: call_id, name, input: arguments }` |
| `{type: "web_search_call", ...}` | `ContentBlock::Extension { kind: "web_search_call", data: ... }` |
| `{type: "reasoning", ...}` | `ContentBlock::Extension { kind: "reasoning", data: ... }` |
| `{type: "file_search_call", ...}` | `ContentBlock::Extension { kind: "file_search_call", data: ... }` |
**status → StopReason 映射**
- `completed``StopReason::Stop`
- `incomplete``StopReason::Length`
- `failed``StopReason::Other`
`response.output` 为空数组时,返回 `LlmError::Request { status: 200, body: "empty output" }`,表示响应格式异常。
对于未知的 `item_type`(非 `message`/`function_call`/`web_search_call`/`file_search_call`/`reasoning`),转换为 `ContentBlock::Extension { kind: item_type, data: serde_json::to_value(item)? }` 以保持前向兼容。
### 3.8 流式 SSE 事件映射
| Response API SSE event | → StreamEvent |
|------------------------|---------------|
| `response.created` | `MessageStart { id, model }` |
| `response.output_item.added` (type: message) | `ContentBlockStart { index, block_type: Text }` |
| `response.output_text.delta` | `TextDelta { text }` |
| `response.output_text.done` | `ContentBlockEnd { index }` |
| `response.refusal.delta` | `RefusalDelta { text }` |
| `response.refusal.done` | `ContentBlockEnd { index }` |
| `response.function_call_arguments.delta` | `ToolCallArgumentsDelta { index, arguments }` |
| `response.function_call_arguments.done` | `ToolCallEnd { index }` |
| `response.completed` | `MessageComplete { full_response }` |
| `response.failed` | `Error { message }` |
### 3.9 流式 SSE 状态机
`ResponseSseEventStream` 维护以下状态:
```
字段:
- byte_stream: reqwest 的 bytes_stream
- buffer: Vec<u8>SSE 行缓冲)
- partial: PartialMessageResponse(累积响应状态)
- block_index: u32(输出 block 序号计数器)
- saw_terminal: bool(是否已见到 response.completed / response.failed
流程:
line 级解析 → event: + data: 配对
→ 反序列化 ResponseSseEvent
→ try_into_stream_event() 映射为 StreamEvent
→ StreamEvent::apply_to(&mut partial)
→ yield StreamEvent
response.completed → partial.finalize() → yield MessageComplete
response.failed → yield Error
```
### 3.10 错误映射
复用 `GenericOpenaiProvider``handle_error_response()` 逻辑:
| HTTP 状态码 | → LlmError |
|------------|------------|
| 401 | `LlmError::Authentication(body)` |
| 429 | `LlmError::RateLimit { retry_after }` |
| 5xx | `LlmError::Request { status, body }` |
| 400 + `context_length_exceeded` | `LlmError::ContextLength` |
### 3.11 Provider 结构体
```rust
pub(crate) struct OpenaiResponseProvider {
http_client: Client,
base_url: String,
api_key: String,
model: String,
timeout_secs: u64,
}
```
#### 工厂方法
```rust
impl OpenaiResponseProvider {
pub(crate) fn from_parts(
base_url: String,
api_key: String,
model: String,
http_client: Client,
timeout_secs: u64,
) -> Self {
Self { http_client, base_url, api_key, model, timeout_secs }
}
}
```
### 3.12 LlmProvider trait 实现
```rust
#[async_trait]
impl LlmProvider for OpenaiResponseProvider {
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
self.chat_blocking(request).await
}
async fn chat_stream(
&self,
request: MessageRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
self.chat_stream_inner(request).await
}
fn capabilities(&self) -> ProviderCapabilities { ... }
}
```
### 3.13 Capabilities
```rust
ProviderCapabilities {
provider_name: "openai-response",
supported_models: Some(vec![model]),
features: ProviderFeatures {
streaming: true,
thinking: true, // o-series reasoning
vision: true, // image input
audio_input: false,
tool_use: true,
parallel_tool_calls: true,
system_prompt_in_messages: false,
max_context_window: 200_000,
},
}
```
### 3.14 工厂函数注册
```rust
ProviderType::OpenaiResponse => {
let client = build_client_with_timeout(config.timeout_secs)?;
Ok(Box::new(openai_response::OpenaiResponseProvider::from_parts(
config.base_url,
config.api_key,
config.model,
client,
config.timeout_secs,
)))
}
```
### 3.15 多轮接续方案
第一版走**全量消息历史模式(模式 A)**:
1. `convert_request()``MessageRequest.messages` 全部转换为 `input` items
2. System 消息拼接到 `instructions`
3. User / Assistant / ToolResult 消息转换为对应的 input items
4. 如果 `extra` 中有 `previous_response_id`,也传入请求体
此模式与 `LlmCycle::submit_with_tools()` 完全兼容——`LlmCycle` 在每次提交时都会填充完整的历史 messages,`OpenaiResponseProvider` 只是把这些 messages 全部序列化为 Response API 格式。无需改动 `LlmCycle`
---
## 4. 实施计划
实施拆分为 3 个 Phase9 个 Step。
### Phase 28Feature gate + Wire 类型 + Provider 骨架(~140 行)
#### Step 28.1Cargo.toml feature 定义
**文件操作**:修改 `Cargo.toml`
```toml
# 在 [features] 的 Provider features 区域追加
provider-openai-response = ["llm", "reqwest", "bytes", "futures-util"]
# 在 full 快捷组合中追加
full = [
"...",
"provider-openai-response",
]
```
**验证**`cargo build --features "provider-openai-response"` 编译通过
#### Step 28.2Wire 类型定义
**文件操作**:新建 `src/llm/provider/openai_response.rs`
定义 §3.5 中的所有 Wire 类型:
- `OpenaiResponseRequest`
- `ResponseInputItem`untagged 枚举:Message / FunctionCall / FunctionCallOutput
- `ResponseInputContent`tagged 枚举:InputText / InputImage
- `ResponseTool`
- `OpenaiResponseBody`
- `ResponseOutputItem`
- `ResponseContentPart`
- `ResponseSseEvent`(完整时序事件枚举)
- `ResponseSseMeta`
**无逻辑代码**,只有 `#[derive(Debug, Clone, Serialize, Deserialize)]` 的结构体和枚举。
**验证**`cargo build --features "provider-openai-response"` 编译通过
#### Step 28.3Provider 结构体 + from_parts
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
- `OpenaiResponseProvider` 结构体
- `from_parts()` 工厂方法
- 基础 HTTP 工具函数(`build_request_builder``handle_error_response``map_reqwest_error`
**验证**`cargo build --features "provider-openai-response"` 编译通过
#### Step 28.4Factory 注册 + 模块门控
**文件操作**
1. 修改 `src/llm.rs` — 在 cfg 条件中追加 `feature = "provider-openai-response"`
2. 修改 `src/llm/provider.rs` — 注册 factory
`src/llm.rs` 中修改现有 Provider features cfg 条件:
```rust
#[cfg(any(
feature = "provider-openai",
feature = "provider-anthropic",
feature = "provider-deepseek",
feature = "provider-qwen",
feature = "provider-ollama",
feature = "provider-openai-response",
))]
pub mod provider;
```
以及在 `src/llm/provider.rs``create_provider()` match 中替换当前 `Err` 为真实构造。
**验证**
- `cargo build --features "provider-openai-response"` 编译通过
- `cargo build --features "full"` 编译通过
---
### Phase 29:核心 Provider 实现(~680 行)
#### Step 29.1convert_request~200 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
实现 `OpenaiResponseProvider::convert_request(&self, request: MessageRequest) -> Result<OpenaiResponseRequest, LlmError>`
处理逻辑:
1. 遍历 `request.messages`,按 §3.6 消息类型映射表转换
2. Assistant 消息回传时设置 `item_type: Some("message".to_string())`,使序列化结果为 `{type: "message", role: "assistant", content: [...]}`User 消息保持 `item_type: None`,序列化为 `{role: "user", content: [...]}`(无 `type` 字段)
3. `request.tools``tools` 数组(`ToolDef``ResponseTool::Function`
4. `request.extra` → 解析 `previous_response_id` / `store` / `metadata` / `truncation` / `reasoning`
5. 标准字段映射(model / max_tokens / temperature / top_p / stop / stream
#### Step 29.2convert_response~100 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
实现 `OpenaiResponseProvider::convert_response(&self, response: OpenaiResponseBody) -> Result<MessageResponse, LlmError>`
处理逻辑:
1. 遍历 `response.output`,找到第一个 `type: "message"` 的 item,提取 text
2. 其他 items`function_call``ContentBlock::ToolUse`,内置工具 → `ContentBlock::Extension`
3. `response.status``StopReason`
4. `response.usage``Usage`
#### Step 29.3:非流式 chat()~80 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
实现 `OpenaiResponseProvider::chat_blocking()`
- `convert_request()` → serde 序列化 → HTTP POST `{base_url}/responses`
- Auth header: `Authorization: Bearer {api_key}`
- 错误处理映射
- 解析响应体 → `convert_response()`
#### Step 29.4SSE 事件类型 + 状态机(~230 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
实现 `ResponseSseEventStream` 结构体及其 `Stream` trait
- 字段:`byte_stream`, `buffer`, `partial: PartialMessageResponse`, `block_index: u32`, `saw_terminal: bool`
- 行级 SSE 解析:`event:` + `data:` 配对
- 事件 → `StreamEvent` 映射
- `PartialMessageResponse::apply_to()` 累积
- 流结束时 `finalize()``MessageComplete`
#### Step 29.5:流式 chat_stream()~50 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
实现 `OpenaiResponseProvider::chat_stream_inner()`
- `convert_request()` 设置 `stream: true`
- HTTP POST → bytes_stream → 包装为 `ResponseSseEventStream`
#### Step 29.6LlmProvider impl~50 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
实现 `LlmProvider for OpenaiResponseProvider`
- `chat()``chat_blocking()`
- `chat_stream()``chat_stream_inner()`
- `capabilities()` → 返回 `ProviderCapabilities`
#### Step 29.7:单元测试(~70 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs``#[cfg(test)] mod tests {}`
| 测试 | 场景 |
|------|------|
| `convert_request_text_only` | 纯文本输入转换 |
| `convert_request_vision` | Vision 输入转换 |
| `convert_request_tool_call` | 工具调用输入转换 |
| `convert_response_message` | 响应 message item 转换 |
| `convert_response_tool_use` | 响应 function_call item 转换 |
---
### Phase 30:测试 + CI + 文档(~520 行)
#### Step 30.1wiremock 非流式测试(~200 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs` 内联测试
| 测试 | 场景 | 验证 |
|------|------|------|
| `response_api_basic_text` | 纯文本响应 | `response.text()` 正确 |
| `response_api_tool_call` | 工具调用 | `stop_reason == ToolUse` |
| `response_api_multi_turn` | 两轮对话 | 第二轮携带历史 |
| `response_api_vision` | 图片输入 | 正确构造 `input_image` |
| `response_api_unauthorized` | 401 错误 | `LlmError::Authentication` |
| `response_api_rate_limit` | 429 错误 | `LlmError::RateLimit` |
| `response_api_server_error` | 500 错误 | `LlmError::Request` |
#### Step 30.2wiremock 流式测试(~200 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs` 内联测试
| 测试 | 场景 | 验证 |
|------|------|------|
| `response_api_stream_text` | 流式文本 | 完整 SSE 事件序列 |
| `response_api_stream_tool` | 流式工具调用 | `FunctionCallArgumentsDelta` 序列 |
| `response_api_stream_error` | 流中途失败 | `StreamEvent::Error` |
| `response_api_stream_multi_turn` | 流式多轮接续 | 第二轮携带历史消息时的完整 SSE 事件序列 |
#### Step 30.3CI 矩阵(~10 行)
**文件操作**:修改 `.github/workflows/ci.yml`
新增测试组合:
```yaml
- "chat,provider-openai,provider-openai-response"
```
#### Step 30.4:文档更新(~50 行)
**文件操作**:修改 `README.md` + `docs/roadmap.md`
- README feature 表新增 `provider-openai-response`
- `docs/roadmap.md``docs/roadmap-unsorted.md` 新增 v0.3.3 或下版本条目
#### Step 30.5Example~60 行)
**文件操作**:新建 `examples/response_api_demo.rs`
```toml
[[example]]
name = "response_api_demo"
required-features = ["llm", "provider-openai-response"]
```
基础对话示例,展示 Response API 的基本用法:
```
cargo run --example response_api_demo --features "full"
```
---
### 实施汇总
| Phase | 内容 | 代码行数估算 | 验证入口 |
|-------|------|------------|---------|
| 28 | Feature gate + Wire 类型 + Provider 骨架 | ~140 | `cargo build --features "provider-openai-response"` |
| 29 | 核心 Provider 实现(转换/HTTP/流式) | ~680 | 5 个单元测试 |
| 30 | 测试 + CI + 文档 | ~520 | 10 个 wiremock 测试 + CI 新组合 |
| **合计** | | **~1,340** | 全量 `cargo test --features "full"` |
---
## 5. 风险评估
| ID | 风险 | 影响 | 概率 | 缓解措施 |
|----|------|------|------|---------|
| R1 | Response API 协议快速迭代 | Wire 类型可能需更新 | 中 | Wire 类型集中在单个文件内,更新成本低 |
| R2 | `ContentBlock::Extension` 承载内置工具结果 | 下游消费方需适配 | 低 | 这是既有的逃生舱机制,已有消费模式 |
| R3 | 全量历史模式 token 开销 | 多轮时 input tokens 增长 | 低 | 功能正确,后续版本可优化为 `previous_response_id` 增量模式 |
| R4 | `instructions` 拼接多个 system 消息 | 语义可能与单 system 消息不同 | 低 | 已确认按 OpenAI 推荐方式全量拼接(`\n` 分隔),行为等价 |
| R5 | 与 `GenericOpenaiProvider` 的错误映射逻辑重复 | 维护两份相似逻辑 | 低 | 提取复用函数时需注意不影响现有 provider |
---
## 6. 验证标准
### 6.1 编译验证
| # | 检查项 | 命令 |
|---|--------|------|
| C1 | 独立 feature 编译 | `cargo build --features "provider-openai-response"` |
| C2 | full 组合编译 | `cargo build --features "full"` |
| C3 | light 组合不受影响 | `cargo build --features "light"`(不包含新 feature |
| C4 | Clippy 合规 | `cargo clippy --all-features --lib -- -D warnings` |
### 6.2 测试验证
| # | 检查项 | 通过条件 |
|---|--------|---------|
| T1 | 单元测试 | `cargo test --features "full"` 全部通过(+15 新增测试) |
| T2 | 非流式 wiremock | 7 个测试覆盖文本/工具/多轮/Vision/401/429/500 |
| T3 | 流式 wiremock | 3 个测试覆盖文本流/工具流/错误流 |
| T4 | 现有测试无回归 | 使用 `--features "full"` 时已有 427 测试全部通过 |
### 6.3 CI 验证
| # | 检查项 | 通过条件 |
|---|--------|---------|
| I1 | 新增 CI 组合 | 包含新 feature 的组合编译通过 |
| I2 | clippy + format | `cargo clippy` + `cargo fmt --check` 通过 |
### 6.4 Example 验证
| # | 检查项 | 通过条件 |
|---|--------|---------|
| E1 | Example 编译 | `cargo build --example response_api_demo --features "full"` 通过 |
| E2 | Example 运行 | `cargo run --example response_api_demo --features "full"` 可执行(需 API key |
@@ -0,0 +1,422 @@
# OpenAI Response Provider 自定义请求头支持
## 背景
OpenAI Responses API 的部分实现(如火山引擎豆包)需要携带特殊的 HTTP 请求头(如 `ark-beta-doubao-app: true`)来启用平台特定功能。当前 `OpenaiResponseProvider``build_request_builder()` 中只设置了 `Authorization` 头,没有途径注入自定义请求头。
原方案只覆盖 OpenAI Response Provider。经讨论后扩展为**三 Provider 统一**方案:OpenAI Chat`GenericOpenaiProvider`)、OpenAI Response`OpenaiResponseProvider`)、Anthropic`AnthropicProvider`)。
核心动机:
- OpenAI Responses API 的部分实现需要携带特殊 HTTP 请求头来启用平台特定功能
- 三种基础协议中,自定义头注入能力不一致
- 统一 API 让调用方用 `set_extra("custom_headers", ...)` 即可,与底层协议无关
## 需求
### 功能需求
双层自定义头机制:
- **Provider 级固定头**`extra_headers: Vec<(String, String)>`,构造时注入,所有请求自动携带。用于该 provider 所有请求都需要的固定标识头(如平台接入标记)
- **请求级临时头**`extra.custom_headers: HashMap<String, String>`,通过 `set_extra` 注入。用于特定请求需要覆盖或追加的头
### 约束
- 不可引入任何平台特定逻辑(火山、豆包等字符串不得出现)
- 自定义头仅运行时生效,不进入 JSON 序列化的请求体
- 兼容已有的 extra 逃生舱机制(builtin_tools、text_format 等)
- agcore 是支持库,不提供运行时敏感头过滤保护(如 Authorization/Cookie),但文档中应说明风险
- 不修改 `LlmProvider` trait、`ProviderType` 枚举
- `create_provider()` 工厂函数只传 `Vec::new()` 作为 extra_headers 默认值,不暴露配置能力;调用方如需 Provider 级固定头,直接构造 provider 后链式调用 `.with_extra_headers()`
### 用户故事
1. 作为集成者,我想对任意 provider 的请求注入自定义 HTTP 头,以启用平台特有功能(请求级)
2. 作为集成者,我想在 provider 构造时注入固定头,让所有请求自动携带,避免每次重复指定(Provider 级)
3. 作为维护者,我想三种基础协议使用统一的 API,调用方无需关心底层 provider 类型
## 方案设计
### 统一设计原则
```
调用方视角(统一 API):
request.set_extra("custom_headers", json!({"X-Foo": "bar"}));
// 不管底层是 OpenAI Chat / OpenAI Response / Anthropic,都能工作
构造方视角(Provider 级):
OpenaiResponseProvider::from_parts(..., extra_headers).with_extra_headers(...);
GenericOpenaiProvider::from_parts(..., extra_headers); // 已有
AnthropicProvider::from_parts(..., extra_headers);
头融合顺序(三 provider 一致):
认证头 (Authorization / x-api-key) → Provider 级 extra_headers → 请求级 custom_headers
↑ 后者覆盖前者
```
### 改动一:GenericOpenaiProvideropenai.rs
**① `OpenaiChatRequest` 新增字段**
`extra_body`(第 147 行)之后:
```rust
/// 请求级别自定义 HTTP 头。运行时注入,不进入 JSON 请求体。
/// ⚠️ 与 struct 已有的 `extra_headers: Option<Value>`OpenAI API 自身的 wire 格式字段)
/// 不同——后者是 OpenAI API 参数,本字段是 reqwest 层的 HTTP 头注入。
#[serde(skip)]
pub custom_headers: HashMap<String, String>,
```
`#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。
**② `convert_request()` 从 extra 提取**
`parallel_tool_calls`(第 559 行)之后:
```rust
let custom_headers: HashMap<String, String> = request
.get_extra_opt("custom_headers")
.unwrap_or_default();
```
**③ `build_request_builder()` 签名改具体类型 + 注入逻辑**
第 454 行,签名从 `&impl Serialize` 改为 `&OpenaiChatRequest`(两处调用点传入的均为该类型,安全):
```rust
fn build_request_builder(
&self,
url: &str,
body: &OpenaiChatRequest, // 从 &impl Serialize 改为具体类型
) -> Result<reqwest::RequestBuilder, LlmError> {
let mut builder = self
.http_client
.post(url)
.header("Authorization", format!("Bearer {}", self.api_key));
// 头融合顺序见上方「统一设计原则」。
// Provider 级固定头先注入,请求级临时头后注入(后者覆盖前者)。
Ok(builder.json(body))
}
```
两处调用点(`chat_blocking` 第 628 行、`chat_stream_inner` 第 669 行)传入的都是 `&OpenaiChatRequest`,零影响。
**④ `with_extra_headers()` builder 方法**
```rust
/// 注入 Provider 级别固定头。返回 self 以支持链式调用。
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
self.extra_headers = headers;
self
}
```
### 改动二:OpenaiResponseProvideropenai_response.rs
**① struct 新增 `extra_headers` 字段**
第 287 行,`pub struct OpenaiResponseProvider` 增加:
```rust
pub struct OpenaiResponseProvider {
// ... 已有字段 ...
extra_headers: Vec<(String, String)>,
}
```
**② `from_parts()` 新增参数**
第 299 行:
```rust
pub(crate) fn from_parts(
base_url: String,
api_key: String,
model: String,
http_client: Client,
timeout_secs: u64,
extra_headers: Vec<(String, String)>, // 新增
) -> Self { ... }
```
**③ `with_extra_headers()` builder 方法**
```rust
/// 注入 Provider 级别固定头。返回 self 以支持链式调用。
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
self.extra_headers = headers;
self
}
```
**④ `OpenaiResponseRequest` 新增字段**
第 73 行,`reasoning` 之后:
```rust
/// 请求级别自定义 HTTP 头。序列化时跳过,仅运行时由 build_request_builder 消费。
/// stream 模式的修改不影响该字段——header 由 convert_request 在请求构造时注入。
#[serde(skip)]
pub custom_headers: HashMap<String, String>,
```
`#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。
**⑤ `convert_request()` 从 extra 提取**
第 404 行,`reasoning` 之后:
```rust
let custom_headers: HashMap<String, String> = extra
.get("custom_headers")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
```
> **注意**OpenaiResponseProvider 的 `convert_request` 在顶部 destructure 了 `request`,因此使用 `extra.get()` 而非 `request.get_extra_opt()`。两者语义一致,均反序列化为 `HashMap<String, String>`,失败时静默降级为空 HashMap。
**⑥ `build_request_builder()` 签名 + 注入逻辑**
第 319 行,签名从 `&impl Serialize` 改为 `&OpenaiResponseRequest`(两处调用点传入的均为该类型,安全):
```rust
/// 构造 HTTP POST 请求 builder(含认证头与额外请求头)。
///
/// 头融合顺序:Authorization → Provider 级 extra_headers → 请求级 custom_headers
/// 后者覆盖前者。
fn build_request_builder(
&self,
body: &OpenaiResponseRequest, // 从 &impl Serialize 改为具体类型
) -> Result<reqwest::RequestBuilder, LlmError> {
let mut builder = self
.http_client
.post(self.endpoint_url())
.header("Authorization", format!("Bearer {}", self.api_key));
for (k, v) in &self.extra_headers {
builder = builder.header(k.as_str(), v.as_str());
}
for (key, value) in &body.custom_headers {
builder = builder.header(key.as_str(), value.as_str());
}
Ok(builder.json(body))
}
```
两处调用点(`chat_blocking` 第 708 行、`chat_stream_inner` 第 741 行)传入的都是 `&OpenaiResponseRequest`,零影响。
### 改动三:AnthropicProvideranthropic.rs
AnthropicProvider 是唯一没有统一 `build_request_builder` 方法的 provider,需要**前置重构**。
**① struct 新增 `extra_headers` 字段**
第 36 行:
```rust
pub struct AnthropicProvider {
// ... 已有字段 ...
extra_headers: Vec<(String, String)>,
}
```
**② `from_parts()` 新增参数**
第 128 行:
```rust
pub(crate) fn from_parts(
base_url: String,
api_key: String,
model: String,
http_client: Client,
timeout_secs: u64,
extra_headers: Vec<(String, String)>, // 新增
) -> Self { ... }
```
**③ `with_extra_headers()` builder 方法**
```rust
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
self.extra_headers = headers;
self
}
```
**④ `AnthropicRequestBody` 新增字段**
第 450 行,`stream` 之后:
```rust
struct AnthropicRequestBody {
model: String,
max_tokens: u32,
// ... 已有字段 ...
/// 请求级别自定义 HTTP 头。运行时注入,不进入 JSON 请求体。
#[serde(skip)]
custom_headers: HashMap<String, String>,
}
```
`#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。
**⑤ `build_request_body()` 从 extra 提取**
```rust
let custom_headers: HashMap<String, String> = request
.get_extra_opt("custom_headers")
.unwrap_or_default();
```
**⑥ 提取 `build_request_builder()` 统一方法(前置重构)**
```rust
/// 构造 HTTP POST 请求 builder(含认证头 + 自定义头)。
/// 认证头(x-api-key / anthropic-version)已由 Client 的 default_headers 提供。
fn build_request_builder(
&self,
body: &AnthropicRequestBody,
) -> Result<reqwest::RequestBuilder, LlmError> {
let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
let mut builder = self.http_client.post(&url).json(body);
for (k, v) in &self.extra_headers {
builder = builder.header(k.as_str(), v.as_str());
}
for (key, value) in &body.custom_headers {
builder = builder.header(key.as_str(), value.as_str());
}
Ok(builder)
}
```
**⑦ 改造 `chat_blocking()``chat_stream_inner()`**
改造前(`chat_blocking`,第 263-269 行):
```rust
let response = self
.http_client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| self.map_reqwest_error(e))?;
```
改造后:
```rust
let response = self
.build_request_builder(&body)?
.send()
.await
.map_err(|e| self.map_reqwest_error(e))?;
```
`chat_stream_inner`(第 298-304 行)同理。
### 改动四:create_provider()provider.rs
依据约束「`create_provider()` 工厂函数不暴露配置能力」,三处分支适配 `from_parts` 的新签名时全部传 `Vec::new()`
```rust
// OpenaiResponse(第 199-207 行)
openai_response::OpenaiResponseProvider::from_parts(
config.base_url, config.api_key, config.model,
client, config.timeout_secs,
Vec::new(), // extra_headers 默认空
)
// Anthropic(第 215-221 行)
anthropic::AnthropicProvider::from_parts(
config.base_url, config.api_key, config.model,
client, config.timeout_secs,
Vec::new(), // extra_headers 默认空
)
// OpenAI Chat(第 185-194 行)— 已有 Vec::new(),无需改动
```
### 调用方式
**请求级临时头**(统一 API,三 provider 通用):
```rust
request.set_extra("custom_headers", serde_json::json!({
"ark-beta-doubao-app": "true"
}));
```
**Provider 级固定头**(构造时注入):
```rust
let provider = OpenaiResponseProvider::from_parts(...)
.with_extra_headers(vec![
("ark-beta-doubao-app".into(), "true".into()),
]);
```
## 风险评估
### 风险点与缓解措施
| 风险 | 等级 | 缓解措施 |
|------|------|---------|
| 用户通过 `custom_headers` 覆盖 `Authorization` 等认证头 | 中 | 文档说明:自定义头按遍历顺序注入,同 key 后注入覆盖前注入。agcore 作为支持库不做运行时拦截 |
| `serde_json::from_value` 类型错误静默降级为空 HashMap | 低 | 与已有 extra 字段(builtin_tools、text_format)一致的模式,保持行为统一。类型错误时请求正常发出,只是不携带自定义头 |
| HashMap 迭代顺序不确定影响测试确定性 | 低 | HTTP 协议不要求 header 顺序,wiremock 按名匹配。无需特殊处理 |
| AnthropicProvider 前置重构引入回归 | 低 | 提取 `build_request_builder` 是纯重构,现有测试覆盖其请求构造行为。重构后运行现有测试套件即可验证 |
| `build_request_builder` 签名从泛型改为具体类型 | 低 | 已确认两处调用点(chat_blocking / chat_stream_inner)传入的均为具体类型,零影响 |
| AnthropicProvider 的 `default_headers`x-api-key / anthropic-version)与 `extra_headers` 同名头合并行为取决于 reqwest 实现 | 低 | 明确约定 Provider 级固定头不应意图覆盖认证头;`build_request_builder` 的 doc comment 中标注认证头来源 |
### 设计取舍记录
| 决策 | 选择 | 理由 |
|------|------|------|
| Provider 级 vs 请求级 | 双层都支持 | 满足固定头和临时头两种场景 |
| `create_provider` 是否暴露 extra_headers | 不暴露,只传 `Vec::new()` | 保持工厂函数签名简洁,固定头通过 builder 方法注入 |
| 敏感头保护 | 不做运行时拦截,文档说明 | agcore 是支持库,不替调用方做保护 |
| `OpenaiChatRequest.custom_headers` 命名 | 用 `custom_headers` 而非 `extra_headers` | 避免与已有的 `extra_headers: Option<Value>`OpenAI API wire 字段)混淆 |
## 验证标准
### 单元测试(每 provider 4 个)
| 测试 | 验证点 |
|------|--------|
| `*_custom_headers_from_extra` | `convert_request` / `build_request_body` 能从 extra 提取 `custom_headers` |
| `*_custom_headers_skipped_in_json` | `#[serde(skip)]` 确保 custom_headers 不进入序列化 JSON body |
| `*_custom_headers_invalid_type_fallback` | 传入错误类型(如字符串而非对象)时静默降级为空 HashMap |
| `*_extra_headers_from_constructor` | 验证 `from_parts` / `new_with_name_and_headers` 传入的 `extra_headers``build_request_builder` 中被正确注入到 HTTP 请求头 |
### 集成测试(每 provider 4 个,wiremock
| 测试 | 验证点 |
|------|--------|
| `*_custom_headers_are_sent` | mock 匹配器验证 HTTP 请求确实携带自定义头 |
| `*_provider_level_headers_are_sent` | 验证 Provider 级固定头(通过 `with_extra_headers` 注入)确实出现在 HTTP 请求中 |
| `*_custom_headers_override_provider_headers` | 当 Provider 级和请求级设置了相同 key 但不同值时,最终 HTTP 请求携带的是请求级的值 |
| `*_custom_headers_can_override_auth_header` | 注入含 `Authorization` 同 key 的 `custom_headers`,验证最终认证头值被覆盖(使行为可见、可预测,与文档风险说明一致) |
### 回归验证
1. 运行 `cargo test --features full` 确保所有现有测试通过
2. `cargo clippy --features full` 无新警告
3. `cargo fmt --check` 格式一致
## 不涉及的改动
- 不新增 Feature gate
- 不修改 `LlmProvider` trait
- 不修改 `ProviderType` 枚举
- 不新增任何平台相关代码
@@ -0,0 +1,268 @@
# Builtin Tools 注入修复方案
## 背景与目标
### 问题描述
agcore 的 OpenaiResponseProvider 在通过 extra 逃生舱注入内置工具(`web_search` / `file_search`)时存在两层缺陷,导致 builtin_tools 完全不生效:
1. **分支逻辑错位**`convert_request`,第 615-640 行):builtin_tools 注入代码被嵌套在 `tools_defs` 非空的 `else` 分支内。当调用方只提供 builtin_tools 而不提供 tools_defs 时,分支走 `if tools_defs.is_empty() { None }`,注入代码完全不执行。
2. **枚举不完整**`ResponseTool`,第 136-146 行):枚举只有 `Function` 一种变体,非 `function` 类型的 builtin 工具(如 `type: "web_search"`)反序列化失败,退化为 `name: ""` 的空函数定义,API 层面被拒绝。
### 目标
- 修复 builtin_tools 注入逻辑,使纯内置工具、混用场景均正常工作
- 不破坏现有 tools_defs 功能
- 添加回归测试
## 需求分析
### 功能需求
| # | 需求 | 优先级 |
|---|------|--------|
| F1 | `tools_defs` 为空、`builtin_tools` 非空时,正确注入内置工具 | P0 |
| F2 | `tools_defs``builtin_tools` 同时非空时,合并注入 | P0 |
| F3 | 两端均为空时,tools 字段为 None(回归保底) | P0 |
| F4 | 无效的 builtin_tools 值不导致崩溃,跳过并告警 | P1 |
### 非功能需求
- 不做底层架构改造(extra 逃生舱机制不变)
- 不改 `openai.rs`Chat Completions API 不支持内置工具)
## 方案设计
### 总体架构
修复分三步,对应三层独立但不相互依赖的改动:
```
┌─────────────────────────────────────────────────┐
│ convert_request() │
│ │
│ [改动二] 重构分支逻辑 │
│ ┌─────────────────────────────────────────┐ │
│ │ tools_defs ──→ 生成 Vec<ResponseTool> │ │
│ │ builtin_tools ──→ 追加到同一 Vec │ │
│ │ 两者都空 ──→ None; 否则 ──→ Some(items) │ │
│ └─────────────────────────────────────────┘ │
│ │
│ [改动三] 错误处理 │
│ unwrap_or_else ──→ match + warn! │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ ResponseTool 枚举 │
│ │
│ [改动一] 添加 Builtin(Value) 变体 │
│ 自定义 Serialize/Deserialize 避免信息丢失 │
└─────────────────────────────────────────────────┘
```
### 改动一:扩展 `ResponseTool` 枚举
**位置**:第 136-146 行
**现状**
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum ResponseTool {
#[serde(rename = "function")]
Function {
name: String,
description: String,
parameters: Value,
},
}
```
**改后**(需要自定义 Serialize/Deserialize):
```rust
/// NOTE: 仅在请求序列化路径使用(convert_request → build_request_builder → HTTP body)。
/// 响应反序列化走 ResponseOutputItem,不经过此类型。
/// 自定义 Deserialize 服务于 convert_request 内 extra 字段反序列化。
#[derive(Debug, Clone)]
pub(crate) enum ResponseTool {
Function {
name: String,
description: String,
parameters: Value,
},
/// 非 function 类型的工具(如 web_search / file_search / code_interpreter)。
/// 直接透传原始 JSON Value,不做结构化解析,避免信息丢失。
Builtin(Value),
}
impl Serialize for ResponseTool {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
ResponseTool::Function { name, description, parameters } => {
let mut map = serde_json::Map::new();
map.insert("type".into(), Value::String("function".into()));
map.insert("name".into(), Value::String(name.clone()));
map.insert("description".into(), Value::String(description.clone()));
map.insert("parameters".into(), parameters.clone());
map.serialize(serializer)
}
ResponseTool::Builtin(value) => value.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for ResponseTool {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = Value::deserialize(deserializer)?;
match value.get("type").and_then(|t| t.as_str()) {
Some("function") => {
let name = value.get("name").and_then(|n| n.as_str()).unwrap_or_default().to_string();
let description = value.get("description").and_then(|d| d.as_str()).unwrap_or_default().to_string();
let parameters = value.get("parameters").cloned().unwrap_or(Value::Null);
Ok(ResponseTool::Function { name, description, parameters })
}
_ => Ok(ResponseTool::Builtin(value)),
}
}
}
```
关键点:
- 自定义 `Serialize``Builtin` 变体直接输出原始 Value(不包裹额外标记)
- 自定义 `Deserialize`:非 `"function"` 类型自动走 `Builtin(Value)` 分支
- 保留原始 JSON 结构,避免信息丢失(如 `search_context_size``user_location` 等字段)
### 改动二:修复 `convert_request` 分支逻辑
**位置**:第 615-640 行
**现状**(伪代码):
```
if tools_defs.is_empty() {
None // ← builtin_tools 被完全跳过
} else {
从 tools_defs 生成 Vec<ResponseTool>
if let Some(builtin_tools) {
for v in extra {
items.push(from_value(v)) // ← 只有进了 else 才执行
}
}
Some(items)
}
```
**改后**(伪代码):
```
let mut items: Vec<ResponseTool> = Vec::new();
// 1. 始终处理 tools_defs
items.extend(tools_defs.into_iter().map(|t| ResponseTool::Function { ... }));
// 2. 始终处理 builtin_tools(与 tools_defs 解耦)
if let Some(builtin_tools) = builtin_tools {
for v in extra {
// 见改动三
items.push(serde_json::from_value(v).unwrap_or_else(|_| { ... }));
}
}
// 3. 两者都空 → None;否则 → Some
if items.is_empty() { None } else { Some(items) }
```
### 改动三:改进错误处理
**位置**:第 630-636 行(`unwrap_or_else` 部分)
**现状**
```rust
items.push(serde_json::from_value(v).unwrap_or_else(|_| {
ResponseTool::Function {
name: String::new(),
description: String::new(),
parameters: Value::Null,
}
}));
```
**改后**
```rust
match serde_json::from_value(v.clone()) {
Ok(tool) => items.push(tool),
Err(e) => {
let raw = serde_json::to_string(&v).unwrap_or_default();
warn!(tool = %raw, error = %e, "skipped invalid builtin_tool");
}
}
```
`warn!` 输出被反序列化的 Value 摘要,便于生产排障时定位问题(无需复现调用方输入)。
`unwrap_or_else` 的错误值(空函数定义)在 OpenAI API 层会被拒绝,无实际价值。替换为 `match` + `warn!` 可明确跳过并记录原因。
### 改动四:添加测试
在文件末尾 `#[cfg(test)]` 区域或 `tests/` 目录新增 6 个测试用例:
| # | 用例名 | 场景 | 验证点 |
|---|--------|------|--------|
| T1 | `test_builtin_only` | 仅提供 builtin_tools | tools 为 Some,含正确 type |
| T2 | `test_mixed_tools` | 同时提供 tools_defs + builtin_tools | 合并后 items 顺序/数量正确 |
| T3 | `test_no_tools` | 两端均为空 | tools 为 None |
| T4 | `test_invalid_builtin` | builtin_tools 含无效 JSON | 不崩溃,有效项保留 |
| T5 | `test_function_wire_format` | ResponseTool::Function 序列化 | JSON 结构与改动前一致(AC7) |
| T6 | `test_builtin_roundtrip` | ResponseTool::Builtin 反序列化+序列化 | 原始 JSON 结构保留 |
## 实现计划
### 步骤
| 步骤 | 改动 | 文件 | 估算 |
|------|------|------|------|
| 1 | 扩展 `ResponseTool` 枚举,添加 `Builtin(Value)` + 自定义 Serialize/Deserialize | `openai_response.rs:136-146` | 40 行 |
| 2 | 重构 `convert_request` 分支逻辑,解耦 tools_defs 与 builtin_tools | `openai_response.rs:615-640` | 15 行 |
| 3 | 替换 `unwrap_or_else``match` + `warn!` | `openai_response.rs:630-636` | 5 行 |
| 4 | 添加 6 个测试用例 | `openai_response.rs` 末尾 | 70 行 |
| 5 | `cargo test` 验证全部通过 | - | - |
### 优先级
**P0(核心修复)**:步骤 1 + 2,修复分支逻辑和枚举不完整问题。
**P1(健壮性)**:步骤 3,改进错误处理。
**P1(质量保障)**:步骤 4 + 5,测试覆盖。
### 依赖关系
无外部依赖。全部改动限定在 `openai_response.rs` 一个文件内。
## 风险评估
| 风险 | 概率 | 影响 | 缓解措施 |
|------|------|------|----------|
| 自定义 Serialize/Deserialize 实现遗漏边界情况 | 低 | 中 | 测试覆盖所有分支:Function / Builtin / 无效值 |
| 现有 `Function` 序列化格式变化 | 低 | 高 | 自定义 Serialize 保持与原 derive 行为一致,测试覆盖 wire 格式 |
| `warn!` 日志在生产环境未配置 logger 导致 panic | 低 | 中 | 使用 `tracing::warn!`(已导入),项目已初始化 tracing logger |
### 回滚方案
单文件改动,回滚只需 `git checkout -- src/llm/provider/openai_response.rs`
## 验收标准
| # | 验收条件 | 验证方式 |
|---|----------|----------|
| AC1 | `tools_defs` 空 + `builtin_tools``{"type":"web_search"}` → 请求体 `tools` 包含 `{"type":"web_search"}` | 单测 T1 |
| AC2 | 混用场景 → tools 数组同时包含 function 和非 function 工具 | 单测 T2 |
| AC3 | 两端空 → tools 字段为 null/None | 单测 T3 |
| AC4 | 无效 builtin_tools → 不 panic,有效项不受影响 | 单测 T4 |
| AC5 | 全部现有测试通过 | `cargo test` |
| AC6 | `cargo clippy` 无新增警告 | `cargo clippy` |
| AC7 | `ResponseTool::Function` 序列化后的 JSON 结构与改动前一致 | 单测:验证字段顺序和值 |
---
**编写人**Writer Agent
**编写日期**2026-07-20
**基于**agcore builtin_tools 注入问题分析结论
@@ -0,0 +1,249 @@
# PDD:Usage 字段反序列化容错方案
**状态**Draft
**作者**Think Agent
**日期**2026-07-27
## 1. 背景与目标
`agcore::Usage` 结构体的必填 token 字段添加 `#[serde(default)]` 反序列化容错,使 OpenAI Response API 和 Chat Completions API 的 blocking 调用在火山平台返回不完整的 `usage` 字段时不再崩溃。
### 触发场景
dc-management 项目调用火山引擎 Responses API 时,非流式路径报错:
```
OpenAI Response 响应解析失败 error=missing field 'prompt_tokens' at line 1 column 1581
```
根因是 `Usage` 的三个 token 字段(`prompt_tokens``completion_tokens``total_tokens`)定义为必填 `u32`,而火山平台的 Responses API 返回的 `usage` 对象中这些子字段偶发缺失。
### 影响面
4/5 的 LLM 命令被阻断:
| 功能 | 命令 | 状态 |
|------|------|------|
| AI 探索(产品采集) | `llm_collect``workflow.rs` `.chat()` | ❌ 不可用 |
| 普通聊天 | `llm_chat``llm.rs` `.chat()` | ❌ 不可用 |
| 要素值正则化 | `llm_normalize_values``normalizer.rs` `.chat()` | ❌ 不可用 |
| 数据验证 | `validator.rs` `.chat()` | ❌ 不可用 |
| 连接验证 | `verify_llm_connection``chat_stream()` | ✅ 幸免(流式) |
## 2. 需求推演概要
### 需求拆解
- **核心需求**blocking 路径下 `Usage` 反序列化不因缺失子字段而崩溃
- **范围边界**:只改 serde 反序列化行为,不改字段类型,不改流式路径
- **质量属性**:最小变更(±3 行)、零副作用、测试可验证
### 关键假设
| 假设 | 依据 | 验证方式 |
|------|------|---------|
| `Usage` 已 derive `Default` | 代码确认 | 编译通过 |
| 缺失字段默认 0 对所有消费者安全 | `CostTracker::add` 使用 `saturating_add`;流式路径已有 `unwrap_or(0)` 行为 | 代码审查 |
| `#[serde(default)]` 不影响序列化 | serde 明确语义 | 代码审查(serde 明确语义) |
| `usage: null` 不会出现 | 当前未观察到,留 `ponytail:` 注释 | 生产观察 |
| Chat Completions blocking 路径存在同源缺失风险 | 两路径共用火山平台底层 API 基础设施 | 在 §5.4 添加 `#[serde(default)]` 预防性加固(经 PRD 作者推演阶段确认) |
## 3. 当前问题分析
### 根因
- `src/llm/types/usage.rs:4-12``Usage` 结构体的 `prompt_tokens``completion_tokens``total_tokens` 定义为 **必填 `u32`**,非 `Option<u32>`
- `src/llm/provider/openai_response.rs:207``OpenaiResponseBody` 内嵌 `usage: Usage`,也是必填
- `src/llm/provider/openai.rs:245``OpenaiChatResponse` 内嵌 `usage: Usage`,同样必填
- 火山平台 Responses API 返回的 `usage` 对象中,子字段缺失 → serde 反序列化 `missing field` 错误
- 流式路径不受影响:其 `PartialUsage` 所有字段均为 `Option<u32>`,缺失时通过 `unwrap_or(0)` 兜底
### 已知线索
- dc-management 的 `verify_llm_connection` 已预见到此问题,注释特意说明使用流式规避
- 这是已知的设计约束——流式已容错但 blocking 路径未同步加固
## 4. 架构决策记录
| 决策 | 选项 | 选择 | 理由 |
|------|------|------|------|
| 容错机制 | `#[serde(default)]` vs 改为 `Option<u32>` vs 自定义 `Deserialize` | `#[serde(default)]` | 最小变更,不改类型语义 |
| 覆盖范围 | 仅 Responses API vs 同时覆盖 Chat Completions | 同时覆盖 | 增量成本≈0,防患于未然(经 PRD 作者推演阶段确认) |
| 测试范围 | 2 个场景 vs 4 个场景 | 4 个场景 | 必要的边界覆盖(缺失 key / 缺失字段 / 空对象 / 完整回归) |
## 5. 设计方案
### 5.1 变更概览
**3 行 serde attribute + 4 个测试用例,零逻辑变更。**
### 5.2 变更一:`Usage` 结构体加 struct-level `#[serde(default)]`
**文件**`src/llm/types/usage.rs:3`
```diff
- #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
+ #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
+ #[serde(default)]
pub struct Usage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub completion_tokens_details: Option<CompletionTokensDetails>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_tokens_details: Option<PromptTokensDetails>,
}
```
**作用**JSON 中缺失 `Usage`**任何** 子字段时,自动使用 `Default::default()` 取值:
- 三个 token 字段(`u32`):缺失时默认 `0`
- 两个 `Option` 详情字段:缺失时默认 `None`
**为什么 struct-level 够用**struct-level 对子字段统一生效,不需要为每个字段单独标注。
### 5.3 变更二:`OpenaiResponseBody.usage``#[serde(default)]`
**文件**`src/llm/provider/openai_response.rs:207`
```diff
pub(crate) struct OpenaiResponseBody {
pub id: String,
pub model: String,
pub output: Vec<ResponseOutputItem>,
+ #[serde(default)]
pub usage: Usage,
pub status: String,
}
```
**作用**:整个 `usage` 键在 JSON 中完全缺失时,自动默认 `Usage::default()`
### 5.4 变更三:`OpenaiChatResponse.usage``#[serde(default)]`
**文件**`src/llm/provider/openai.rs:245`
```diff
pub(crate) struct OpenaiChatResponse {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
pub choices: Vec<Choice>,
+ #[serde(default)]
pub usage: crate::llm::types::usage::Usage,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_fingerprint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<ServiceTier>,
}
```
### 5.5 自动受益路径(无需变更)
| 路径 | 字段 | 为何已安全 |
|------|------|-----------|
| `OpenaiChatChunk.usage` | `Option<Usage>` | `Option` 天然兜底 `None``Some({不全})``Usage` struct-level `#[serde(default)]` 兜住。注意:此路径仅用于流式反序列化,API 流式 last chunk 的 usage 通常完整,受益场景概率极低 |
| `MessageResponse.usage` | `Usage`(IR 层) | IR 层不直接反序列化 JSON,只从 provider 传递已解析的值 |
### 5.6 未覆盖的已知边界
```rust
// ponytail: #[serde(default)] handles missing key; null usage not observed from API,
// but if it appears, add deserialize_with to map null → Usage::default()
```
`"usage": null` 会导致 serde 尝试将 `null` 反序列化为 `Usage` 结构体,当前方案无法兜底。当前未观察到该行为,暂不处理。
### `total_tokens` 缺失时的语义不一致
当 API 返回 `{"prompt_tokens": 10, "completion_tokens": 5}` 但缺失 `total_tokens` 时,struct-level `#[serde(default)]` 使 `total_tokens = 0`,而非 `10 + 5 = 15``CostTracker::add` 使用 `saturating_add` 不会崩溃,但聚合统计中 `total_tokens` 可能不等于 `prompt_tokens + completion_tokens` 之和。此行为与流式 `PartialUsage::into_usage()``unwrap_or(0)` 一致,属于已知的简化取舍。
### Responses API 流式路径 usage 恒为零(pre-existing
`ResponseSseMeta``openai_response.rs:335-340`)不包含 `usage` 字段,导致流式 `response.completed` 事件的 token 用量信息未被捕获,`MessageComplete.full_response.usage` 恒为零。这是一个独立于本次变更的 pre-existing 缺陷。阻塞路径加 `#[serde(default)]` 后,两条路径行为一致(皆为零值),不引入新差异。此问题建议作为后续独立跟踪项处理。
### 5.7 效应链路
```
Usage struct-level #[serde(default)]
├─ OpenaiResponseBody.usage: Usage ← 子字段缺失兜住
│ └─ field-level #[serde(default)] ← 整个 key 缺失兜住
├─ OpenaiChatResponse.usage: Usage ← 子字段缺失兜住(自动受益)
│ └─ field-level #[serde(default)] ← 整个 key 缺失兜住(新增)
└─ OpenaiChatChunk.usage: Option<Usage> ← 子字段缺失兜住(自动受益,无需改动)
└─ Option 已有 skip_serializing_if ← None 时跳过
```
## 6. 实施步骤
| 步骤 | 文件 | 操作 | 验证 |
|------|------|------|------|
| 1 | `src/llm/types/usage.rs:3` | 加 `#[serde(default)]` | `cargo build` |
| 2 | `src/llm/provider/openai_response.rs:207` | 加 `#[serde(default)]` | `cargo build` |
| 3 | `src/llm/provider/openai.rs:245` | 加 `#[serde(default)]` | `cargo build` |
| 4 | `openai_response.rs` tests 模块 | 加 4 个测试用例 | `cargo test` |
| 5 | 全量检查 | `cargo test && cargo clippy` | 无失败/新增警告 |
**预计时长**:30 分钟(含测试编写与验证)。
## 7. 验证标准
- [ ] `Usage` 结构体三个 token 字段在 JSON 缺失时不报错,默认值为 `0`
- [ ] `OpenaiResponseBody.usage` 键在 JSON 中完全缺失时不报错,默认 `Usage::default()`
- [ ] `OpenaiChatResponse.usage` 键在 JSON 中完全缺失时不报错,默认 `Usage::default()`
- [ ] 序列化行为不受影响(输出 JSON 仍包含全部 token 字段)
- [ ] `cargo test` 全部通过
- [ ] `cargo clippy` 无新增警告
- [ ] dc-management 项目 `llm_collect`(AI 探索)命令正常返回结果(agcore 发布后由 dc-management 侧执行)
- [ ] dc-management 项目 `llm_chat`(普通聊天)命令正常响应(agcore 发布后由 dc-management 侧执行)
- [ ] 序列化后反序列化 roundtrip 验证字段值不变
- [ ] `CostTracker``session.usage()` 的聚合逻辑不依赖 `total_tokens == prompt_tokens + completion_tokens` 恒等式
### 测试清单
| 测试名 | 场景 | 输入 | 断言 |
|--------|------|------|------|
| `deserialize_missing_usage_fields` | usage 存在但缺子字段 | `"usage": {"prompt_tokens": 5}` | 缺省字段默认 0 |
| `deserialize_missing_usage_key` | 整个 usage key 缺失 | 无 `usage` 字段 | `Usage::default()` |
| `deserialize_empty_usage_object` | `usage: {}` | 空对象 | 全字段默认 0/None |
| `deserialize_full_usage_with_details` | 完整 usage(含 details) | 含所有字段 | 正确解析,类型不变 |
| `deserialize_usage_missing_prompt_tokens` | 精确复现报错场景:usage 存在但缺 `prompt_tokens` | `"usage": {"completion_tokens": 5, "total_tokens": 5}` | `prompt_tokens` 默认 0 |
| `deserialize_response_body_missing_usage_key` | `OpenaiResponseBody` 上下文中 usage key 完全缺失 | 完整 `OpenaiResponseBody` JSON 无 `usage` | `usage == Usage::default()` |
| `deserialize_chat_response_missing_usage_key` | `OpenaiChatResponse` 上下文中 usage key 完全缺失 | 完整 `OpenaiChatResponse` JSON 无 `usage` | `usage == Usage::default()` |
| `deserialize_chat_response_missing_usage_fields` | `OpenaiChatResponse` 的 usage 存在但缺子字段 | 完整 `OpenaiChatResponse` JSON`usage` 仅含 `prompt_tokens` | 缺省字段默认 0 |
| `serialize_deserialize_roundtrip` | 序列化后反序列化,验证字段值不变 | 完整 `Usage` 结构体 | roundtrip 后字段值一致 |
## 8. 回滚方案
逐个 revert 三个文件中的 `#[serde(default)]` 行,删除对应的测试用例。回滚后功能恢复原状(无数据迁移、无配置变更)。
## 9. 非目标
- 不修改 `Usage` 字段类型(保持 `u32`,不改为 `Option<u32>`
- 不修改流式路径(`PartialUsage` / `ResponseSseMeta` 的 usage 捕获问题单独处理)
- 不修改其他 provider`Anthropic` / `Ollama`,它们有自己的反序列化逻辑)
- 不处理 `"usage": null` 边界(未观察到,留 `ponytail:` 注释)
## 10. 风险评估
| 风险 | 影响 | 可能性 | 应对方向 |
|------|------|--------|---------|
| `"usage": null` 反序列化失败 | 崩溃 | 低 | 未观察到;留 ponytail 注释 |
| 零值 Usage 掩盖 API 异常 | 计费数据不全 | 低 | `CostTracker::add` 可加 `warn!` 日志 |
| `prompt + completion ≠ total` | 聚合语义不一致 | 低 | `PartialUsage` 已有同样行为,接受 |
| 下游依赖升级后反序列化行为变化 | 无 | 极低 | 类型不变、字段名不变 |
## 11. 术语表
| 术语 | 定义 | 说明 |
|------|------|------|
| blocking 路径 | `LlmProvider::chat()` 非流式调用 | 单次 HTTP 请求,完整 JSON 响应后一次性解析 |
| streaming 路径 | `LlmProvider::chat_stream()` 流式调用 | 通过 SSE 逐事件推送,usage 可选 |
| `#[serde(default)]` | serde 属性宏 | 反序列化时缺失字段使用类型的 `Default` 实现填充 |
## 12. 历史版本
| 版本 | 日期 | 变更说明 |
|------|------|---------|
| v1 | 2026-07-27 | 初始版本,基于 PRD 1 推演 |
@@ -0,0 +1,222 @@
# 模板编译器 UTF-8 编码修复方案
- 状态:Approved(第 1 轮审查通过,已按审查结论修正)
- 作者:think
- 日期:2026-08-03
- 关联 PRD`design/prd/2-模板编译器UTF-8编码修复需求.md`
## 1. 背景与目标
agcore 的 `src/prompt/template.rs` 模板编译器有 6 处 `bytes[i] as char`(将 UTF-8 字节逐字节强转为 Unicode 码点,等价 Latin-1 解码),导致含中文等多字节字符的模板经 `compile → render` 后产生 mojibake 乱码,且乱码会原样发送给 LLM。下游 dc-management 的 system 提示词(采集策略 validator / collector)已全部乱码,属静默失败——模型在容忍乱码的情况下继续工作,但提示词中的规则约束已被破坏。
目标(对齐 PRD §1、§4 v1):
- 统一修复 6 处逐字节强转,非 ASCII 文本逐字符正确保留
- 补充回归测试(模板编译器首次补测试)
- 版本 0.3.6 → 0.3.7patch 发布
- 公开 API 不变、ASCII 模板行为零变化
## 2. 需求推演概要
### 2.1 需求拆解
v1 必做三项:6 处修复(含 parse_tag 内标签内容)、回归测试、版本号更新。v2 无。
非目标:不重构语法、不加新功能、不改公开 API、不修改其他模块(全库已扫描确认无同类风险点)、不涉及下游回切决策。裸闭合标签(顶层出现 `{{/xxx}}`)的静默截断行为(template.rs L248-249 既有 `tag.starts_with("/") => break`)不在本次修复范围,后续单独评估。
### 2.2 边界识别
- 模板语法标签(`{{``}}``#if``#each``#raw`)均为 ASCII,字节比较判断语法安全,保持不变
- 非 ASCII 文本必须逐字符保留(修复目标是编译输出与模板原文一致)
- ASCII 模板行为零变化是硬约束(向后兼容,需回归测试保障)
### 2.3 关键假设
1. **UTF-8 结构性保证**:多字节字符的 continuation bytes 恒在 0x800xBF,首字节 ≥ 0xC0,而 `{`=0x7B、`}`=0x7D、`#`=0x23——任何多字节字符的任何字节都不可能等于语法字符,故 `bytes[i]` 字节比较永远不会在多字节字符内部误命中
2. **字符边界不变量**:循环中 i 起始为 0(边界);`{{`/`}}` 检测命中后 `end = i + 2``{`/`}` 各 1 字节,保持边界);字符推进按 `len_utf8()`(保持边界)。因此 `template[i..]` 切片不会 panic
3. 6 处 `as char` 是全库唯一编码风险点(grep `as char|char::from` 确认仅 template.rs 6 处)
4. 模板文件经 `include_str!` 加载有编译期 UTF-8 校验,编码问题不可能存在;日志/发送链路无转码——问题仅存在于模板编译器的内存字符串处理
## 3. 当前问题分析
### 3.1 根因
6 处 `bytes[i] as char` 明细:
| 行号 | 函数 | 破坏内容 |
|------|------|---------|
| 255 | `compile_fragments` literal 分支 | 模板纯文本(主要破坏点) |
| 275 | `parse_tag` | `{{ 标签 }}` 内部内容(当前 ASCII 变量名未触发,中文变量名同样损坏) |
| 324 | `parse_block` else_body | `#if` else 分支块体 |
| 326 | `parse_block` body | `#if` 分支块体 |
| 364 | `parse_each_block` body | `#each` 块体 |
| 389 | `parse_raw_block` content | `#raw` 块内容 |
中文每字 3 字节(emoji 4 字节)被拆成多个 Latin-1 字符(0xE4→ä、0xBD→½),产生 mojibake。
### 3.2 关键代码结构观察
- `parse_block` / `parse_each_block` / `parse_raw_block` 遇到 `{{` 标签时用 `template[i..end]` 原样字符串切片推回(该路径天然保留 UTF-8),只有逐字节累积路径被破坏
- `parse_tag` 当前签名 `fn parse_tag(bytes: &[u8], start: usize)` 只收字节切片,无法按字符边界推进,需要改签名
- 4 个调用点:223compile_fragments/ 296parse_block/ 344parse_each_block/ 380parse_raw_block),且宿主函数均已持有 `template: &str` 参数,调用点改换为纯机械替换
- `bytes` 局部变量在各函数中仍被 `{{` 判断使用,不可删除
## 4. 架构决策记录
### ADR-1:采用「字节索引 + 字符边界推进」(方案 A)
| 方案 | 描述 | 结论 |
|------|------|------|
| A. 字节索引 + 字符推进(采纳) | 保持 `bytes[i]` ASCII 判断,字符累积改 `template[i..].chars().next()` + `len_utf8()` 推进 | ✅ 改动最小,标签检测、`template[i..end]` 切片、递归编译逻辑零变动,正确性有结构性论证 |
| B. 整体重构 chars 迭代器(否决) | 编译器是「索引 + 原样子串回填」混合模型,迭代器消费性导致 4 个函数的位置换算全部重写、嵌套 depth 管理重做 | ❌ 回归风险远高于收益 |
| C. 手写 UTF-8 长度表(否决) | 避免 chars() 解码 | ❌ 引入手写 0xC0/0xE0/0xF0 分支,标准库更可靠,性能差异可忽略 |
### ADR-2`parse_tag` 签名 `&[u8]``&str`
- 理由:类型系统强制 UTF-8 保证,未来维护者想再写 `bytes[i] as char` 必须显式 `as_bytes()`,从类型层面降低复发概率;改动成本几乎为零(1 处签名 + 4 处调用点机械替换)
- 否决替代:内部 `from_utf8(bytes)` 转换以保持 `&[u8]` 签名——引入不可能触发的错误分支和 O(n) 校验,语义绕
### ADR-3:加 `debug_assert!(template.is_char_boundary(i))`
- 零成本(仅 debug 构建生效)故障信号,防未来索引推进逻辑回归
## 5. 设计方案
### 5.1 修复模式(6 处统一)
保持 `bytes[i]` 对 ASCII 语法字符判断不变;字符累积分支统一改为:
```rust
} else {
debug_assert!(template.is_char_boundary(i));
match template[i..].chars().next() {
Some(ch) => {
// 推入对应 Stringliteral / content / body / else_body
i += ch.len_utf8();
}
None => return Err(PromptError::Parse("模板包含非法字符序列".to_string())),
}
}
```
要点:
- **`None` 分支语义统一为显式失败**:6 处全部返回 `Err(PromptError::Parse("模板包含非法字符序列"))`。特别地,`compile_fragments``None` 分支**绝不能**写 `break`——顶层循环 break 后会以 `Ok(fragments)` 返回,静默丢弃模板剩余部分;其余 5 处 break 虽会落到函数末尾的 `Err`(非静默),但错误消息为「未闭合」不准确。统一显式 `Err` 使防御失效时可观测,且消息一致准确
- **None 分支为纯防御,结构性保证下不可达**`while i < len` + 字符边界不变量保证 `template[i..]` 非空;且 `template[i..]` 在非字符边界处切片会先 panic(str 切片要求字符边界),`chars().next()` 返回 None 实际不会发生。该分支的价值在于:未来维护者若将索引逻辑改写为宽容 API(如 `get(i..)`),防御分支仍能保证显式失败而非静默错误
- 每字符常数开销约 2–3 倍于原逐字节路径,但模板编译是低频一次性操作(register 时编译 / register_lazy 首次 render),渲染热路径走 Fragment AST 与此无关,无需优化;纯 ASCII 快速路径属于过度设计,明确不做
### 5.2 `parse_tag` 签名变更
```rust
fn parse_tag(template: &str, start: usize) -> Result<(String, usize), PromptError> {
let bytes = template.as_bytes(); // {{ 判断仍用字节比较
// ...内容累积同样按 5.1 模式按字符边界推进
}
```
- 4 个调用点(223 / 296 / 344 / 380 行):`parse_tag(bytes, i)``parse_tag(template, i)`
- 改动后 grep `parse_tag(` 复核 4 处调用点无遗漏
### 5.3 测试设计(`#[cfg(test)] mod tests` 新建于 template.rs
测试清单(对应 PRD §4 清单 + 评审增量,共 16 个用例):
| # | 测试 | 覆盖点 |
|---|------|--------|
| 1 | 纯中文模板 compile+render 与原文逐字符一致 | 主破坏路径 literal 恒等 |
| 2 | 真实故障素材:PRD §3.1 原文「你是采集策略专家,负责审查已采集的产品编码结果,决策下一轮搜索方向。」及含 ★、→、中文引号「」、全角标点的样本 | 故障现场固化回归(增量 B) |
| 3 | 中文 + `{{ var }}` 变量插值混合 | 混合渲染 |
| 4 | 中文位于 `#if` body | 块内中文 |
| 5 | 中文位于 `#if` else 分支 | 块内中文 |
| 6 | 中文位于 `#each` body | 块内中文(循环变量固定 `{{item}}`Array 用 `TemplateContext::from_json(&json!(...))` 构造) |
| 7 | 中文位于 `#raw` 内容 | 块内中文 |
| 8 | emoji 等 4 字节字符 | 4 字节字符保留 |
| 9 | 多字节字符紧邻 `{{` / `}}` 边界 | 边界解析 |
| 10 | 模板以中文结尾(EOF 边界) | 尾部边界 |
| 11 | 空模板 | 边界 |
| 12 | 中文 + 未闭合 `{{` → 返回 Err 且不 panic | 错误路径(增量 A),断言 `matches!(err, PromptError::Parse(_))`;None 防御分支结构性不可达、不单独设用例,本用例确保索引推进边界改动不引入 panic |
| 13 | 纯 ASCII 模板渲染与修复前一致 | 向后兼容回归:渲染结果等于预定义期望输出(PRD §7 要求,勿做快照对比);以 composer.rs 既有 4 个 ASCII 测试为硬基线 |
| 14 | 中文变量名 `{{ 问候 }}` | 标签内非 ASCII 内容(增量,PRD 已有) |
| 15 | 中文 `#if` 条件 | 标签内非 ASCII 内容(增量,PRD 已有) |
| 16 | 嵌套块:外层 `#if` 分支内含 `#each` + 中文文本 | 嵌套块中多字节字符正确保留(审查观察补充) |
测试写法约定(增量 D):
- 错误断言必须用 `matches!``PromptError` 只 derive 了 `Error, Debug`,无 `PartialEq``assert_eq!` 不可用——本模块首个测试文件最易踩的坑)
- `#each` 循环变量硬编码为 `item`(渲染器 `child_ctx.vars.insert("item", ...)` 固定)
- Array 构造沿用 composer.rs 既有模式:`TemplateContext::from_json(&serde_json::json!({...}))`
- 避免对含 `TemplateValue::Object` 的渲染输出做全等断言(HashMap 无序迭代,Display 输出不稳定)
- 测试函数签名可用 `-> Result<(), PromptError>` + `?` 或与 composer.rs 一致的 unwrap 风格
- 测试 12 的未闭合标签错误断言:`tpl.unwrap_err()` 对 compile 返回值合法可调用(要求 `PromptTemplate: Debug`Ok 时 panic),但 `PromptError``PartialEq`,拿 err 后无法 `assert_eq!` 比对变体;建议直接 `assert!(PromptTemplate::compile("中文{{未闭合").is_err())``matches!(tpl.unwrap_err(), PromptError::Parse(_))`
### 5.4 版本号
- `Cargo.toml` version 0.3.6 → 0.3.7
- 若仓库提交 Cargo.lock,确认 lock 中 agcore 条目随 `cargo build` 更新并一并提交
## 6. 实施步骤
| 步骤 | 操作 | 验证 |
|------|------|------|
| 1 | 跑基线:`cargo test` | composer.rs 4 个 ASCII 模板测试通过(硬基线) |
| 2 | 改 `parse_tag` 签名 + 4 调用点 | `cargo build` 通过 |
| 3 | 6 处字符推进修复 + debug_assert | `grep -n "as char\|char::from" src/` 无结果 |
| 4 | 新增 `#[cfg(test)] mod tests`16 个用例) | `cargo test` 全绿 |
| 5 | Cargo.toml 0.3.6 → 0.3.7 + Cargo.lock 同步 | 版本号确认 |
| 6 | `cargo clippy` | 无新增警告 |
| 7 | 步骤 2–6 的改动合并为一次提交(避免「可编译但含缺陷」的中间态单独提交),打 tag v0.3.7(发布动作由维护者执行) | tag 描述含验证指引 |
## 7. 验证标准
对齐 PRD §7 验收标准,并补充增量 A:
- [ ] 中文模板经 compile + render 后与原文逐字符一致(含纯文本、变量插值混合、真实故障素材)
- [ ] 中文位于 #if / #each / #raw 块体内时正确保留
- [ ] emoji 等 4 字节字符正确保留
- [ ] 多字节字符紧邻 {{ / }} 边界时解析正确
- [ ] 模板以中文结尾、空模板等边界场景不 panic
- [ ] 中文 + 未闭合标签 → 返回 Err 且不 panic(增量 A
- [ ] 纯 ASCII 模板渲染结果与修复前完全一致(composer.rs 4 个测试为基线全部通过,新测试断言等于预定义期望输出)
- [ ] 6 处逐字节强转全部消除(grep `as char|char::from``src/` 无结果)
- [ ] cargo test 全部通过(含新增 16 个测试)
- [ ] cargo clippy 无新增警告
- [ ] Cargo.toml 版本为 0.3.7
## 8. 发布计划
| 阶段 | 范围 | 说明 |
|------|------|------|
| v1 | 6 处修复 + 16 个回归测试 + 版本号 0.3.7 | 本方案范围 |
| 发布 | 打 tag v0.3.7,推送 origin | 实际发布动作由维护者执行 |
| 发布说明 | `git tag -a v0.3.7` tag 描述(增量 E): | 不更新 CHANGELOG |
| 下游升级 | dc-management 升级 agcore 引用至 v0.3.7 | 下游自行评估是否回切模板链路 |
tag 描述建议内容(增量 E,写入发布说明):
1. 修复说明:模板编译器 UTF-8 编码修复(6 处逐字节强转改为按字符边界推进),中文模板经 compile+render 后与原文一致
2. 最小验证代码(可复制):
```rust
use agcore::prompt::{PromptTemplate, TemplateContext};
let tpl = PromptTemplate::compile("你是助手:{{msg}}").unwrap();
let mut ctx = TemplateContext::new();
ctx.insert("msg", "你好");
assert_eq!(tpl.render(&ctx).unwrap(), "你是助手:你好");
```
3. 行为变更提示:修复后 LLM 将从「读乱码提示词」变为「读正确提示词」,此前被破坏的规则约束(编码格式、品牌限定等)恢复生效,模型输出可能明显变化——升级后建议跑一轮真实采集对比验证后再正式切换
## 9. 回滚方案
- 代码回滚:`git revert` 该修复 commit,回到 0.3.6 行为
- 下游回退:dc-management 将 agcore 依赖回退至 0.3.6 即可恢复原行为(无 API 变更,回退无迁移成本)
- 注意:回滚即恢复乱码行为,仅作为应急手段;正确路径是验证后继续使用 0.3.7
- 版本号冲突:`git revert` 后 Cargo.toml 回到 0.3.6,若应急后需重新发布,将撞上已发布的 v0.3.6/v0.3.7 tag,应升级至 0.3.8
## 10. 历史版本
| 版本 | 日期 | 变更说明 |
|------|------|---------|
| v1 | 2026-08-03 | 首版方案(基于 PRD v1 + 双顾问评审增量) |
| v2 | 2026-08-03 | 第 1 轮审查结论修正:None 分支统一显式 Err(消除静默截断)、测试 13 断言措辞对齐 PRD、新增嵌套块测试 16、回滚补版本号冲突说明、grep 范围标注 src/ |
| v3 | 2026-08-03 | 第 2 轮复审修正:§6 步骤 4 用例数 15→16 全文统一、unwrap_err 断言表述订正、§7 第 7 条补「预定义期望输出」对齐 PRD、§2.1 非目标补充裸闭合标签静默截断不在范围 |
View File
@@ -0,0 +1,181 @@
# PRD:Usage 字段反序列化容错
**状态**Draft
**作者**proposal
**日期**2026-07-27
## 1. 核心目标
`agcore::Usage` 结构体的必填 token 字段添加 `#[serde(default)]` 反序列化容错,使 OpenAI Response API 的 blocking(非流式)调用在火山平台返回不完整的 `usage` 字段时不再崩溃。
## 2. 目标用户与场景
| 用户角色 | 使用场景 | 核心诉求 |
|---------|---------|---------|
| dc-management 使用者 | AI 探索(产品信息采集) | 点击「AI 探索」后能正常返回结果,不因 usage 缺失而报错 |
| dc-management 使用者 | LLM 聊天 | 普通聊天功能正常响应 |
| dc-management 使用者 | 要素值正则化(Phase 2) | 数据归一化流程不因 usage 解析失败而中断 |
| agcore 下游 crate | 任何使用 `agcore::llm` 且后端可能不返回完整 usage 的项目 | 反序列化鲁棒性提升 |
## 3. 问题描述
### 3.1 报错信息
dc-management 项目调用火山引擎 Responses API 时,非流式路径报错:
```
OpenAI Response 响应解析失败 error=missing field 'prompt_tokens' at line 1 column 1581
```
### 3.2 影响面
经排查,以下功能被阻断(4/5 的 LLM 命令受影响):
| 功能 | 命令 | 状态 |
|------|------|------|
| AI 探索(产品采集) | `llm_collect``workflow.rs` `.chat()` | ❌ 不可用 |
| 普通聊天 | `llm_chat``llm.rs` `.chat()` | ❌ 不可用 |
| 要素值正则化 | `llm_normalize_values``normalizer.rs` `.chat()` | ❌ 不可用 |
| 数据验证 | `validator.rs` `.chat()` | ❌ 不可用 |
| 连接验证 | `verify_llm_connection``chat_stream()` | ✅ 幸免(流式) |
### 3.3 根因
- **`llm/types/usage.rs:4-12`**`Usage` 结构体的 `prompt_tokens``completion_tokens``total_tokens` 定义为 **必填 `u32`**,非 `Option<u32>`
- **`llm/provider/openai_response.rs:207`**`OpenaiResponseBody` 内嵌 `usage: Usage`,也是必填
- 火山平台的 Responses API 返回的 `usage` 对象中,三个必填 token 子字段**缺失**(`usage` 键存在但子字段不完整),触发了 serde 反序列化的 `missing field` 错误
- 流式路径不受影响:其 `PartialUsage` 所有字段均为 `Option<u32>`,缺失时通过 `unwrap_or(0)` 兜底
### 3.4 已知线索
- dc-management 的 `verify_llm_connection`(连接验证函数)**已预见到此问题**,注释明确说明特意使用流式来规避(`llm.rs:368-371`
- 这是已知的设计约束——流式已容错但 blocking 路径未同步加固
## 4. 功能清单
### v1 必做
- **`Usage` 结构体加 struct-level `#[serde(default)]`**`src/llm/types/usage.rs:3`):
```diff
- #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
+ #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
+ #[serde(default)]
pub struct Usage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub completion_tokens_details: Option<CompletionTokensDetails>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_tokens_details: Option<PromptTokensDetails>,
}
```
- 作用:JSON 中缺失 `Usage` 的**任何**子字段时,自动使用 `Default::default()` 取值
- 三个 token 字段:缺失时默认 `0`
- 两个 `Option` 详情字段(`completion_tokens_details` / `prompt_tokens_details`):缺失时默认 `None`
- **类型全部保持不动**,不改为 `Option`,不丢失语义
- 等效代码行数:1 行
- **`OpenaiResponseBody.usage``#[serde(default)]`**`src/llm/provider/openai_response.rs:207`):
```diff
pub(crate) struct OpenaiResponseBody {
pub id: String,
pub model: String,
pub output: Vec<ResponseOutputItem>,
+ #[serde(default)]
pub usage: Usage,
pub status: String,
}
```
- 作用:整个 `usage` 键在 JSON 中完全缺失时,自动默认 `Usage::default()`
- 等效代码行数:1 行
**变更合计:2 行 serde 属性宏,零逻辑变更,零类型变更。**
### v1 可选
- 无(上述两项即可完整修复)
### v2 考虑
- 无——struct-level `#[serde(default)]` 已覆盖所有子字段,`completion_tokens_details` / `prompt_tokens_details``#[serde(default)]` 需求已在 v1 中一并解决
- 流式路径的 `ResponseSseMeta` 不包含 `usage` 字段(`src/llm/provider/openai_response.rs:335-340`),`response.completed` 事件的 usage 信息未被捕获。这是一个独立的缺陷,与当前问题无关,建议单独处理
### 非目标
- 不修改 `Usage` 字段类型(保持 `u32`,不改为 `Option<u32>`
- 不修改流式路径(`PartialUsage` 的 usage 捕获问题单独处理)
- 不修改其他 provider`OpenaiChat` / `Anthropic`),它们有自己的反序列化逻辑
## 5. 边界与假设
| 边界 / 假设 | 来源 | 说明 |
|-------------|------|------|
| `Usage` 已 derive `Default` | 代码确认 | `#[serde(default)]` 直接使用 `Default` 实现(全字段 `0` |
| 缺失字段默认 0 对所有消费者安全 | 代码审查 | 所有消费方(`CostTracker::add``MessageResponse` 使用处)都在做 `saturating_add``0` 是安全值。流式路径的 `PartialUsage.into_usage()` 已经 `unwrap_or(0)`,行为一致 |
| 序列化行为不受影响 | serde 语义 | `#[serde(default)]` 仅在反序列化缺失字段时生效,不影响序列化输出 |
| 报错提示的 `missing field` 确认是子字段缺失而非整个 usage 缺失 | 错误消息分析 | 错误消息 `missing field 'prompt_tokens'` 说明 JSON 路径 `usage.prompt_tokens` 不存在即键 `usage` 存在但子字段缺失,因此 `#[serde(default)]` 加在子字段上是必要条件 |
## 6. 术语表
| 术语 | 定义 | 说明 |
|------|------|------|
| blocking 路径 | `LlmProvider::chat()` 非流式调用 | 发送单个 HTTP 请求,等待完整 JSON 响应后一次性解析 |
| streaming 路径 | `LlmProvider::chat_stream()` 流式调用 | 通过 SSE 逐事件推送,usage 可选 |
| `#[serde(default)]` | serde 属性宏 | 反序列化时如果字段缺失,使用类型的 `Default` 实现填充 |
| Responses API | OpenAI 标准 `POST /responses` 协议 | 区别于 Chat Completions`POST /chat/completions` |
## 7. 验收标准
- [ ] `Usage` 结构体三个 token 字段在 JSON 缺失时不报错,默认值为 `0`
- [ ] `OpenaiResponseBody.usage` 键在 JSON 中完全缺失时也不报错,默认值为 `Usage::default()`
- [ ] dc-management 的 `llm_collect`AI 探索)命令正常返回结果
- [ ] dc-management 的 `llm_chat`(普通聊天)命令正常响应
- [ ] 已有序列化行为不受影响(输出 JSON 仍包含全部 token 字段)
- [ ] `cargo test` 全部通过
- [ ] `cargo clippy` 无新增警告
- [ ] 补两条反序列化测试:
- `response_api_missing_usage_fields` — mock 响应中 `usage` 对象缺失 `prompt_tokens` / `completion_tokens` / `total_tokens`,验证不报错且 usage 字段全为 0
- `response_api_missing_usage_key` — mock 响应中完全没有 `usage` 键,验证不报错且默认 `Usage::default()`
## 8. 风险评估
| 风险 | 影响 | 可能性 | 应对方向 |
|------|------|--------|---------|
| 下游 crate 升级 agcore 后依赖 usage 字段不缺失 | 无——`#[serde(default)]` 不改变已有行为 | 极低 | 类型不变、字段名不变 |
| 序列化时 `#[serde(default)]` 影响输出 | 无——`#[serde(default)]` 只影响反序列化 | 极低 | serde 明确语义 |
| 测试覆盖不足 | 当前 mock 响应均携带完整 usage,不会触发新路径 | 中 | 建议加一条 usage 缺失的测试用例 |
## 9. 发布计划(可选)
| 阶段 | 范围 | 时间 |
|------|------|------|
| v1 | 两处变更(usage.rs 3 字段 + openai_response.rs 1 字段) | 即日 |
| 发布 | 打 tag(如 `v0.3.6``v0.3.5-usage-fix`),更新 dc-management 引用 | 即日 |
## 10. 历史版本
| 版本 | 日期 | 变更说明 |
|------|------|---------|
| v1 | 2026-07-27 | 人工种子(原始) |
### 种子内容
发起方:dc-management 项目 `src/routes/products/ai-explore/` 页面「AI 探索」功能报错
报错信息:
```
OpenAI Response 响应解析失败 error=missing field 'prompt_tokens' at line 1 column 1581
```
根因:
- `src/llm/types/usage.rs``Usage` 结构的 `prompt_tokens`, `completion_tokens`, `total_tokens` 为必填 `u32`
- `src/llm/provider/openai_response.rs``OpenaiResponseBody` 内嵌 `usage: Usage`
- 火山平台 Responses API 返回的 usage 子字段缺失 → serde 反序列化失败
修复方向:
- `Usage` 三字段加 `#[serde(default)]`(缺失时默认 0
- `OpenaiResponseBody.usage``#[serde(default)]`(整个缺失时默认 Usage::default()
@@ -0,0 +1,172 @@
# PRD:模板编译器 UTF-8 编码修复
**状态**Draft
**作者**proposal
**日期**2026-08-03
## 1. 核心目标
修复 `agcore::prompt::PromptTemplate` 模板编译器(`src/prompt/template.rs`)对非 ASCII(UTF-8)文本的编码破坏问题:当前 6 处 `bytes[i] as char` 将 UTF-8 字节逐字节强转为 Unicode 码点(等价 Latin-1 解码),导致所有含中文等多字节字符的模板经 `compile → render` 后输出乱码(mojibake),且该乱码会原样发送给 LLM。本次修复使模板编译器对非 ASCII 文本逐字符正确保留,同时保持 ASCII 模板行为完全不变。
## 2. 目标用户与场景
| 用户角色 | 使用场景 | 核心诉求 |
|---------|---------|---------|
| agcore 下游 crate(如 dc-management | 使用 `PromptTemplate` 编译中文 system prompt 模板(validator / collector 采集策略) | 模板文本经编译渲染后与原文一致,不产生乱码 |
| agcore 下游 crate | 使用 `PromptTemplate``#if` / `#each` / `#raw` 块语法,块体内含中文 | 块内容逐字符正确保留 |
| agcore 维护者 | 修复后发布 patch 版本,下游可升级 | 公开 API 不变、ASCII 行为不变、有回归测试保障 |
## 3. 问题描述
### 3.1 现象
dc-management 项目的探索调试日志(`log/explore-20260803-013158915066.txt`)显示:发送给 LLM 的 system 提示词全部乱码:
```
ä½ æ¯éä¾ç¥ç¥ä¸å®¶ï¼è´è´£å®¡æ¥å·²é产åç¼ç ç»æï¼å³ç­ä¸ä¸è½®æç´¢æ¹åã
```
(原文为「你是采集策略专家,负责审查已采集的产品编码结果,决策下一轮搜索方向。」)
日志中的 user 消息与 LLM 响应均为正常中文,证明:乱码发生在**内存字符串**中(模板编译阶段),实际发送给 LLM 的请求体就是损坏文本;日志文件本身(纯 UTF-8 直写)只是如实记录了已损坏的内容。该缺陷为静默失败——模型在容忍乱码的情况下继续工作,但提示词中的规则约束(编码格式、品牌限定等)已被破坏,直接影响下游采集质量。
### 3.2 根因
`src/prompt/template.rs` 共 6 处 `bytes[i] as char`(将 UTF-8 字节逐字节强转为 Unicode 码点,等价 Latin-1 解码):
| 行号 | 位置 | 破坏内容 |
|------|------|---------|
| 255 | `compile_fragments` literal 分支 | 模板纯文本(**主要破坏点**) |
| 275 | `parse_tag` | `{{ 标签 }}` 内部内容(当前为 ASCII 变量名,未触发;若使用中文变量名同样损坏) |
| 324 | `parse_block` else_body | `#if` 分支的 else 块体 |
| 326 | `parse_block` body | `#if` 分支块体 |
| 364 | `parse_each_block` body | `#each` 块体 |
| 389 | `parse_raw_block` content | `#raw` 块内容 |
中文 UTF-8 编码每字 3 字节(emoji 等 4 字节),被拆成多个 Latin-1 字符(如 `0xE4 → ä``0xBD → ½`),产生 mojibake。所有经 `PromptTemplate::compile → render` 链路的非 ASCII 模板文本均被破坏。
### 3.3 影响面
- 所有使用 `PromptTemplate` 且模板含非 ASCII 文本的 agcore 下游项目
- dc-management 的采集策略(validator)与产品信息采集(collector)两条 system prompt 链路全部受影响
- 已确认模板文件本身为合法 UTF-8、日志写出链路无转码、发送链路无转码——问题仅存在于模板编译器
### 3.4 已知线索
- 下游使用 `include_str!` 宏加载模板文件(如 dc-management 的 `src-tauri/src/llm/prompts.rs`),该宏具有编译期 UTF-8 校验:非法编码的模板文件在编译期即报错,故模板文件编码问题不可能存在
- agcore 仓库 `src/prompt/template.rs` 当前没有任何测试(无 `#[cfg(test)]` 模块),本次为模板编译器首次补测试
- 模板语法标签(`{{ }}``#if``#each``#raw`)均为 ASCII,字节比较判断语法是安全的,无需改动
## 4. 功能清单
### v1 必做
- **统一修复 6 处 `bytes[i] as char`**`src/prompt/template.rs` 255 / 275 / 324 / 326 / 364 / 389 行):
- 保持现有 `bytes[i]` 对 ASCII 语法字符(`b'{'` / `b'}'` 等)的字节判断不变
- 字符累积改为按 UTF-8 字符边界推进:从当前位置取完整字符(如 `template[i..].chars().next()`),再按该字符的 UTF-8 长度推进索引(`i += ch.len_utf8()`
- 效果:非 ASCII 文本(中文 / emoji / 任意多字节字符)逐字符正确保留;ASCII 文本行为与修复前完全一致
- **补充回归测试**`src/prompt/template.rs` 新建 `#[cfg(test)] mod tests`):
- 纯中文模板 compile + render 后与原文逐字符一致(主破坏路径 literal)
- 中文文本 + `{{ var }}` 变量插值混合渲染正确
- 中文文本位于 `#if` / `#each` / `#raw` 块体内时正确保留
- emoji 等 4 字节字符正确保留
- 多字节字符紧邻 `{{` / `}}` 标签边界的解析正确性
- 纯 ASCII 模板渲染结果与修复前一致(向后兼容回归)
- 标签内部含非 ASCII 内容(中文变量名如 `{{ 问候 }}`、中文 `#if` 条件)的解析与渲染正确 ← PM Advisor
- **版本号更新**`Cargo.toml` version `0.3.6``0.3.7`
### v1 可选
- 无(上述三项即可完整修复)
### v2 考虑
- 无。CHANGELOG 记录暂不做(本次需求明确暂不记录),后续版本若需要可单独补充
### 非目标
- 不重构模板语法(标签体系、块结构保持现状)
- 不新增模板功能(新标签、新渲染特性)
- 不改动公开 API`PromptTemplate::compile` / `render` 签名与语义不变)
- 不修改其他模块(已扫描 `as char` / `from_utf8_lossy` 等模式,项目中无同类编码风险点)
- 不涉及下游 dc-management 是否回切模板链路的决策(由下游另行评估)
## 5. 边界与假设
| 边界 / 假设 | 来源 | 说明 |
|-------------|------|------|
| ASCII 语法判断可保留 | 语法分析 | 模板标签均为 ASCII(`{``}``#``/``>`),`bytes[i]` 字节比较对 ASCII 安全 |
| 非 ASCII 文本必须逐字符保留 | 需求确认 | 修复目标是编译输出与模板原文一致 |
| ASCII 模板行为零变化 | 需求确认 | 向后兼容是硬约束,需回归测试保障 |
| 公开 API 不变 | 需求确认 | `compile` / `render` 签名不变,下游无需改代码 |
| 修复范围 6 处统一 | 用户确认 | 「尽可能全面的修复」,包括 parse_tag 内的变量名内容 |
| 测试纳入本次范围 | 用户确认 | 用户明确要求补充测试 |
| 版本为 patch0.3.7 | 用户确认 | bug 修复走 patch 版本,尽快让下游升级 |
| CHANGELOG 暂不记录 | 用户确认 | 本次发布不更新 CHANGELOG |
## 6. 术语表
| 术语 | 定义 | 说明 |
|------|------|------|
| mojibake | 乱码 | 文本因编码解码不匹配产生的字符错乱,此处为 UTF-8 字节被按 Latin-1 逐字节解码 |
| Latin-1 | ISO-8859-1 单字节编码 | 0x000xFF 每个字节对应一个 Unicode 码点;`u8 as char` 等价于该解码 |
| `len_utf8()` | Rust 标准库方法 | 返回 `char` 的 UTF-8 编码长度(1–4 字节),用于按字符边界推进索引 |
| `PromptTemplate` | agcore 提示词模板引擎 | 支持变量插值 `{{ var }}`、条件渲染 `#if`、循环 `#each`、原始块 `#raw` |
## 7. 验收标准
- [ ] 中文模板经 `compile` + `render` 后与原文逐字符一致(含纯文本、变量插值混合)
- [ ] 中文位于 `#if` / `#each` / `#raw` 块体内时正确保留
- [ ] emoji 等 4 字节字符正确保留
- [ ] 多字节字符紧邻 `{{` / `}}` 边界时解析正确
- [ ] 纯 ASCII 模板渲染结果与修复前完全一致(以 `src/prompt/composer.rs` 既有 4 个 ASCII 编译测试为基线全部通过,新测试断言等于预定义期望输出)← PM Advisor
- [ ] 6 处逐字节强转全部消除(grep 模式 `as char|char::from` 验证)← PM Advisor
- [ ] `cargo test` 全部通过(含新增测试)
- [ ] `cargo clippy` 无新增警告
- [ ] `Cargo.toml` 版本为 0.3.7
## 8. 风险评估
| 风险 | 影响 | 可能性 | 应对方向 |
|------|------|--------|---------|
| 索引推进改动引入边界回归(模板尾部、空模板、`{{` 未闭合) | 解析错误或 panic | 中 | 新增测试覆盖边界;保持 ASCII 判断逻辑不变 |
| parse_tag 内标签内容的解码方式与 literal 不一致 | 中文变量名场景损坏残留 | 低 | 6 处统一采用同一修复模式 |
| 下游对 0.3.7 的升级未验证中文模板 | 下游继续受影响 | 中 | tag 描述中明确修复内容与最小验证步骤(编译含中文的模板对比输出)← PM Advisor |
| 未来新增模板代码时再次引入逐字节强转 | 同类 bug 复发 | 低 | 回归测试(中文断言)可捕获此类问题 |
## 9. 发布计划
| 阶段 | 范围 | 说明 |
|------|------|------|
| v1 | 6 处修复 + 回归测试 + 版本号 0.3.7 | 本 PRD 范围 |
| 发布 | 打 tag `v0.3.7`,推送 origin | 实际发布动作由维护者执行 |
| 发布说明 | 以 `git tag -a v0.3.7` 的 tag 描述作为发布说明载体(附最小验证:编译含中文的模板并对比输出)← PM Advisor | 不更新 CHANGELOGtag 描述随仓库分发 |
| 下游升级 | dc-management 升级 agcore 引用至 v0.3.7 | 下游自行评估是否回切模板链路 |
## 10. 历史版本
| 版本 | 日期 | 变更说明 |
|------|------|---------|
| v1 | 2026-08-03 | 人工种子(原始) |
### 种子内容
发起方:dc-management 项目排查探索调试日志 `log/explore-20260803-013158915066.txt`,发现发送给 LLM 的 system 提示词乱码。
原始需求描述(一字不改):
「检查一下当前日志 log 目录中保存的日志文件,似乎现在发送给LLM的提示词存在乱码?」
根因:
- `src/prompt/template.rs` 中 6 处 `bytes[i] as char`255 / 275 / 324 / 326 / 364 / 389 行)将 UTF-8 字节逐字节强转为 Unicode 码点(等价 Latin-1 解码),非 ASCII 模板文本被破坏
- 日志文件、发送链路、模板文件编码均无问题,乱码发生在内存字符串(模板编译阶段)
修复方向(用户确认):
- 6 处统一修复:保持 ASCII 语法判断不变,字符累积按 UTF-8 字符边界推进
- 补充回归测试(中文模板恒等、混合插值、块内中文、emoji、边界、ASCII 回归)
- `Cargo.toml` 0.3.6 → 0.3.7,打 tag `v0.3.7`
- CHANGELOG 暂不记录
View File
+200
View File
@@ -0,0 +1,200 @@
# AG Core Roadmap — Unsorted
> 本文件存放**尚未归到任何具体版本**的 roadmap 内容:跨版本的全局视图、面向未来的展望、风险与建议、阶段总回顾。
>
> **已分版本的内容**:请查阅
> - [`roadmap-v0.1.0.md`](./roadmap-v0.1.0.md) — Phase 04c + v0.1.0 Release
> - [`roadmap-v0.2.0.md`](./roadmap-v0.2.0.md) — Phase 512 + v0.2.0-rc.1
> - [`roadmap-v0.3.0.md`](./roadmap-v0.3.0.md) — Phase 1319(全部完成)
> - [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md) — Phase A-E 多 Agent 编排路线图
>
> 返回总入口:[`roadmap.md`](./roadmap.md)
---
## 全局愿景
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
**当前状态**v0.3.5。Phase 0-30 全部完成。v0.4.0 规划已确定,覆盖 5 个增量 Phase(A-E):Swarm 编排抽象、结果聚合、Human-in-the-loop + 用户 Steering、TokenJuice 语义压缩、自动校正。目标是从"多 Agent 基础系统"升级为"多 Agent 多职责编排系统"。
---
## 模块完整性评估
| 功能领域 | 方案状态 | 文档位置 | 实现优先级 |
|---------|---------|---------|-----------|
| LLM 调用周期 | ✅ 完整 | `specs/llm-call-lifecycle.md` | P0 |
| 提示词工程 | ✅ 完整 | `docs/4-prompt-engineering.md` | P1 |
| 工具系统 + 权限 | ✅ 完整 | `docs/5-tool-system.md` | P1 |
| 记忆检索 | ✅ 完整 | `docs/6-memory-system.md` | P2 |
| Agent 运行时(4a 胶水层) | ✅ 已实现 | `docs/7-agent-runtime.md` | P2 |
| 生命周期钩子 | ✅ 完整 | `docs/3-phase0-remaining.md` | P0LLM Cycle 扩展) |
| Provider 注册发现 | ✅ 完整 | `docs/3-phase0-remaining.md` | P0Provider 接口扩展) |
| 流式事件系统 | ✅ 完整 | `docs/3-phase0-remaining.md` | P0(流式接口前置) |
---
## v0.4.0 规划
v0.4.0 的完整规划已移入独立的 [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md),包含 5 个增量 Phase
| Phase | 内容 | 状态 |
|-------|------|------|
| **Phase A** | Swarm 编排(Star/Sequential/Hierarchical + Subgraph | 📋 待实施 |
| **Phase B** | 结果聚合 + 编排模式完善 | 📋 待实施 |
| **Phase C** | Human-in-the-loop + 用户 Steering | 📋 待实施 |
| **Phase D** | TokenJuice 语义压缩(工具结果/历史/跨 Agent) | 📋 待实施 |
| **Phase E** | 自动校正 / Reflection | 📋 待实施 |
### 未来版本(v0.5+
以下功能已从 v0.4 范围移出:
| 功能 | 说明 |
|------|------|
| Agent 自动创生 | LLM 自主决定何时派发子 agent — 设计复杂,v0.4 专注显式声明式编排 |
| 分布式 session 共享(Redis 后端) | 与编排正交,多数用户单进程即可 |
| 精确 tokenizer 计数(tiktoken-rs | 依赖引入,不在 v0.4 核心范围内 |
| 增量 Checkpoint | 存储优化,当前全量 JSON 够用 |
| 路线 BStateGraph 通用图引擎) | 预留为路线 A 的未来升级路径 |
| RL 轨迹导出 | 专项需求 |
### 明确不做(agcore 范围外)
| 功能 | 原因 |
|------|------|
| TUI / 多平台 Gateway | 应用层职责(Feishu / Telegram / Discord 桥接) |
| 配置自动加载(config/figment) | 配置来源策略应由上游应用决定,agcore 不定义配置格式 |
| 提示词自动优化 | 属于智能层,不应内建于 core 库 |
---
## 风险与建议
1. **持久化依赖**`rusqlite` + `bundled` 零外部依赖编译,但 SQLite 不适配所有场景(分布式/高并发写)。`MemoryStore` trait 的抽象层允许下游自行实现 Redis / PostgreSQL 后端
2. **ContextSlot 心智负担**`ContextSlot` 引入了一等抽象的复杂度。建议通过 `AgentBuilder` 默认创建 `"default"` slot,让简单场景无感使用
3. **向量检索规模上限**v0.3 的 `PersistentVectorStore` 全量加载到内存做余弦搜索,适合 ≤10 万条向量。超出此规模需换用专用向量库。v0.4 可以评估引入
4. **Scope 蔓延**v0.3 新增 `agent/summary` `document/` `engine/` `memory/vector_store` 模块,功能覆盖扩展到多 Agent 基础系统。始终保持 trait + reference impl 的边界,业务循环留给上层(Phase 16 已交付 `agent/summary` 摘要生产端 + `format_messages_as_text` 简洁版格式化 + 30K 字符整体截断保留最新;Phase 18 已交付 `engine/switch_agent` 热切换 + `engine/sub_agent` 调度全栈(dispatch / dispatch_all / dispatch_stream);实施后两轮审查 PASS,0 🔴 阻塞)
5. **API 稳定性**v0.3 引入 `Checkpointer``SessionManager``VectorStore` 等新公开 APIv0.2 已有的 `#[non_exhaustive]``#[deprecated]` 机制继续沿用
6. **Checkpointer 存储效率**:v0.3 使用全量 JSON 序列化存储 checkpoint,每轮对话约几百 KB。`fork` 从历史 checkpoint 创建新 session 时也会复制全量。等实际使用中发现存储瓶颈时再改为增量模式
---
## 下一步行动
1. **v0.4.0 启动**:按 [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md) 规划,从 Phase A(Swarm 编排)开始实施
2. **Phase A 实施**engine/supervisor.rs + tools/builtin.rs + Swarm::star/sequential/hierarchical
3. **示例先行**:每个 Phase 交付时同步提交对应的示例程序
4. **里程碑追踪**:以 M16-M20 为目标里程碑,逐 Phase 推进
---
**已完成 / 进行中阶段**
- ✅ Phase 0 Foundation — 全部交付物已完成
- ✅ Phase 1 Prompt Engineering — 全部交付物已完成
- ✅ Phase 2 Tool System — 全部交付物已完成
- ✅ Phase 3 Memory System — 全部交付物已完成
- ✅ Phase 4a Core Glue — 全部交付物已完成
- ✅ Phase 4b Task Execution — 全部交付物已完成
- ✅ Phase 4c Session Memory — 全部交付物已完成
- ✅ Phase 5 Warmup — ProviderConfig::from_env + OllamaProvider + `#[non_exhaustive]` 前置标记(ProviderType / StopReason / FinishReason / EvictionPolicy
- ✅ Phase 6 ToolDefinition IR — `ToolDef` 新类型 + 双向 `From` 转换 + 别名彻底移除 + `#[allow(deprecated)]` 清理(cycle/registry/mcp/agent);Anthropic 零改动;roundtrip 测试覆盖
- ✅ Phase 7 SqliteStore — `rusqlite 0.32` + WAL 模式 + `Arc<Mutex<Connection>>` + `spawn_blocking``memory/store.rs``store/{in_memory,sqlite_store}.rs` 模块化;9 个内联测试覆盖 CRUD/upsert/过滤/10×10 并发/持久化 round-trip`InMemoryStore ↔ SqliteStore` trait-box 互换兼容
- ✅ **Phase 8 MVP 集成出口** — 14 个公开枚举追加 `#[non_exhaustive]`P0 核心 IR + P0 Error + P1 其他) + `StepStatus::Completed(ChatResponse)``Completed(MessageResponse)` 迁移 + CHANGELOG v0.2.0-rc.1 + 2 个新示例(`quick_start` 60 行 + `end_to_end` 246 行),10 个离线示例全部 exit 0**v0.2.0-rc.1 标签已打**;实施后三方审查发现 6 项问题(1 🔴 + 2 🟡 + 3 💭)已全部修复
- ✅ **Phase 9 流式体验增强**`AgentSession::submit_turn_stream` 流式事件序列 + `LlmCycle::submit_with_tools_stream` spawn + mpsc 状态机 + `StreamEvent::ToolExecutionStarted`/`Completed` 新变体 + 9 单元测试 + 2 集成测试(含 `submit_turn_stream_end_to_end` 端到端 mock 验证 + `submit_turn_stream_triggers_turn_hooks` Hook 触发验证),全量 200 → 211;`CycleConfig``Clone` derive;方案文档 `docs/16-phase9-streaming-experience.md`821 行)
- ✅ **Phase 10 ContextSlot 上下文管理**`src/agent/context.rs` 新增 `ContextSlot` 核心类型(Full / Focused / Readonly 三种模式,New / Derived / Static 三种来源)+ JSON blob 批次持久化(每 slot 3-4 条 MemoryItem`slot_config` key 自恢复支持旧版本兼容);`AgentSession` 扩展 slots 字段 + 5 个管理方法(`create_slot` / `switch_slot` / `list_slots` / `derive_slot` / `delete_slot`,自动创建 `"default"` slot`delete_slot` 双重保护禁止删 default/最后一个);`submit_turn`/`finalize_turn` 改造为基于当前 slot 的增量追加写回(`cycle.messages()[input_len..]` 提取本轮新增消息,确保 Focused 模式"读时过滤"语义不丢失数据);`finalize_turn` 签名变更(新增 `new_messages_from_cycle: Vec<Message>` 参数,返回 `Result<(), AgentError>`);`agent/error.rs` 新增 3 个 Slot 错误变体(`SlotReadonly` / `SlotNotFound` / `SlotAlreadyExists`);`examples/context_slot_demo.rs` 新增分支对话示例(法律咨询入口 → 两个派生方向 → 切换 → 隔离验证 → 删除保护);方案文档 `docs/17-phase10-contextslot.md`(1227 行,含 §5 推荐方案、§6 实施建议、§9 实施计划,经过 4 轮方案/计划/实施审查 + 1 轮非阻塞建议修复);全量 211 → 254(+43 新测试),clippy 0 警告,doc 0 warning11 个离线示例全部 exit 0
- ✅ **Phase 11 测试与检索补强**`src/memory/vector.rs` 新增 `VectorRetriever` traitindex + search 抽象)+ `InMemoryVectorRetriever` 引用实现(HashMap + 全量余弦相似度扫描 + 零依赖 `dot()`),6 个内联测试覆盖 basic/empty/zero-vector/k=0/2 个并发;wiremock Provider roundtrip 测试 12 个(OpenAI 8 + Anthropic 4)覆盖请求体/header/401/429/500/529/流式 usage-only/流式错误/ToolUse/结构化错误体;`MemoryStore` 并发测试 5 个(InMemoryStore 3 + SqliteStore 2)覆盖 100 并发写、5 写+5 读混合 2 秒、15 写者容量淘汰;`openai.rs` `handle_error_response` 修复 429 retry-after 解析(5 行,与 anthropic 对齐);方案文档 `docs/18-phase11-testing-and-retrieval.md`(647 行,含 10 项架构决策 + 2 条实施偏差记录 #6 mid-stream mock 模式 + #7 retry-after 修复);全量 254 → 277+23 新测试),clippy 0 警告,doc 0 warning,并发测试 3 次稳定无 flaky
- ✅ **Phase 13 热身清理 + ContextSlot fork/merge** — 3 个旧 types 文件删除(`request.rs` 187 行 + `response.rs` 177 行 + `old_stream.rs` 45 行),所有 OpenAI wire-format 类型迁入 `provider/openai.rs` 可见性 `pub(crate)`Breaking Change:原 `agcore::llm::types::OpenaiChatRequest/Response/Chunk` 公共 re-export 路径已删除);`ChatResponse` 自 v0.1.0 标记 `#[deprecated]` 后在 Phase 13 整体删除;`ToolChoice``request.rs` 迁入 `tool.rs`(公共 `agcore::llm::types::ToolChoice` 路径不变);`ContextSlot::fork()` 派生独立子 slot`SlotSource::Derived { parent_id, strategy }` 血缘可追溯)+ `ContextSlot::merge(child, MergeStrategy)` 合入父 slot`Append` / `Replace` 两种策略,`#[non_exhaustive]` 预留扩展);`MergeStrategy` 防御性检查(self-merge / 跨 session / Readonly 目标全部阻断);`AgentSession::derive_slot` 重构复用 `fork()` 消除重复;`agent.rs` 追加 `MergeStrategy` re-export9 个 fork/merge 内联测试覆盖 happy path 与 error path`stream.rs` 简化为 module doc + `pub use` 重导出(保持 `use crate::llm::stream::StreamEvent` 路径兼容);方案文档 `docs/19-phase13-cleanup-and-fork-merge.md`640 行);全量 277 → 286+9 新测试),clippy 0 警告,doc 0 warning
- ✅ Provider IR 重构 — 统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen/Ollama 适配
- ✅ LlmCycle 简化 — IR 消息类型切换 + Phase 0 桥接层移除
- ✅ v0.1 Release — 技术债扫清、MockProvider 公开化、8 个离线示例(含 `simple_visit`)、README + 错误消息友好化、CHANGELOG 初始化
- ✅ **v0.2 规划细化完成** — 8 个增量 PhasePhase 5-12),17 个可验证 Step,覆盖 P0-P2 全部 12 项功能 + ContextSlot
- ✅ **v0.3.0 Phase 13 完成** — 技术债清理(3 旧 types 文件 + ChatResponse 删除)+ ContextSlot fork/merge9 新测试),M9 里程碑达成
- ✅ **v0.3.0 Phase 14 完成** — Document 类型(id/content/metadata/mime_type+ `RecursiveCharacterSplitter` 两阶段算法(按 separator 优先级递归分割 + 贪心合并 overlap,全部 `chars_len()` 字符级比较)+ `Embedding` traitasync + `LlmError` 复用)+ `MockEmbedding`sin-hash 零依赖伪随机 + L2 归一化)+ 19 Document 测试 + 6 Embedding 测试(含 1 个 split_multibyte_utf8_boundary CJK 边界测试);`src/document.rs`580 行)+ `src/llm/embedding.rs`183 行)+ `examples/document_demo.rs`74 行);`pub use document::Document` 在 lib.rs 重导出;CJK 分隔符(`。`/``/``)加入 `DEFAULT_SEPARATORS`;方案文档 `docs/20-phase14-document-and-embedding.md`1417 行);全量 286 → 313+27 新测试,0 失败),clippy 0 警告,doc 0 warning,零新外部依赖;M10 里程碑达成
- ✅ **v0.3.0 Phase 15 完成**`VectorStore` trait`add`/`search`/`remove`/`add_one`,返回 `(Document, f32)` 消除调用方 id→Document 维护开销)+ `InMemoryVectorStore``Mutex<HashMap>` + 余弦全量扫描 + 预计算 L2 norm 缓存)+ `PersistentVectorStore`(构造时全量加载,先写持久化后写内存,持久化失败时内存不污染重启自动恢复,`remove` 幽灵数据窗口已知)+ `RagPipeline` 组合器(ingest: split→embed→store.add / retrieve: embed→store.search`splitter: Option<RecursiveCharacterSplitter>` 灵活切换);`src/memory/vector_store.rs`(937 行,19 个内联测试覆盖 14 场景含 2 个性能基准)+ 零新外部依赖(纯 Rust `dot()` 余弦);旧 `VectorRetriever`/`InMemoryVectorRetriever` 标注 `#[deprecated(since = "0.3.0")]` 迁移路径清晰;`search_orthogonal_vectors` 返回 1 条 score≈0(文档已同步修正不过滤低分向量);方案文档 `docs/21-phase15-vector-store-persistence.md`(1570 行,经 3 轮审查 + 文档-代码一致化修复);全量 313 → 335(+22 新测试),clippy 0 警告,doc 0 warningM11 里程碑达成
- ✅ **v0.3.0 Phase 16 完成**`SummaryConfig` 配置结构体(6 个字段:`trigger_token_ratio=0.75` / `max_context_tokens=32_000` / `summary_prompt` / `debounce_turns=3` / `summary_model=None` / `max_tool_result_chars=500`,默认 `None` 沿用主模型避断裂非 OpenAI 用户)+ `AgentBuilder::summary_config(cfg)` 链式方法 + `AgentConfig.summary_config: Option<SummaryConfig>` 字段;`AgentSession` 新增 `last_summary_turn: Option<u32>` 字段(首次不受防抖约束,`should_summarize``Option` 哨兵实现)+ `maybe_summarize(current_turn)` 内联检查点(OnTurnEnd 之后 / `turn_index` 之前,对称 `submit_turn` / `finalize_turn` 两个入口,流式路径 `saturating_sub(1)` 修正)+ 关联函数 `generate_summary`(构造独立 `LlmCycle``submit_messages``vec![Message::user_text(prompt)]``max_tokens=1024`,空消息守卫直接返回空串)+ 公开 API `get_conversation_summary()``src/agent/summary.rs`~240 行,含 8 个 SummaryConfig/`format_messages_as_text` 内联测试——默认值/空输入/系统用户助理/ToolResult(含 `tool_call_id`/工具调用/Unicode 安全截断/整体 30K 截断保留最新;有效字符数截断多字节安全,droptest 验证保留尾部消息)+ `src/agent/session.rs` 注入 10 个摘要集成测试(默认值不触发 / 超阈值触发 / 防抖阻止重复 / SessionMemory 写入 / Full 模式不注入 / 失败不阻断主流程 / 流式路径触发 / 默认配置零影响 / **Focused `summary_override` 写入正向验证** / **空消息不调用 LLM** / **巨型 `max_context_tokens` 永不触发**);`format_messages_as_text` 简洁版消息格式化(`[Tool: name]` + `Tool Result [id]:` + ToolResult 字符级 `chars().take(max_tool_result_chars)` 截断 + 整段 30K 总长度截断从头部保留最新);所有错误静默(失败用 `tracing::error!`,成功用 `tracing::info!(turn, summary_len)`);`MergeStrategy` 注释中过时 "Summarize 指向"与 `context.rs:78` "v0.3 将支持 Hook 驱动" 过时注释在实施时同步移除/更新;方案文档 `docs/22-phase16-summary-auto-generation.md`(471 行),实施后**两轮审查 PASS**:第一轮 PM/SA 审查 11 项问题修复 + 第二轮实施审查 9 项问题修复(🔴 `generate_summary` 空消息 bug + 🟡 W4 流式路径防抖 + 🟡 W2 模型硬编码 + 🟡 W5 Full 模式无谓 save + 🟡 W3 30K 截断 + 🟡 W6 成功无日志 + 🟡 W1/W7 测试补全 + 💭 注释同步);零新外部依赖;全量 335 → **353**(+18 新测试,含二次审查增补 4 个),clippy 0 警告,doc 0 warning`quick_start` 示例正常 exit 0;**M12 里程碑达成** + 第二轮审查门禁 PASS
- ✅ **v0.3.0 Phase 17 完成** — 新建 `src/engine/` 模块(5 文件:`mod.rs`/`error.rs`/`snapshot.rs`/`checkpointer.rs`/`session_manager.rs`),实现 **SessionManager**10 个公开方法:`create`/`create_child`/`get`/`recover`/`replace`/`children`/`parent`/`destroy`/`submit_turn`/`submit_turn_stream`/`finalize_turn_stream`,内部 `RwLock<HashMap>` + `Arc<tokio::sync::Mutex<AgentSession>>` + `Checkpointer` 组合)和 **Checkpointer**5 个公开方法:`checkpoint`/`rollback_load`/`list_checkpoints`/`delete_all`/`latest_snapshot`);`SessionSnapshot` 独立 struct 避开 `Arc<dyn Agent>` 不可序列化,配套 `SessionMemoryEntry` 保留 metadata/created_at`AgentSession` 扩展三段式快照(`to_snapshot` async 读 MemoryStore + `from_snapshot` 纯同步构造 + `restore_memory` &mut self async 写回持久层);`SessionMemory` 新增 `list_entries()``set_with_meta()` 方法(恢复时保留完整 entry 数据);存储 key 风格统一为 `session:{id}:meta` / `ckpt:{id}:{ckpt_id}`(与 `slot_data:` 风格一致);`EngineError` 6 个变体(含 `Memory(#[from] MemoryError)` 透传 + `Agent(#[from] AgentError)`);`CkptMeta``created_at_nanos` 字段确保同秒内精确降序排序;ckpt_id 用纳秒+单调计数器生成(零外部依赖,ponytail);session_id 用纳秒+计数器自动生成(统一策略,UUID v4 备选);自动 checkpoint 失败 `tracing::error!` 不阻断主流程(不提供强持久化保证);流式 checkpoint 仅在 `finalize_turn_stream` 创建(不留半成品污染);孤儿策略:`destroy()` 不递归删除子 session,父被销毁后 `parent()` 返回 `Ok(None)`3 处 derive 改动(`CostTracker` + `ContextSlot` + `MergeStrategy` 加 serde`CostTracker` 额外加 `Clone`);`SessionManager::recover` + `replace` 内部自动 `restore_memory` 写回持久层;零新外部依赖;方案文档 `docs/23-phase17-agent-execution-engine.md`(775 行,经两轮 PM+SA 审查 + 实施后第三轮 PM+SA+Code Reviewer 三方联合审查),实施后**两轮审查门禁 PASS**:第一轮修复 6 🔴 + 第二轮修复 2 🔴(to_snapshot 同步→async + Roadmap 同步)+ 实施后修复 8 个 🟡(restore_memory metadata/created_at 完整恢复 + &mut self 签名 + 死代码清理 + 3 个边界测试 + tracing 补全 + 文档语义统一 + 示例 rollback 一致性 assert);15 个 `SessionManager` 内联测试(CRUD/recover/replace/树形/孤儿/auto_checkpoint on-off+ 6 个 `Checkpointer` 内联测试(roundtrip/不存在的 ckpt/同秒降序/delete_all 幂等/latest/隔离)+ 1 个 `snapshot_deserialize_with_minimal_fields` 序列化兼容测试;全量 353 → **374**+21 新测试),clippy 0 警告,doc 0 warning`engine_demo` 示例端到端演示 create→submit_turn→checkpoint→rollback→replace→destroy 全链路并验证 rollback 一致性;**M13 里程碑达成** + 两轮审查门禁 PASS
- ✅ **v0.3.0 Phase 18 完成** — 新增 `src/engine/switch.rs`222 行)实现 `SessionManager::switch_agent()` 热切换(替换 `Arc<dyn Agent>`slot 历史 / `turn_index` / `session_memory` / `cost_so_far` 全部保留,同步更新 `SessionMeta.agent_name` 到持久层,`created_at` / `parent_id` 保持原始不可变)+ 新增 `src/engine/sub_agent.rs`1071 行)实现 4 个公开方法(`dispatch` / `dispatch_all` / `dispatch_stream` 与前述 `switch_agent` 共 4 个 Phase 18 核心 API+ 3 个公开类型(`DispatchConfig` / `SubTaskResult` / `SubTaskStreamEvent`);`DispatchConfig` 4 字段(`max_concurrency=10` / `inherit_session_memory=true` / `bridge_keys=None` / `shared_namespace=None`+ 三态 `bridge_keys` 语义(`None` = 不继承 / `Some(vec![])` = 全部 / `Some(keys)` = 指定 keys+ 约定式 `shared_namespace` 子↔子共享(`shared:{prefix}:{key}`)不触发自动注入;`dispatch` 流程:`create_child``inherit_session_memory`(快照语义)→ `submit_turn` → 返回 `SubTaskResult``dispatch_all` `tokio::sync::Semaphore` 并发控制 + `Vec<Result<...>>` 部分成功语义按输入顺序 indexed 收集;`dispatch_stream` `unbounded_channel` + spawn task 消息重建 + `finalize_turn` 后台落库(明确不参与 `auto_checkpoint` 防重复);`SubTaskStreamEvent` 事件序列:`ChildCreated``Stream(StreamEvent) × N``Completed(SubTaskResult)``Error { child_id, error }``EngineError` 新增 `DispatchFailed(#[source] String)` 变体 + `CostTracker``From<Usage>` 转换;`save_session_meta` / `load_session_meta``pub(crate)``switch.rs` 调用;4 个端到端示例:`agent_switch_demo`115 行)+ `sub_agent_dispatch_demo`141 行)+ `bridge_keys_demo`197 行)+ `dispatch_stream_demo`121 行)全部 exit 017 个内联测试(4 switch + 5 dispatch + 4 dispatch_all + 4 dispatch_stream);零新外部依赖;方案文档 `docs/24-phase18-agent-switch-and-dispatch.md`700 行);全量 374 → **391**(+17 新测试,0 失败),clippy 0 警告,doc 0 warning**M14 里程碑达成**
- ✅ **v0.3.0 Phase 19 完成** — 知识图谱 + 双通道检索,详见 `docs/25-phase19-knowledge-graph-and-retrieval.md`;全量 391 → **427 passed / 0 failed**(+36 新测试);**M15 里程碑达成**
- ✅ **v0.3.2 Phase 20-27 全部完成** — Cargo features 拆分(16 模块级 + 5 provider + 4 快捷组合),详见 `docs/roadmap-v0.3.2.md`;全量 427 → **427 passed**(不变,门控验证)
- ✅ **Phase 28-30 OpenAI Response API Provider 完成** — 独立 feature `provider-openai-response`,全量约 450 passed
- 📋 **v0.4.0 规划完成** — 5 个增量 PhaseA-E)覆盖多 Agent 编排、HITL + Steering、TokenJuice、自动校正。详见 [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md)
---
## 设计笔记
### Checkpointer 分层存储模型
> 来源:v0.4.0 规划讨论中涉及增量 Checkpoint 的技术推演。当前全量 JSON checkpoint 够用,但为未来优化预留设计方案。
#### 分层叠加模型(OverlayFS 模式)
受容器分层文件系统启发,增量 Checkpoint 可以借鉴 overlayfs 的"底层只读 + 上层可写叠加"设计:
**全量基座(只读)**
```rust
pub struct SnapshotBase {
pub checkpoint_id: String,
pub session_id: String,
pub snapshot: SessionSnapshot, // 完整 JSON 化状态
}
```
**增量层(叠加 diff**
```rust
pub struct SnapshotLayer {
pub base_checkpoint_id: String,
pub applies_to_id: String, // 在哪个 checkpoint 上叠加
pub diff: Vec<DiffOp>, // JSON Patch 操作集合
}
pub enum DiffOp {
MessageAppended { message: Message },
SlotChanged { slot_id: String, diff: serde_json::Value },
TurnIndexIncremented { from: u32, to: u32 },
CostUpdated { diff: CostTracker },
}
```
**重建路径**
```
rollback_load("session_x", 6)
→ 读取 "ckpt:{session_x}:base"(全量)
→ 读取 "ckpt:{session_x}:layer:1" ~ "ckpt:{session_x}:layer:6"
→ 依次应用 layer.1 → layer.2 → ... → layer.6
→ 得到 session_6 的状态
```
**层折叠(类似 docker squash**
```
layer.1 → layer.2 → layer.3 → layer.4 → layer.5
↓ 合并
base.ckpt'(包含 layer.1-3)→ layer.4 → layer.5
```
#### Shadow FS 模型(运行中保护)
与分层模型互补,shadow 模型适用于运行中的 session 保护而非长期存储:
```rust
// submit_turn 在 shadow session 上执行,commit 时才原子切换
let shadow = current_session.fork(); // 复用 ContextSlot::fork
let result = shadow.submit_turn(input).await;
if result.is_ok() {
current_session.commit(shadow); // 原子替换
} else {
drop(shadow); // 丢弃,当前 session 完好无损
}
```
#### 适用场景对比
| 模型 | 适合场景 | 不适合场景 |
|------|---------|-----------|
| **分层叠加(OverlayFS** | Checkpoint 链长期存储、time-travel、多版本回退 | session 较小(< 10KB/轮)时复杂度不值得 |
| **Shadow FSCoW** | 运行中 session 保护、防止 submit_turn 失败污染 | 不能替代 checkpoint 链、不支持多时间点回退 |
**触发条件**:当单 session checkpoint 超过 500KB 且频繁保存导致性能瓶颈时,考虑实现分层模型。
@@ -1,33 +1,18 @@
# AG Core Roadmap
# AG Core Roadmap — v0.1.0
> 定稿日期:2026-05-11
> 最后更新:2026-07-04v0.1 发布完成)
> 本文件聚焦 **v0.1.0 版本** 的规划与交付(Phase 04c),已于 2026-07-04 完成发布。
> 返回总入口:[`roadmap.md`](./roadmap.md)
## 愿景
## v0.1.0 愿景
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
**当前状态**Phase 0-4c 全部完成;Provider IR 重构(统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen Provider)已完成;LlmCycle 简化(IR 消息类型切换 + 桥接层移除)已完成;v0.1 发布就绪(**182 个测试通过、0 clippy 警告、7 个离线示例可运行**)。
## v0.1.0 总体范围
**总体规模**5 个主体 PhasePhase 04c+ Provider IR 重构 + LlmCycle 简化 + v0.1 Release 收尾,182 个测试全绿,clippy 0 警告,7 个离线示例全 exit 0。
---
## 模块完整性评估
| 功能领域 | 方案状态 | 文档位置 | 实现优先级 |
|---------|---------|---------|-----------|
| LLM 调用周期 | ✅ 完整 | `specs/llm-call-lifecycle.md` | P0 |
| 提示词工程 | ✅ 完整 | `docs/4-prompt-engineering.md` | P1 |
| 工具系统 + 权限 | ✅ 完整 | `docs/5-tool-system.md` | P1 |
| 记忆检索 | ✅ 完整 | `docs/6-memory-system.md` | P2 |
| Agent 运行时(4a 胶水层) | ✅ 已实现 | `docs/7-agent-runtime.md` | P2 |
| 生命周期钩子 | ✅ 完整 | `docs/3-phase0-remaining.md` | P0LLM Cycle 扩展) |
| Provider 注册发现 | ✅ 完整 | `docs/3-phase0-remaining.md` | P0Provider 接口扩展) |
| 流式事件系统 | ✅ 完整 | `docs/3-phase0-remaining.md` | P0(流式接口前置) |
---
## 分阶段 Roadmap
### Phase 0 — Foundation(基础设施)
**目标**:实现 LLM 调用周期的核心功能,作为所有上层模块的基础。
@@ -211,9 +196,6 @@ AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可
**状态**:✅ Phase 4c 全部交付物已完成
---
## 依赖关系图
```mermaid
graph BT
P0["<b>Phase 0: Foundation</b><br/>LLM Cycle<br/>ProviderRegistry<br/>HookExecutor<br/>StreamEvents<br/>Auto-compaction"]:::done
@@ -240,110 +222,6 @@ graph BT
---
## 扩展计划(v0.2+
> 以下功能在已完成的 phase 中已实现基础能力或在 Phase 4 阶段明确了边界,后续可按维度增量扩展。
> 设计参考:见 `docs/note-agent-harness-references.md`OpenClaw / Hermes / OpenHuman / OpenHarness 横向对比)。
> OpenCode 借鉴:见 `docs/note-opencode-agent-switching.md`Agent 切换 + System Prompt 拼接机制)。
### 已有扩展项(沿用)
| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 |
|-------|---------|------|--------|------|
| Prompt Optimizer | `prompt` | 提示词自动优化 | P3 | 待实现 |
| 流式接口优化 | `llm/stream` | 流式响应解析与事件化 | P0 | ✅ 已完成基础实现 |
### v0.2+ 新增扩展项
> 以下为基于 Phase 4 设计讨论确定的 v0.2+ 候选扩展方向,按维度分组。
> 标注为"v0.2 待评估"表示在 Phase 4 完成后再决定是否启动。
#### Multi-Agent / 协同
| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 |
|-------|---------|------|--------|------|
| Multi-Agent 协同(Swarm | `agent` | 子 Agent 委派、并行子任务、结果聚合 | P2 | v0.2 待评估 |
#### 技能(Skills
| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 |
|-------|---------|------|--------|------|
| Markdown 技能按需加载 | `agent` / `prompt` | 兼容 `SKILL.md` 格式(Hermes / OpenHarness 风格),按 prompt 上下文动态加载 | P2 | v0.2 待评估 |
#### 记忆(Memory
| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 |
|-------|---------|------|--------|------|
| 多通道检索(hybrid | `memory/retriever` | 在 TextOverlap 之上叠加向量检索通道 | P2 | v0.2 待评估 |
| KnowledgeGraph 深度记忆 | `memory` | 实体-关系图、`note-knowledge-graph-design.md` 已记录设计 | P3 | v0.2 待评估 |
| TokenJuice 智能压缩 | `memory` / `llm/compact` | 借鉴 OpenHuman TokenJuice,对工具结果做语义压缩而非字节截断 | P3 | v0.2 待评估 |
#### 交互层(TUI / Gateway
| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 |
|-------|---------|------|--------|------|
| TUI / 多平台 Gateway | 应用层 | OpenClaw / Hermes 风格的消息平台桥接(Feishu / Telegram / Discord 等) | P3 | v0.2+ 应用层 |
#### 训练基础设施
| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 |
|-------|---------|------|--------|------|
| RL 轨迹导出 | `agent` | ShareGPT 格式轨迹、Atropos 集成(Hermes 风格) | P3 | v0.3+ 探索 |
#### 安全治理
| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 |
|-------|---------|------|--------|------|
| Human-in-the-loop 审批 | `agent` / `tools/permission` | 高危工具执行前的异步审批回调(OpenHarness `permission_prompt` 模式) | P2 | v0.2 待评估 |
#### 流式 / 实时
| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 |
|-------|---------|------|--------|------|
| 流式 `submit_turn` | `agent/session` | Phase 4 v1 只暴露非流式 `submit_turn()`v0.2 包装 `LlmCycle::submit_stream` 暴露流式入口 | P2 | v0.2 待评估 |
#### Agent 切换 / Prompt 动态(OpenCode 借鉴)
| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 |
|-------|---------|------|--------|------|
| Agent 身份切换(角色轮换) | `agent` | 借鉴 OpenCode Tab 键切换 build/plan:同一 `AgentSession` 持有可热替换的 `Agent` 引用,切换时不重置消息历史,在末尾追加 `synthetic: true` 的状态变更消息。详见 `docs/note-opencode-agent-switching.md` §4 | P2 | v0.2 待评估 |
| System Prompt 多层动态拼接 | `agent/session` | 借鉴 OpenCode `request.ts:58-66`:拆分 `base_prompt + agent_prompt + env_context` 三层,`AgentSession::submit_turn` 每轮重算(不缓存),便于按 agent 类型动态切换 | P2 | v0.2 待评估 |
| **多 Context 切换** | `agent` | **Phase 4c 的 SessionMemory 数据结构已预留信息桥接通道,v0.2+ 在其上包装 `ContextManager` 实现完整的多 context 切换:创建/销毁/切换 context、通过 SessionMemory 桥接关键信息。详见 `docs/note-context-switch-design.md`** | P2 | v0.2 待评估 |
---
## 风险与建议
1. **Phase 0 已完成**:LLM 调用周期基础设施已全部实现,可以支撑后续模块开发
2. **并行可能性**Phase 0 和 Phase 1 可并行开展(无相互依赖),可加速早期交付
3. **MCP 协议复杂性**MCP 涉及协议握手、session 管理、长期连接,建议预留充足时间调研协议细节
4. **Scope 蔓延风险**:当前 specs 只有 1 份文档,建议每个模块上线前都产出对应 spec,避免边实现边设计
5. **Phase 4 抽象化边界**AG Core 定位为"支持库"而非"Agent 产品"Phase 44a/4b/4c)需严格控制范围——只暴露 trait + 最小 reference impl,业务循环(多轮 turn 编排、对话记忆自动回写、Task 拆解策略)留给上层应用。`SessionMemory`(Phase 4c)提供信息桥接通道但不实现 context 切换逻辑。多 context 切换管理延后至 v0.2+。详细设计决策见 `docs/7-agent-runtime.md`
6. **参考项目语言差异**OpenClaw / Hermes / OpenHarness 均为 Python/TypeScript 实现,OpenHuman 虽是 Rust + Tauri 但定位是桌面应用。借鉴时**只取架构模式**,不照搬具体实现(如 Pydantic 工具校验、SQLite Memory Tree、Node+Python 双进程等)
---
## 下一步行动
1. **Phase 4c 已完成**Phase 4a + 4b + 4c 已交付(116 测试通过,0 clippy 警告)。可启动 v0.2+ 扩展评估(如多 Context 切换、Multi-Agent 协同等)
2. **Context 切换备忘**`docs/note-context-switch-design.md` 记录了多 context 切换方案讨论,作为 v0.2+ 扩展项的输入
3. **参考项目调研沉淀**:已完成 OpenClaw / Hermes / OpenHuman / OpenHarness 横向调研,结果沉淀至 `docs/note-agent-harness-references.md`,作为 v0.2+ 扩展项的输入
4. **Phase 3 备用设计就绪**`docs/note-knowledge-graph-design.md` 记录了 KnowledgeGraph、高级评分、RecallBased 淘汰等设计,v0.2+ 记忆扩展可直接参考
**已完成 / 进行中阶段**
- ✅ Phase 0 Foundation — 全部交付物已完成
- ✅ Phase 1 Prompt Engineering — 全部交付物已完成
- ✅ Phase 2 Tool System — 全部交付物已完成
- ✅ Phase 3 Memory System — 全部交付物已完成
- ✅ Phase 4a Core Glue — 全部交付物已完成
- ✅ Phase 4b Task Execution — 全部交付物已完成
- ✅ Phase 4c Session Memory — 全部交付物已完成
- ✅ Provider IR 重构 — 统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen 适配(方案:`docs/10-llm-provider-refinement.md``docs/10a-phase0-types-and-trait.md``docs/10b-phase1-provider-adaptation.md`
- ✅ LlmCycle 简化 — IR 消息类型切换 + Phase 0 桥接层移除(方案:`docs/10c-phase2-llm-cycle-simplify.md`
- ✅ v0.1 Release — 技术债扫清、MockProvider 公开化、7 个离线示例、README + 错误消息友好化、Roadmap 同步、CHANGELOG 初始化(计划:`docs/11-v0.1-release-plan.md`
---
## v0.1 发布里程碑(2026-07-04
**质量基线**
+378
View File
@@ -0,0 +1,378 @@
# AG Core Roadmap — v0.2.0
> 本文件聚焦 **v0.2.0 版本** 的规划与交付(Phase 5–12)。已打 `v0.2.0-rc.1` 标签。
> 返回总入口:[`roadmap.md`](./roadmap.md)
## v0.2.0 愿景
从"LLM 调用工具箱"升级为"生产可用的 Agent 服务"。解决 Rust Agent 工具箱从"能跑"到"能被人依赖"的鸿沟——持久化、配置层、上下文管理三大块补齐后,开发者可在 30 分钟内写出生产可用的 Agent 服务。
## v0.2.0 总体范围
**总体规模**8 个增量 PhasePhase 512),17 个可验证 Step,约 2000+ 行新增代码,测试 182 → 277+。
---
## v0.2.0 — 生产就绪(Production-Ready Core
**目标**:解决 Rust Agent 工具箱从"能跑"到"能被人依赖"的鸿沟。持久化、配置层、上下文管理三大块补齐后,开发者可在 30 分钟内写出生产可用的 Agent 服务。
**总体规模**8 个增量 PhasePhase 5-12),17 个可验证 Step。
### 功能清单
#### P0 — 必须交付
| # | 功能 | 模块 | 方案要点 |
|---|------|------|---------|
| 1 | SqliteStore | `memory` | `rusqlite` + `bundled` feature`MemoryStore` 的 SQLite 实现,进程重启数据不丢 |
| 2 | ProviderConfig 扩展 + `from_env()` | `llm` | 补全 `timeout_secs` / `max_retries` 字段;`AG_LLM_*` 环境变量辅助函数 |
| 3 | ToolDefinition IR 正式化 | `tools` | 移除 deprecated OpenAI wire 格式,替换为自定义 `ToolDef` 结构体 |
| 4 | API 稳定性管理 | `*` | 公开枚举加 `#[non_exhaustive]`CHANGELOG 记录 Breaking Changes;废弃 API 用 `#[deprecated]` 标记 |
| 5 | Quick Start + 端到端示例 | `examples/` | 30 行 `main.rs` 快速开始;一个"SQLite 持久化 + Provider + 工具调用 + 多轮对话"的可运行示例(`cargo run --example` |
#### P1 — 重要但不阻塞
| # | 功能 | 模块 | 方案要点 |
|---|------|------|---------|
| 6 | Ollama Provider | `llm/provider` | OpenAI Compat,本地 LLM 支持,实现量极小 |
| 7 | VectorRetriever trait | `memory` | 语义检索 trait 抽象(`index` / `search`),不绑定后端实现 |
| 8 | 流式 `submit_turn_stream` | `agent` | `AgentSession` 新增 `submit_turn_stream()`,返回 `Stream<Item = StreamEvent>` |
| 9 | 测试补强 | `*` | wiremock Provider roundtrip 测试;多线程并发写入 MemoryStore 测试 |
#### P2 — 有时间再做
| # | 功能 | 模块 | 备注 |
|---|------|------|------|
| 10 | MCP StreamableHttp | `tools` | 当前仅预留枚举变体 |
| 11 | Gemini Provider | `llm/provider` | 协议差异大,实现成本较高 |
| 12 | 文件系统 MemoryStore 后端 | `memory` | JSON/JSONL 轻量持久化 |
### ContextSlot 上下文管理
**模块归属**`src/llm/context.rs`(与 `compact.rs` 同级)
**核心概念**`ContextSlot` 是一段带策略配置的消息列表,以 `slot_id` 为 namespace 独立持久化到 `MemoryStore`。支持三种模式、三种来源和派生关联(记录 `parent_id`)。
**核心类型**
```rust
pub struct ContextSlot { id, session_id, config, messages, store }
pub struct SlotConfig { mode: SlotMode, source: SlotSource, budget, compact }
pub enum SlotMode {
Full, // 完整对话历史
Focused(FocusedConfig), // 聚焦:保持 LLM 注意力
Readonly, // 只读参考上下文
}
pub struct FocusedConfig { keep_system, recent_turns, inject_summary }
pub enum SlotSource {
New, // 全新空槽,独立持久化
Derived { parent_id, strategy: DeriveStrategy }, // 从父 slot 派生
Static(Vec<Message>), // 预置消息,不持久化
}
pub enum DeriveStrategy { Full, Focused(FocusedConfig) }
pub struct ContextBudget { system, history, tools, tool_results, reserve }
```
**持久化 Key 命名**
- `slot_msg:{session_id}:{slot_id}:{index}` → 消息内容
- `slot_meta:{session_id}:{slot_id}``SlotMeta`(含 `parent_id`
- `slot_rel:{session_id}:{child_id}:parent``"{parent_id}"`
**`AgentSession` 扩展**
- `create_slot(id, config)` — 创建新 slot
- `switch_slot(id)` — 切换当前 slot
- `list_slots()` — 列出所有 slot
- `derive_slot(id, parent_id, strategy)` — 从父 slot 派生
**与 `ConversationMemory` 的关系**:保留不废除。`ConversationMemory` 继续服务传统对话场景。
**v0.2 不做**
- ❌ `slot.fork()` / `merge()` — 分支方法推迟到 v0.3+
- ❌ `inject_summary` 自动生成 — v0.2 仅消费端(从 `SessionMemory` 读取),生成在 v0.3+
- ❌ 血缘关系图遍历 — 只存 `parent_id`,不做查询
**依赖**Phase 0MemoryStore trait)、Phase 3MemoryStore 持久化)
**优先级**P1
---
### v0.2.0 实施计划 — 8 个增量 Phase
> **编号说明**Phase 5-12 接续 v0.1 的 Phase 0-4c,按开发顺序排列。
#### Phase 5: 热身准备(Warmup
**目标**:快速交付三个互不依赖的独立改动,建立交付节奏。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **5.1** ✅ | `ProviderConfig` 扩展:补 `timeout_secs`(def=30) + `max_retries`(def=3);新增 `ProviderConfig::from_env(prefix)` | `llm/provider.rs` + 各 Provider `new()` 构造函数 | `cargo test` + `from_env()` 单元测试 |
| **5.2** ✅ | `OllamaProvider`:基于 `GenericOpenaiProvider` 包装,改 base_url 为 `http://localhost:11434``ProviderType` 新增 `Ollama` | `llm/provider/provider.rs` + `llm/provider/ollama.rs`(新增) | `cargo build` — 纯类型级验证 |
| **5.3** ✅ | 公开枚举 `#[non_exhaustive]` 前置标记:`ProviderType` / `StopReason` / `FinishReason` / `EvictionPolicy` / `SlotMode`(预置) | 各枚举定义处 | 编译通过 + `cargo clippy` 0 警告 |
**实际新增**2026-07-05 commit `98dfe6c`):
- 新增文件 1 个(`llm/provider/ollama.rs`72 行)
- 修改文件 2 个(`llm/provider.rs``from_env` + `Default` + 4 个字段;`memory/store.rs` EvictionPolicy 加 `#[non_exhaustive]`
- `ProviderType::Ollama` 变体 + `FromStr` 解析("ollama" → Ollama
- `OllamaProvider::new(base_url, api_key, model, timeout_secs)` + `with_client()` 构造函数
- `ProviderConfig::from_env(prefix)` 解析 `{prefix}_API_KEY` / `{prefix}_BASE_URL` / `{prefix}_MODEL` 环境变量
- 全量测试 182 → 190+8phase 5 新增 from_env 与 Ollama 相关单测)
- clippy 0 警告
**依赖**:无(三个 Step 互不冲突)
**优先级**P05.1+ P15.2+ P0 前置(5.3
**为何独立成 Phase**:三个改动零文件重叠,可以并行推进。它们是后续所有 Phase 的"门把手"——先做完热身再进入核心工作。
**状态**:✅ Phase 5 全部交付物已完成
---
#### Phase 6: ToolDefinition IR 正式化
**目标**:引入 `ToolDef` 新类型,替换已标记 `#[deprecated]``ToolDefinition``OpenaiToolDefinition` 别名)。
**这是 v0.2 技术风险最高的 Phase**,影响 4 个模块约 8 个文件。通过 5 个 Step 逐文件切割确保每步可编译。
| Step | 内容 | 验证标准 |
|------|------|---------|
| **6.1** ✅ | `types/tool.rs` 新增 `ToolDef` 结构体 + `From<ToolDef> for OpenaiToolDefinition` + 反向 `From` | 单元测试 roundtrip |
| **6.2** ✅ | `types/mod.rs` 切别名 `pub type ToolDefinition = ToolDef``MessageRequest.tools``Vec<ToolDef>` | `cargo build` 编译断点 |
| **6.3** ✅ | `cycle.rs` 4 个方法签名 + `registry.rs` `definitions()` 签名更新 | `cargo build` |
| **6.4** ✅ | Provider 适配层(openai.rs / anthropic.rs / openai_compat.rs):`build_request()` 内做 `ToolDef → wire-format` 转换 | `cargo test` 每个 provider 测试 |
| **6.5** ✅ | 所有测试/示例中 `ToolDefinition``ToolDef` 修复;移除旧 `#[deprecated]` alias | `cargo test --all-targets` 全绿 |
**边界切割技巧**
- Step 6.1 → 6.2 之间是安全 checkpoint:新类型存在但旧代码照常编译
- Provider 层不改序列化逻辑,只加一层 `From` 转换
- 当前代码中 `ToolDefinition` 已是 `#[deprecated(since = "0.1.0")]`,用户已有迁移预期
**依赖**:无(仅与 Phase 5.3 有枚举兼容关系)
**优先级**P0
**实际新增**2026-07-05 commit `4cf5918` / `9da9b83` / `b187519`,详见 `docs/13-phase6-tooldef-ir.md`):
- 修改文件 8 个:`llm/types/tool.rs``llm/types/mod.rs``llm/types/request_v2.rs``llm/cycle.rs``llm/provider/openai.rs``tools/registry.rs``tools/mcp.rs``agent/agent.rs`
- `ToolDef` IRname / description / parameters,无 `strict`)新增于 `types/tool.rs`,配套双向 `From` 转换
- `OpenaiToolDefinition` 降级为 `#[doc(hidden)]`,仅供 OpenAI 适配层内部消费
- `MessageRequest.tools` 切换为 `Vec<ToolDef>`
- `ToolDefinition` 别名最终完全移除(直接使用 `ToolDef`
- 4 处 `#[allow(deprecated)]` 抑制点全部清理(cycle/registry/mcp/agent);残留 `#[allow(deprecated)]` 均与 `ChatResponse` / `with_system_prompt` 等其他弃用项无关
- 新增 roundtrip 测试 `message_request_with_tools_roundtrip`(断言 `strict` 字段不泄漏到序列化输出)
- Anthropic 适配层字段名一致零改动;openai_compat/ollama 委托 `GenericOpenaiProvider` 零改动
- 全量测试 190 → 191+1Phase 6 新增 roundtrip);clippy 0 警告
**状态**:✅ Phase 6 全部交付物已完成
---
#### Phase 7: SqliteStore 持久化
**目标**:实现 `MemoryStore` 的 SQLite 后端,进程重启数据不丢。
**与 Phase 6 无耦合,可重叠开发。**
| Step | 内容 | 文件 | 验证标准 |
|------|------|-----|---------|
| **7.1** ✅ | 新增 `memory/store/sqlite.rs``Mutex<Connection>` + `spawn_blocking`,实现 `save/get/delete/list` + prefix 过滤 | `memory/store/sqlite.rs` + `Cargo.toml`add `rusqlite` | 单元测试 CRUD + prefix 查询 |
| **7.2** ✅ | WAL 模式 + 并发安全 + 集成测试(`tokio::spawn` 10 个并发 task | `sqlite.rs` 扩展 | 并发写入 100 轮无 race |
**设计决策**
- 用 `Mutex<Connection>` 而非连接池(ponytail:一个连接够用就不加 r2d2)
- WAL 模式:`PRAGMA journal_mode=WAL` 解决读写锁
**依赖**`MemoryStore` traitv0.1 Phase 3 已就绪)
**优先级**P0
**实际新增**2026-07-05 commit `13edacd` / `c8a91f6` / `c82af60`,详见 `docs/14-phase7-sqlite-store.md`):
- 方案文档:`docs/14-phase7-sqlite-store.md`526 行,Phase 7 设计推演与权衡记录)
- 结构重组:`src/memory/store.rs` 单体文件 → `src/memory/store/{mod.rs(in_memory.rs, sqlite_store.rs)}` 模块目录;外部导入路径 `crate::memory::store::MemoryStore` 不变
- 新增文件 2 个:`src/memory/store/sqlite_store.rs`545 行 SqliteStore 实现 + 9 个内联测试)、`src/memory/store/in_memory.rs`266 行,结构搬移)
- 核心实现要点:
- `Arc<Mutex<Connection>>` 串行化所有 IO`spawn_blocking` 卸载到阻塞线程池
- WAL 模式 + `synchronous=NORMAL` + `busy_timeout=5s` + `wal_autocheckpoint=1000`
- `PRAGMA user_version` schema 版本管理(`INITIAL_USER_VERSION = 1`
- `created_at` 归一化为 UTC 的 RFC 3339 TEXT,字典序等价时间序
- 错误精细映射:`SqliteFailure` / `InvalidQuery``InvalidInput``FromSqlConversionFailure``Serialization`;其他 → `Storage`
- 9 个内联测试覆盖:CRUD、upsert、prefix / since / offset+limit 过滤、10 写者 × 10 次并发写入、持久化 round-trip(重启连接不丢数据)、`InMemoryStore ↔ SqliteStore` trait-box 互换兼容性
- 依赖:`rusqlite = { version = "0.32", features = ["bundled"] }``time` 增补 `parsing` / `formatting` / `macros` features`dev-dependencies` 新增 `tempfile = "3"`
- 全量测试 191 → 200+9Phase 7 新增 SqliteStore 单测);clippy 0 警告
**状态**:✅ Phase 7 全部交付物已完成
---
#### Phase 8: MVP 集成出口(v0.2.0-rc.1 候选)
**目标**:P0 五项全部交付。开发者 clone 仓库后 10 分钟跑起持久化 Agent。
| Step | 内容 | 验证标准 |
|------|------|---------|
| **8.1** ✅ | API 稳定性扫尾:`#[non_exhaustive]` × 14 公开枚举 + `StepStatus::Completed``MessageResponse` + CHANGELOG v0.2.0-rc.1 + Cargo.toml 0.2.0-rc.1 | `cargo doc --no-deps` 0 warning + 零 deprecated warning |
| **8.2** ✅ | Quick Start 示例(57 行 `main.rs`):MockProvider + EchoTool + submit_turn 真实工具调用 | `cargo run --example quick_start` exit 0 |
| **8.3** ✅ | 端到端示例:SqliteStore + AG_LLM_* from_env 自动检测 + 3 工具 + 3 轮对话 + 持久化跨连接验证 | `cargo run --example end_to_end`Mock fallback,无需 API key|
**Phase 8 全部完成**。**已打 `v0.2.0-rc.1` 标签**。
**实际新增**2026-07-057 commits):
- `feat(core)` —— 14 个公开枚举追加 `#[non_exhaustive]`P0 核心 IR + P0 Error + P1 其他)
- `refactor(agent)` —— `StepStatus::Completed(ChatResponse)``Completed(MessageResponse)` + `task_agent_demo.rs` 清理 3 处废弃类型
- `docs` —— CHANGELOG v0.2.0-rc.1 条目 + Cargo.toml version 0.1.0 → 0.2.0-rc.1 + README 示例列表 7 → 10
- `test(core)` —— 验证 commit 1-3 零回归(test 200 passed + clippy 0 警告 + doc 0 warning
- `feat(examples)` —— `quick_start.rs`60 行)+ `end_to_end.rs`246 行)
- `docs(roadmap)` —— 标记 Phase 8 全部完成 + M4 里程碑 ✅
- `fix(examples)` —— 实施后 PM/SA/Code Reviewer 三方审查发现 6 项问题(🔴 CalcTool 除零 panic + 🟡 drop 注释准确性 + 🟡 EchoTool 错误处理 + 💭 断言一致性 + 💭 工具两端语义统一 + 💭 trailing newline),全部修复
**依赖**Phase 5ProviderConfig from_env+ Phase 6ToolDef+ Phase 7SqliteStore
**优先级**P0
**状态**:✅ Phase 8 全部交付物已完成
---
#### Phase 9: 流式体验增强
**目标**:Agent 会话支持流式输出,开发者看到实时 token。
| Step | 内容 | 文件 | 验证标准 |
|------|------|-----|---------|
| **9.1** ✅ | `AgentSession::submit_turn_stream(user_input) -> impl Stream<Item=StreamEvent>` | `agent/session.rs` | 单元测试验证流事件序列:`TextDelta → ... → MessageComplete` |
**注意**:tool 自动循环时流中插入 `ToolExecutionStarted` 事件,用户端 UI 显示"正在调用工具..."。
**依赖**Phase 6ToolDef+ `LlmProvider.chat_stream`v0.1 已有)
**优先级**P1
**实际新增**2026-07-06 commit `212cfcc`,详见 `docs/16-phase9-streaming-experience.md`):
- 方案文档:`docs/16-phase9-streaming-experience.md`(821 行,含状态机设计推演与边界情况)
- 修改文件 3 个:`src/agent/session.rs`+208,含 `submit_turn_stream` / `finalize_turn`)、`src/llm/cycle.rs`+784,含 `submit_with_tools_stream` / `run_tool_loop` spawn + mpsc 状态机)、`src/llm/types/response_v2.rs`+21,含 `StreamEvent::ToolExecutionStarted`/`Completed` 变体 + `apply_to` 元事件)
- 关键设计:`CycleConfig``Clone` derive 以支持 spawn 跨 task`finalize_turn` 手动同步状态(`submit_turn_stream` 返回流前不落库,避免半成品被 hook 误读)
- 测试:新增 9 个单元测试 + 2 个集成测试(含 `submit_turn_stream_end_to_end` 端到端 mock provider 流消费 + `submit_turn_stream_triggers_turn_hooks` Hook 触发验证),全量 200 → 211(+11,0 失败)
- clippy 0 警告
- 无新增外部依赖
**状态**:✅ Phase 9 全部交付物已完成
---
#### Phase 10: ContextSlot 上下文管理
**目标**:支持多上下文分区管理,Agent 可在不同 slot 之间切换。
| Step | 内容 | 验证标准 |
|------|------|---------|
| **10.1** ✅ | `src/agent/context.rs``ContextSlot` + `SlotConfig` / `SlotMode` / `FocusedConfig` / `SlotSource` / `DeriveStrategy` / `ContextBudget` / `SlotMeta` 核心类型 | `cargo build --all-targets` |
| **10.2** ✅ | ContextSlot 持久化:基于 `MemoryStore` trait(不绑定 SqliteStore)实现 save/load/list/delete + slot 命名空间 key 策略 + `load_messages()` Focused 读时过滤 + `append_messages()` Readonly 阻断 + colon 注入防护 | 单元测试:持久化 roundtrip / session 隔离 / Focused 边界 / delete 保护 / 派生 / load_messages() |
| **10.3** ✅ | `AgentSession` 扩展:`create_slot` / `switch_slot` / `list_slots` / `derive_slot` / `delete_slot` + `new()` 自动创建 `"default"` slot + `submit_turn`/`finalize_turn` 改造为基于当前 slot 的增量追加写回 + 新示例 `context_slot_demo` | 集成测试 + `cargo run --example context_slot_demo` exit 0 |
**如何保证简单场景无感**`AgentSession::new()` 内部检查,自动创建 `"default"` slot → `submit_turn` 默认写到 default slot。
**实际新增**2026-07-07 commit `6359422`,详见 `docs/17-phase10-contextslot.md`):
- 方案文档:`docs/17-phase10-contextslot.md`(1227 行,含 §5 推荐方案、§6 实施建议、§9 实施计划,经过 4 轮方案/计划/实施审查 + 1 轮非阻塞建议修复)
- 新增文件 3 个:`src/agent/context.rs`~430 行 ContextSlot 核心类型 + 持久化方法 + 22 个测试)、`src/agent/context.rs` 中的 `ContextSlot::filter_focused` 静态方法(被 `load_messages``derive_slot` 复用,消除代码重复)、`examples/context_slot_demo.rs`(~160 行分支对话示例:法律咨询 → 派生两个方向 → 切换 → 隔离验证 → 删除保护)
- 修改文件 3 个:`src/agent.rs`+5 行 module 声明 + re-export)、`src/agent/error.rs`+56 行:3 个新变体 `SlotReadonly`/`SlotNotFound`/`SlotAlreadyExists` + 4 个测试)、`src/agent/session.rs`+825/-197 行:slots 字段 + 6 个管理方法 + submit_turn/finalize_turn 改造 + 17 个测试)
- 关键设计:
- **模块归属**`agent/context.rs`(零新依赖方向,遵循 `agent → memory` 已有依赖)
- **持久化**JSON blob 批次存储,每 slot 3-4 条 `MemoryItem``slot_data` / `slot_meta` / `slot_config` / `slot_rel`
- **submit_turn 签名不变**:方案 A(内部 `current_slot_id` 状态),向后兼容
- **Focused 模式读时过滤**`load_messages() -> Vec<Message>`,避免 Rust 借用检查问题
- **增量追加写回**`cycle.messages()[input_len..]` 提取本轮新增消息,确保 Focused 模式数据不丢失
- **delete_slot 双重保护**:禁止删 `"default"` + 至少保留一个 slot
- **colon 注入防护**`assert_no_colon` 在 key 构造时 panic
- **错误传播**`serde_json` / `MemoryStore` 所有错误用 `?` 传播,无静默吞掉
- 验证:211 → 254 测试(+43 新测试),clippy 0 警告,doc 0 warning10 + 1 示例全部 exit 0
- finalize_turn 签名变更(破坏性):新增 `new_messages_from_cycle: Vec<Message>` 参数,返回从 `()` 改为 `Result<(), AgentError>`——影响 Phase 9 的 `submit_turn_stream_triggers_turn_hooks``submit_turn_stream_end_to_end` 2 个测试,已适配
**依赖**Phase 5`#[non_exhaustive]` 预置 SlotMode 等枚举)、Phase 7SqliteStore 推荐持久化后端;`MemoryStore` trait 即可)
**优先级**P1
**状态**:✅ Phase 10 全部交付物已完成
---
#### Phase 11: 测试与检索补强
**目标**:补全测试覆盖 + 语义检索抽象。
| Step | 内容 | 验证标准 |
|------|------|---------|
| **11.1** ✅ | `VectorRetriever` trait`index(id, embeddings)` + `search(query, k)` | 编译 + mock 测试 |
| **11.2** ✅ | wiremock Provider roundtrip 测试:模拟 OpenAI/Anthropic HTTP 端点 | `cargo test` 新增 10+ roundtrip 测试 |
| **11.3** ✅ | 并发测试补强:InMemoryStore + SqliteStore 多线程写入验证 | 跑 100 轮无 race |
**实际新增**2026-07-06 commit `71abe88` / `b4e5c7d`,详见 `docs/18-phase11-testing-and-retrieval.md`):
- 方案文档:`docs/18-phase11-testing-and-retrieval.md`647 行,含 11.1/11.2/11.3 设计 + 10 项架构决策 + 实施后补充 2 条偏差记录 #6 mid-stream mock 模式 + #7 429 retry-after 修复)
- 新增文件 1 个:`src/memory/vector.rs`237 行 — `VectorRetriever` trait + `InMemoryVectorRetriever` 引用实现 + `dot()` 零依赖 + 6 个内联测试)
- 修改文件 5 个:
- `src/memory.rs`+2 行:module 声明 + re-export
- `src/llm/provider/openai.rs`+8 wiremock 测试 + `handle_error_response` 429 retry-after 解析修复 5 行)
- `src/llm/provider/anthropic.rs`+4 wiremock 测试)
- `src/memory/store/in_memory.rs`(+3 并发测试:100 并发写、5 写+5 读混合、15 写者容量淘汰)
- `src/memory/store/sqlite_store.rs`(+2 并发测试:100 并发写、5 写+5 读混合)
- 关键设计:
- **零依赖 dot()**:手写点积/范数,零新增 crate 依赖
- **Wiremock 测试自包含**:每个测试独立 `MockServer::start()`,沿用现有模式
- **429 retry-after 修复**`openai.rs``anthropic.rs` 行为对齐(5 行代码)
- **偏差记录**:方案文档「已否决的方案 #6/#7」记录两处实施偏差,便于后续审计追溯
- 验证:254 → 277 测试(+23 个新测试),clippy 0 警告,doc 0 warning;并发测试连续 3 次运行稳定无 flaky
- **依赖**:无(与方案一致)
- **状态**:✅ Phase 11 全部交付物已完成
---
#### Phase 12: P2 锦上添花(可选)
**目标**:时间允许时按优先级交付。
| 优先级 | 功能 | 实现量估计 | 备注 |
|--------|------|-----------|------|
| **12.1** | 文件系统 MemoryStoreJSON/JSONL | ~80 行 | 最简单,适合练手 |
| **12.2** | MCP StreamableHttp 传输 | ~150 行 | 协议还在演进 |
| **12.3** | Gemini Provider | ~300 行 | 协议差异大,建议推迟到 v0.3 |
**依赖**:无(独立交付)
---
### v0.2.0 Phase 依赖关系图
```mermaid
graph BT
P5["<b>Phase 5: 热身准备</b><br/>ProviderConfig::from_env<br/>Ollama Provider<br/>#[non_exhaustive] 标记"]:::done
P6["<b>Phase 6: ToolDef IR</b><br/>Provider 无关工具定义"]:::done
P7["<b>Phase 7: SqliteStore</b><br/>rusqlite + WAL<br/>9 个内联测试<br/>持久化 round-trip"]:::done
P8["<b>Phase 8: MVP 出口</b><br/>rc.1 标签<br/>14 枚举 #[non_exhaustive]<br/>StepStatus IR 迁移<br/>quick_start + end_to_end"]:::done
P9["<b>Phase 9: 流式体验增强</b><br/>submit_turn_stream<br/>submit_with_tools_stream<br/>9 单元测试 + 2 集成测试"]:::done
P10["<b>Phase 10: ContextSlot</b><br/>ContextSlot 类型<br/>JSON blob 持久化<br/>AgentSession 集成<br/>43 个新测试"]:::done
P11["<b>Phase 11: 测试与检索补强</b><br/>VectorRetriever trait<br/>12 wiremock tests<br/>5 并发测试"]:::done
P12["Phase 12<br/>P2 锦上添花"]:::p2
P8 --> P5
P8 --> P6
P8 --> P7
P9 --> P6
P10 --> P7
P10 --> P8
P11 -.-> P7
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
classDef warmup fill:#e2e8f0,stroke:#94a3b8
classDef core fill:#fbbf24,stroke:#d97706
classDef mvp fill:#4ade80,stroke:#16a34a
classDef p1 fill:#93c5fd,stroke:#2563eb
classDef p2 fill:#c4b5fd,stroke:#7c3aed
```
---
### 关键里程碑
| 里程碑 | Phase 完成条件 | 可验证指标 | 状态 |
|--------|---------------|-----------|------|
| **M1** | Phase 5 | 热身三项完成:`from_env()` 可用 / Ollama 类型存在 / `#[non_exhaustive]` 就位 | ✅ 2026-07-05 |
| **M2** | Phase 6 | `ToolDef` 全量切换,`cargo test --all-targets` 全绿 | ✅ 2026-07-05 |
| **M3** | Phase 7 | SqliteStore CRUD + 并发测试通过,进程重启数据不丢 | ✅ 2026-07-05 |
| **M4** | **Phase 8 (rc.1)** | P0 五项全部交付,`cargo run --example quick_start` 跑通 | ✅ 2026-07-05 |
| **M5** | Phase 9 | `submit_turn_stream` 流式事件序列验证通过 | ✅ 2026-07-06 |
| **M6** | Phase 10 | ContextSlot 创建/切换/派生集成测试通过 | ✅ 2026-07-07 |
| **M7** | Phase 11 | wiremock + 并发测试补强,测试总量 200+ | ✅ 2026-07-06 |
| **M8** | Phase 12(可选) | P2 功能按需交付 | ⏳ |
+367
View File
@@ -0,0 +1,367 @@
# AG Core Roadmap — v0.3.0
> 本文件聚焦 **v0.3.0 版本** 的规划与交付(Phase 1319)。Phase 13-19 全部完成,v0.3.0 交付完毕。
> 返回总入口:[`roadmap.md`](./roadmap.md)
## v0.3.0 愿景
从"LLM 调用工具箱"升级为"能构建多 Agent 协作、RAG、长记忆 Agent 产品的基础系统"。补齐 LangChain 7 大组件中缺失的 Document 和 VectorStore 能力,落地笔记设计中的 ContextSlot fork/merge、摘要自动生成、知识图谱,建立 engine 引擎层(会话树 + time-travel Checkpointer + SubAgent Dispatch + Agent Switch),为即将开发的多 Agent 产品提供完整基础。
## v0.3.0 总体范围
**总体规模**7 个增量 PhasePhase 1319),总新增代码约 2600 行,测试从 277 → 427。7 个 Phase 全部完成(M9-M15 已达成),v0.3.0 交付完毕。
---
## v0.3.0 — 多 Agent 基础系统(Multi-Agent Foundation
**目标**:从"LLM 调用工具箱"升级为"能构建多 Agent 协作、RAG、长记忆 Agent 产品的基础系统"。补齐 LangChain 7 大组件中缺失的 Document 和 VectorStore 能力,落地笔记设计中的 ContextSlot fork/merge、摘要自动生成、知识图谱,建立 engine 引擎层(会话树 + time-travel Checkpointer + SubAgent Dispatch + Agent Switch),为即将开发的多 Agent 产品提供完整基础。
**总体规模**7 个增量 PhasePhase 13-19),总新增代码约 2600 行,测试从 277 → 427。
### 功能清单
#### P0 — 必须交付
| # | 功能 | 模块 | 方案要点 |
|---|------|------|---------|
| 1 | 技术债清理(旧 types 文件) | `llm/types` | `request.rs` / `response.rs` / `old_stream.rs` 三个 Phase 0 旧文件删除;内部类型移入 `provider/openai.rs` |
| 2 | ContextSlot fork/merge | `agent/context` | `fork(child_id, strategy)` 别名 + `merge(child, MergeStrategy)` 三种策略(Append/Replace/Summarize |
| 3 | Document 系统 | `document/`(新模块) | `Document` 核心类型 + `RecursiveCharacterSplitter`(递归字符分割,支持 chunk_size/chunk_overlap/separators |
| 4 | Embedding 抽象 | `llm/embedding` | `Embedding` trait`embed` / `dim`+ `MockEmbedding` 测试实现 |
| 5 | 向量存储持久化 | `vector/`(新模块) | `VectorStore` trait + `InMemoryVectorStore`(读写)+ `PersistentVectorStore`SqliteStore 后端)+ `RagPipeline` 组合器 |
| 6 | 摘要自动生成 | `agent` / `llm/hooks` | `SummaryConfig` 配置 + `OnTurnEnd` Hook 自动检测 token 水位 → 调 LLM 生成摘要 → `SessionMemory::set("conversation_summary", ...)` |
| 7 | SessionManager + 会话树 | `engine/`(新模块) | Session 工厂(`create`/`create_child`+ 按 ID 恢复(`get`+ 子树管理(`children`/`parent`/`destroy_subtree`+ 元数据持久化(MemoryStore |
| 8 | Time-travel Checkpointer | `engine/checkpointer` | `checkpoint(session)` 全量序列化 + `rollback(session_id, ckpt_id)` 回滚 + `fork(session_id, ckpt_id, new_id)` 分支 + `list_checkpoints` |
| 9 | Agent Switch | `engine/switch` | 热切换 `session.agent`(替换 `Arc<dyn Agent>`),slot 历史 / turn_index / session_memory 全保留 |
| 10 | SubAgent Dispatch | `engine/sub_agent` | `dispatch(parent, sub_agent, task, config)` 单任务 + `dispatch_all(parent, tasks, config)` 并行派发(Semaphore 并发控制)+ 子 SessionMemory 继承 + `SubTaskResult` 结构化回传 |
| 11 | 知识图谱 | `memory/graph` | `KnowledgeGraph` trait`add_entity` / `add_relation` / `get_related` / `find_by_keywords`+ `InMemoryGraph` 实现 + `tag_index` 标签管理 |
| 12 | 双通道检索 | `memory/retriever` | `MemoryRetriever` 扩展为双通道(`KnowledgeStore` + `KnowledgeGraph`+ `RetrievalStrategy::Hybrid` |
### 实施计划 — 7 个增量 Phase
> **编号说明**Phase 13-19 接续 v0.2 的 Phase 5-12,按开发顺序排列。
#### Phase 13: 热身清理 + ContextSlot fork/merge
**目标**:清除 Phase 0 遗留的旧 types 文件,交付超低价功能建立节奏。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **13.1** | `OpenaiChatRequest` 移入 `provider/openai.rs``types/request.rs` 删除 | `llm/types/request.rs` + `llm/provider/openai.rs` | `cargo build --all-targets` |
| **13.2** | `OpenaiChatResponse/Chunk` 移入 `provider/openai.rs``types/response.rs` 删除 | `llm/types/response.rs` + `llm/provider/openai.rs` | `cargo build --all-targets` |
| **13.3** | `old_stream.rs` 删除 + `types/mod.rs``ChatResponse` 删除 | `llm/types/old_stream.rs` + `llm/types/mod.rs` | `cargo build` + 确认 3 个旧文件不存在 |
| **13.4** | `ToolChoice``request.rs` 搬到 `tool.rs` | `llm/types/tool.rs` + `llm/types/request_v2.rs` | `cargo test --all-targets` 全绿 |
| **13.5** | `ContextSlot::fork(child_id, strategy)` 别名 + `merge(child, MergeStrategy)` | `agent/context.rs` | 单元测试:fork → 子 slot 消息 = 父 slot 副本;merge(Append) → 消息按序追加 |
**依赖**:无
**优先级**P0
**预估规模**:约 200 行
**状态**:✅ Phase 13 全部交付物已完成(2026-07-08)
---
#### Phase 14: Document 系统 + Embedding 抽象
**目标**:补齐 LangChain 7 大组件中最明显的缺口——Document 类型和分割器。不搞 Loader 框架,用户用 `fs::read_to_string` 自行加载。
**交付物**
1. `src/document.rs` 新模块(`Document` 类型 + `RecursiveCharacterSplitter`
2. `src/llm/embedding.rs``Embedding` trait + `MockEmbedding`
**设计要点**
- `Document`id / content / metadataHashMap<String, String>/ mime_type
- `RecursiveCharacterSplitter`chunk_size(默认 1000/ chunk_overlap(默认 200/ separators`["\n\n", "\n", "。", "", "", ".", " ", ""]`,含 CJK 标点)
- 两阶段算法:按 separator 优先级递归分割(Phase 1+ 贪心合并 + overlap 滑动窗口(Phase 2
- 所有长度比较以 Unicode 字符数为单位(`chars_len()`),非字节数
- `Embedding` trait`async fn embed(&self, input: &[String]) -> Result<Vec<Vec<f32>>, LlmError>` + `fn dim()`
- 复用 `LlmError` 而非新错误类型
- `MockEmbedding`:sin-hash 零依赖伪随机向量 + L2 归一化
- 不引入 `DocumentLoader` trait(应用层职责)
**实际新增**2026-07-09 commit `d4c4d8f`,详见 `docs/20-phase14-document-and-embedding.md`):
- 新增文件 3 个:
- `src/document.rs`580 行)— `Document` 类型(4 字段 + `new`/`from_raw` 构造器,2 个 `new` 接受 `impl Into<String>` + `RecursiveCharacterSplitter`(两阶段算法:按 separator 优先级递归分割 + 贪心合并 overlap,所有长度比较 `chars_len()` 字符级,overlap 提取 `chars().rev().take().rev()` 字符级安全)+ 19 个内联测试
- `src/llm/embedding.rs`183 行)— `Embedding` traitasync + `LlmError`+ `MockEmbedding`(sin-hash:字节和+长度做种子,`f32::sin(seed + i) * 10000`,L2 归一化到单位长度,零向量防除零)+ 6 个内联测试
- `examples/document_demo.rs`(74 行)— 端到端演示 Document → RecursiveCharacterSplitter → MockEmbedding → InMemoryVectorRetriever → search
- 修改文件 2 个:
- `src/lib.rs`+3 行:`pub mod document` + `pub use document::Document` + 空行)
- `src/llm.rs`+1 行:`pub mod embedding`
- 关键设计:
- **早返回守卫**`split_text``chars_len(text) <= self.chunk_size` 时直接返回 `[text]`,避免短文本在 Phase 2 `join("")` 中丢失 separator 边界
- **`Document::new` 使用 `impl Into<String>`**:接受 `&str``String`,比规范示例的 `String` 更灵活
- **`new()` panic + `try_new()` Result 双路径**:与 Rust 库惯例一致
- **CJK 分隔符扩展**`DEFAULT_SEPARATORS` 包含 `"。"`/`""`/`""`,避免中文文本跳过句子级退化为空格分割
- **chunk_size = 0 校验**:构造器拒绝零值,避免字符级兜底死循环
- **tracing 埋点**`split()` 入口 `tracing::debug!` + 每文档/每 chunk `tracing::trace!`
- **debug_assert 溢出保护**:单文档 chunk 数 < 10000 时 `debug_assert!`
- **Metadata 键覆盖文档化**`HashMap::insert()` 静默覆盖 source_id/chunk_index/chunk_count 在 `split()` doc comment 注明
- 测试:19 个 Document 测试(含 1 个 split_multibyte_utf8_boundary CJK 边界测试)+ 6 个 Embedding 测试,全量 286 → 313(+27 新测试,但部分测试覆盖范围重叠计算约 25 个净增)
- 方案文档:`docs/20-phase14-document-and-embedding.md`(1417 行,含背景/调研/方案对比/实施计划(详细版)/3 轮审查修复记录),经过 3 轮 PM/SA 审查 + 1 轮实施后修复
- clippy 0 警告,doc 0 warning
- 无新增外部依赖(`Cargo.toml` 未修改)
**实施后调整**
- 实施发现方案算法中 Phase 1 累加器设计与测试期望冲突("para1\n\npara2" 在 chunk_size=100 时 1 chunk 更合理),简化为"按 separator 切分 + Phase 2 合并"两阶段分工
- 二次审查发现 `split_text` 缺少早返回守卫 + `current_sep_count` 虚增计数,全部已修复
**依赖**:无(纯数据结构 + 零新 crate 依赖)
**优先级**P0
**预估规模**:约 350 行
**状态**:✅ Phase 14 全部交付物已完成(2026-07-09)
---
#### Phase 15: 向量存储持久化(SqliteStore 后端)
**目标**:实现 VectorStore 持久化,让语义检索支持进程重启后数据恢复。
**设计决策**:不用 pgvector。基于已有 SqliteStore`rusqlite`)做持久化包装——运行时全量加载到 InMemory 索引做余弦搜索,写时同步到 SqliteStore。
**交付物**
1. 新增 `src/memory/vector_store.rs`937 行)—— `VectorStore` trait + `InMemoryVectorStore` + `PersistentVectorStore` + `RagPipeline`
2. `VectorStore` trait`add(&[Document], &[Vec<f32>])` 批量 / `search(query, k)` 返回 `(Document, f32)` / `remove(ids)` 幂等
3. `PersistentVectorStore`:构造时从 `MemoryStore` 全量加载已有索引;`add` 先写持久化后写内存(持久化失败时内存不污染,重启自动恢复);`search` 纯内存余弦搜索(快照 clone + 锁外计算)
4. `RagPipeline`:组合器封装 `split → embed → store.add`ingest)和 `embed → store.search`retrieve)两条管线
5. 存储格式:`vec:{namespace}:{doc_id}` → JSON `{doc_id, content, metadata, embedding, created_at}`,通过 `MemoryStore` 通用接口读写
6. `src/memory/vector.rs``VectorRetriever` trait + `InMemoryVectorRetriever` 标注 `#[deprecated(since = "0.3.0")]`,迁移路径指向 `VectorStore` / `InMemoryVectorStore`
**实际新增**2026-07-09 commit `32d886f`):
- 新增文件 1 个:`src/memory/vector_store.rs`937 行,含 19 个内联测试)
- 修改文件 3 个:`src/memory/vector.rs`+4 行 deprecated 标注);`src/memory.rs`+pub mod vector_store + 4 个 pub use re-export);`examples/document_demo.rs`(迁移到 RagPipeline ingest+retrieve
- 零新外部依赖(`Cargo.toml` 未修改)
- 全量测试 313 → 335+22Phase 15 新增 19 测试 + 部分重叠计数 22 净增);clippy 0 警告,doc 0 warning
- 设计文档:`docs/21-phase15-vector-store-persistence.md`(1570 行,经 3 轮审查 + 文档-代码不一致修复:`search_orthogonal_vectors` 返回 1 条 score≈0 而非空列表)
**依赖**Phase 14Document 类型 + Embedding trait
**优先级**P0
**预估规模**:约 400 行(实际约 937 行纯实现 + 测试)
**状态**:✅ Phase 15 全部交付物已完成
---
#### Phase 16: 摘要自动生成
**目标**:闭环长对话能力。v0.2 的 `inject_summary` 消费端(`FocusedConfig.summary_override`)已就绪,缺的是生产端。
**交付物**
1. `SummaryConfig` 结构体:`trigger_token_ratio`(默认 0.75 / `max_context_tokens`(默认 32_000/ `summary_prompt`(默认中文 `DEFAULT_SUMMARY_PROMPT``{messages}` / `debounce_turns`(默认 3 / `summary_model`(默认 `None` 沿用主模型) / `max_tool_result_chars`(默认 500
2. 在 `submit_turn` / `finalize_turn` 中 OnTurnEnd 之后插入**内联检查点**(非 Hook 扩展):`should_summarize`(水位 + 防抖,首次不受防抖约束)→ `generate_summary` 关联函数(新 `LlmCycle` + `submit_messages` 无工具调用)→ 更新 `FocusedConfig.summary_override` + `slot.save()` 持久化 + `SessionMemory::set("conversation_summary", summary)` 全局快照
3. `AgentBuilder` 扩展:`.summary_config(cfg)` 方法(不覆盖整个 `AgentConfig`
4. 公开 API`get_conversation_summary() -> Result<Option<String>, AgentError>`
5. `format_messages_as_text()` 简洁版消息格式化(System/User/Assistant + `[Tool: name]` + `Tool Result [id]:` 截断到 `max_tool_result_chars` 字符)
**设计决策**:内联于 `submit_turn` 流程而非 Hook 扩展(因为 HookContext 无法携带 `&mut self` 引用更新 slot config,且流式路径的 `finalize_turn``cycle` 已销毁)。`Option<SummaryConfig>` 的 opt-in 机制已足够提供可插拔性,不改变 Hook 系统签名。流式路径中 `submit_turn_stream` 已将 `turn_index` 提前 ++1,检查点使用 `saturating_sub(1)` 修正。
**依赖**:无(`submit_turn` 流程 + `CostTracker` + `SessionMemory` + `LlmProvider` 均已就绪)
**优先级**P0
**预估规模**:约 220 行(实际约 250 行,含 11 个内联测试)
**方案文档**`docs/22-phase16-summary-auto-generation.md`471 行,经 PM/SA 审查 11 项修复 + 实施后第二轮审查 9 项修复全部完成)
**状态**:✅ Phase 16 全部交付物已完成(含实施后 PM/SA/Code Reviewer 第二轮审查 PASS
**实施后审查修复记录**(共 9 项):
- 🔴 B1`generate_summary` 调用 `submit_messages(Vec::new(), vec![])` 发送空消息列表 → 移除 `with_messages()`,直接 `submit_messages(vec![Message::user_text(prompt)], vec![])`
- 🟡 W4`should_summarize` 使用 `self.turn_index` 而非 `current_turn` 参数 → 改签名接收 `current_turn`,流式路径防抖准确
- 🟡 W2`summary_model` 硬编码 `unwrap_or("gpt-4o")` → 改为条件赋值,`None` 时沿用 `CycleConfig::default()`
- 🟡 W5Full 模式 `slot.save()` 无谓调用 → 移入 `SlotMode::Focused` 分支内
- 🟡 W3`format_messages_as_text` 缺 30K 整体截断 → 新增 `MAX_TOTAL_CHARS=30_000` + `truncate_total_chars`,优先保留最新
- 🟡 W6:摘要成功无日志 → 添加 `tracing::info!(turn, summary_len, "摘要自动生成成功")`
- 🟡 W1/W7:缺 3 个测试 → 新增 `format_total_charset_truncation_keeps_recent` / `summary_written_to_focused_slot_config` / `summary_skipped_for_empty_messages` / `summary_not_generated_if_max_context_unreachable`
- 💭 `context.rs:78` 过时注释("v0.3 将支持 Hook 驱动")→ 更新为"v0.3 Phase 16 起 AgentBuilder 内联检查点自动生成摘要"
**第二轮审查门禁**:PASS(0 🔴 阻塞)。`cargo test --all-targets` **353 passed / 0 failed**clippy 0 警告,doc 0 warning。
---
#### Phase 17: Agent 执行引擎(会话树 + Time-travel Checkpointer
**目标**:建立 `engine/` 模块。解决 v0.2 中"session 在变量里、无法通过 ID 恢复、不支持父子关系"的空白。
**方案文档**`docs/23-phase17-agent-execution-engine.md`
**交付物**
1. `src/engine/` 新模块(`session_manager.rs` + `checkpointer.rs` + `snapshot.rs` + `error.rs`
2. `SessionManager`
- `create(agent, bundle) -> Result<String, EngineError>` — 创建根 sessionUUID v4 自动生成 ID
- `create_child(parent_id, agent) -> Result<String, EngineError>` — 创建子 session(继承父 `RuntimeBundle``Arc::clone` 共享引用)
- `get(session_id) -> Result<Arc<Mutex<AgentSession>>, EngineError>` — 按 ID 查找(仅查内存,不自动从存储恢复)
- `recover(session_id, agent, bundle) -> Result<Arc<Mutex<AgentSession>>, EngineError>` — 从存储恢复 session
- `replace(session_id, session) -> Result<(), EngineError>` — 替换已有 session 实例(用于 rollback 后切换)
- `children(parent_id)` / `parent(child_id)` — 树形查询
- `destroy(id)` — 生命周期管理(允许孤儿 session 存在,不递归删除子 session)
3. `Checkpointer`
- `checkpoint(session)` — 每个 `submit_turn` 末尾自动保存全量状态快照
- `rollback_load(session_id, ckpt_id) -> SessionSnapshot` — 读取 checkpoint JSON 为 snapshot(不重建 AgentSession
- `list_checkpoints(session_id)` — 列出 checkpoint 列表
- `delete_all(session_id)` — 清理某 session 所有 checkpoint
- `fork()` 推迟(底层可拆解为 `rollback` + `create_child`,作为高层 API 等价于约 30 行组合代码,已具备原始能力)
4. `SessionSnapshot` 独立 struct(位于 `engine/snapshot.rs`)—— 避开 `Arc<dyn Agent>` 不可序列化的限制,通过 `to_snapshot()` / `from_snapshot()` 双向转换实现 AgentSession 快照持久化
- `to_snapshot()`async,从 `SessionMemory` 读取完整数据)+ `from_snapshot()`(纯同步构造)+ `restore_memory()`async 写回持久层)
5. `EngineError` 枚举(含 `MemoryError` 透传变体 与项目既有 `AgentError` 风格一致)
**Checkpoint 存储格式**`ckpt:{session_id}:{ckpt_id}``SessionSnapshot` JSON(全量 session 状态,含所有 slot 消息列表)。Ponytail:全量 JSON 够用,等遇到存储效率问题时再改增量模式。
**会话树持久化**`session:{session_id}:meta``SessionMeta` JSON`{agent_name, parent_id, created_at, turn_count}`
**依赖**Phase 10ContextSlot 持久化 — 消息由 slot 自己管,Checkpointer 管执行状态)
**优先级**P0
**预估规模**:约 700 行(5 新增文件 + 5 修改文件)
**状态**:✅ Phase 17 全部交付物已完成
---
**实际新增**2026-07-153 commits + 实施审查修复一轮):
- **新增 5 文件(`src/engine/`**`mod.rs`19 行)+ `error.rs`43 行)+ `snapshot.rs`35 行)+ `checkpointer.rs`373 行)+ `session_manager.rs`910 行含测试)
- **修改 5 文件**
- `src/llm/types/usage.rs``CostTracker``Clone, Serialize, Deserialize`3 行)
- `src/agent/context.rs``ContextSlot` + `MergeStrategy``Serialize, Deserialize`4 行)
- `src/agent/session_memory.rs` — 新增 `list_entries()` + `set_with_meta()` 方法
- `src/agent/session.rs` — 新增 `to_snapshot()` (async) / `from_snapshot()` (sync) / `restore_memory()` (&mut self, async) / `has_pending_memory_restore()` + 公开 `bundle()` accessor
- `src/lib.rs``pub mod engine`
- **新增 1 示例**`examples/engine_demo.rs`~210 行,端到端演示 create → submit_turn → checkpoint → list → rollback_load → from_snapshot → restore_memory → replace → destroy 全链路,含 rollback 一致性 assert
- **依赖**:零新外部依赖(ponytail:ckpt_id 用纳秒+计数器生成,session_id 同理)
- **测试**353 → **374**+21 引擎内联测试:Checkpointer 6 个 + SessionManager 15 个)
- **质量基线**`cargo test --all-targets` 374 passed / 0 failed`cargo clippy --all-targets -- -D warnings` 0 警告;`cargo doc --no-deps` 0 warning`cargo run --example engine_demo` exit 0
- **关键设计决策落地**
- `SessionSnapshot` 独立 struct(避开 `Arc<dyn Agent>` 不可序列化)
- `to_snapshot` async + `from_snapshot` 纯同步 + `restore_memory` async 三段式分离
- `session_memory_data` 改用 `HashMap<String, SessionMemoryEntry>`(保留 metadata/created_at
- `EngineError::Memory(#[from] MemoryError)` 透传变体
- `EngineManager` 锁契约:所有写操作先 HashMap 再 I/O(或反之,destroy 反向)
- 自动 checkpoint 失败 `tracing::error!` 不阻断主流程(不提供强持久化保证)
- ckpt_id 时间戳+纳秒+计数器无外部依赖(`created_at_nanos` 字段确保同秒内精确排序)
- 孤儿策略:`destroy()` 不递归删除子 session;父被销毁后 `parent()` 返回 `Ok(None)`
- **实施审查通过**:经过 PM + SA + Code Reviewer 三方联合审查 → 1 轮修复 → 全部 🟡 警告关闭
- **M13 里程碑达成** — Phase 17 rc.1 标签可打(v0.3.0 第二个 Phase
---
#### Phase 18: Agent Switch + SubAgent Dispatch + Agent 间交互
**目标**:在 SessionManager 基础上,提供 Agent 角色热切换和子代理调度能力。
**交付物**
1. `engine/switch.rs``switch_agent(session_id, new_agent)`:替换 `Arc<dyn Agent>`slot 历史 / turn_index / session_memory 全保留
2. `engine/sub_agent.rs` — SubAgent Dispatch 核心:
- `DispatchConfig``max_concurrency`(默认 10/ `inherit_session_memory`(默认 true/ `bridge_keys`
- `dispatch(parent_id, sub_agent, task, config) -> SubTaskResult`:创建子 session → 继承父 SessionMemory → `submit_turn` → 返回结构化结果
- `dispatch_stream(parent_id, sub_agent, task, config) -> SubTaskStream`:流式版
- `dispatch_all(parent_id, tasks, config) -> Vec<SubTaskResult>`:并行派发,`tokio::sync::Semaphore` 控制并发数
3. `SubTaskResult``child_id` / `response` / `usage` / `summary` + `child_memory(sm)` 读取子 SessionMemory
**Agent 间交互三层级**
- 父→子:继承 SessionMemory 快照 + `bridge_keys` 指定 key 强制注入 system prompt
- 子→父:`SubTaskResult` 结构化回传 + `SessionMemory["result_summary"]` 结论摘要
- 子↔子(间接):通过公共 `MemoryStore` namespace`shared:{parent_session_id}`)共享数据
**依赖**Phase 17SessionManager + 会话树)
**优先级**P0
**预估规模**:约 500 行
**实际新增**2026-07-15 commit `46de111`,详见 `docs/24-phase18-agent-switch-and-dispatch.md`):
- 方案文档:`docs/24-phase18-agent-switch-and-dispatch.md`700 行,含 Agent Switch 与 SubAgent Dispatch 的设计推演)
- 新增文件 2 个:
- `src/engine/switch.rs`222 行)— `SessionManager::switch_agent()` 热切换:替换 `Arc<dyn Agent>`slot 历史 / turn_index / session_memory / cost_so_far 全部保留,同步更新 `SessionMeta.agent_name` 到持久层
- `src/engine/sub_agent.rs`1071 行)— SubAgent 调度完整实现:4 个公开方法 + 3 个公开类型
- 修改文件 4 个:
- `src/engine/mod.rs`+5 行:`pub mod switch; pub mod sub_agent;` + `pub use sub_agent::{DispatchConfig, SubTaskResult, SubTaskStreamEvent};`
- `src/engine/error.rs`+1 变体:`DispatchFailed(#[source] String)`
- `src/engine/session_manager.rs`+2 处可见性:`save_session_meta` / `load_session_meta``pub(crate)``switch.rs` 使用)
- `src/llm/types/usage.rs`+`From<Usage>` 实现供 `SubTaskResult.usage` 字段构造)
- 新增 4 个示例:
- `examples/agent_switch_demo.rs`(115 行)— Agent 热切换演示
- `examples/sub_agent_dispatch_demo.rs`141 行)— dispatch / dispatch_all 并行派发演示
- `examples/bridge_keys_demo.rs`197 行)— bridge_keys 过滤的 SessionMemory 继承演示
- `examples/dispatch_stream_demo.rs`121 行)— dispatch_stream 流式派发演示
- 关键设计:
- **`switch_agent` 锁契约**:先 `get` session → 锁 `Mutex` 替换 agent 并读取 turn_index → 释放 Mutex → 读/写 `SessionMeta`(无锁 IO),最大限度减少锁竞争
- **`SessionMeta` 保留原则**:切换 `agent_name` 字段,但 `created_at` / `parent_id` 保留原始(血缘不可变)
- **`switch_agent` 不自动 checkpoint**:与 `auto_checkpoint` 语义一致(仅 `submit_turn` / `finalize_turn` 触发),避免每次角色切换产生冗余 checkpoint
- **`inherit_session_memory` 快照语义**:捕获调用时刻的父 session_memory 快照,子 session 写回后即使父被并发写入也不传播(防止非确定性结果)
- **`bridge_keys` 三态语义**`None` = 不继承任何(安全默认)/ `Some(vec![])` = 继承全部 / `Some(keys)` = 仅继承指定 key
- **`shared_namespace` 约定式共享**:纯约定字段,不触发自动注入逻辑,子 agent 显式 `session.set_session_data("shared:{prefix}:{key}", value)` 写入
- **`dispatch_all` 部分成功语义**`Vec<Result<SubTaskResult, EngineError>>` 按输入顺序 indexed 收集,task panic 通过 `DispatchFailed` 哨兵占位(不破坏顺序一致性)
- **`dispatch_stream` 后台 finalize**spawn task 内部调 `finalize_turn()` 落库,明确不参与 `auto_checkpoint`(避免与流式 checkpoint 重复)
- **`SubTaskStreamEvent` 事件序列**`ChildCreated { child_id }``Stream(StreamEvent) × N``Completed(SubTaskResult)``Error { child_id, error }`
- **`SUBTASK_NAMESPACE` 防误注入**:子 session 注入到 SessionManager 时使用 `subtask:` prefix 避免与 SessionMeta 的 `session:{id}:meta` 冲突
- 测试:+17 内联测试(4 switch + 5 dispatch + 4 dispatch_all + 4 dispatch_stream),全量 374 → **391 passed / 0 failed**+170 失败)
- 质量基线:`cargo test --all-targets` 391 passed / 0 failed`cargo clippy --all-targets -- -D warnings` 0 警告;`cargo doc --no-deps` 0 warning4 个示例全部 exit 0
- 零新外部依赖(ponytail:与 Phase 17 一致)
**状态**:✅ Phase 18 全部交付物已完成
---
#### Phase 19: 知识图谱 + 双通道检索
**目标**:落地 `docs/note-knowledge-graph-design.md` 中记录的知识图谱设计,提供实体-关系图检索能力。扩展 `MemoryRetriever` 为双通道。
**交付物**
1. `src/memory/graph.rs`(新文件):
- `GraphEntity` / `GraphRelation` / `ScoredEntity` 核心类型
- `RelationDirection` 枚举(Outgoing / Incoming / Both
- `KnowledgeGraph` trait`add_entity` / `get_entity` / `remove_entity` / `add_relation` / `remove_relation` / `get_related` / `find_by_keywords` / `find_tags` / `set_entity_tags`
- `InMemoryGraph` 实现:`HashMap<String, GraphEntity>` + `Vec<GraphRelation>` + BFS 图遍历
- `TagConstraints``max_tags_per_entity` 默认 8
2. `src/memory/retriever.rs` 扩展:
- `MemoryRetriever` 增加 `knowledge_graph` 可选字段
- `RetrievalStrategy` 枚举:`Hybrid`(默认)/ `KnowledgeOnly` / `GraphOnly`
**与 Document 系统的关系**:知识图谱提供实体级检索("这个实体和什么相关"),VectorStore 提供语义相似度检索("哪些文档最相似"),两者互补。
**依赖**MemoryStore 持久化(v0.1 Phase 3
**优先级**P0
**预估规模**:约 400 行(实际约 720 行核心 + 200 行测试)
**方案文档**`docs/25-phase19-knowledge-graph-and-retrieval.md`(652 行,经 PM/SA 双轮审查 PASS
**状态**:✅ Phase 19 全部交付物已完成(2026-07-17)
**实际新增**2026-07-17):
- 新增文件 2 个:
- `src/memory/graph.rs`~580 行)- `GraphEntity`id/name/entity_type/description/tags/properties+ `GraphRelation`(无 id 字段,`composite_key()` 派生)+ `RelationDirection``#[derive(Default)]` + `#[default]` Outgoing+ `ScoredEntity`(含 path 路径)+ `TagConstraints`max_tags_per_entity 默认 8+ `KnowledgeGraph` trait10 个 async 方法)+ `InMemoryGraph``Mutex<GraphInner>` 单一锁结构,避免嵌套锁死锁)+ BFS 图遍历(visited 防环 + 权重乘积衰减 + 多路径先到先得 + depth=0 返回空)+ 标签管理(tag_index 反向索引)+ 23 个内联测试
- `examples/knowledge_graph_demo.rs`(~140 行)- 端到端演示:构建图谱 -> BFS 遍历 -> 标签管理 -> Hybrid/GraphOnly 双通道检索
- 修改文件 3 个:
- `src/memory/retriever.rs` - `RetrievalStrategy` 枚举(Hybrid 默认 / KnowledgeOnly / GraphOnly+ `RetrievalItem` enum(统一列表,`score()` 方法)+ `RetrievalResult` 新增 `strategy` 字段(反映实际执行策略)+ `MemoryRetriever` 双通道(`with_knowledge_graph` / `with_strategy` 链式构造)+ `search_knowledge_store` / `search_graph` 私有方法 + `tokio::join!` 并行 + 旧 `ScoredItem` 标注 `#[deprecated]` + `RetrieverConfig` 新增 `graph_depth`(默认 2+ 13 个内联测试
- `src/memory.rs` - `pub mod graph` + 重导出 7 个图类型 + 更新 retriever 重导出
- `examples/knowledge_search_demo.rs` - 适配新 API`RetrievalItem` enum match + `RetrieverConfig.graph_depth`
- 关键设计:
- **`Mutex<GraphInner>` 单一锁结构** - 避免 `set_entity_tags` 嵌套锁死锁风险(审查修复)
- **`RetrievalResult.strategy` 反映实际执行策略** - graph 未注入时退化为 `KnowledgeOnly`(审查修复)
- **`GraphRelation` 无 id 字段** + `composite_key()` 派生方法
- **BFS**`visited` 防环 + 权重乘积衰减 + 多路径先到先得 + `depth=0` 返回空
- **零新外部依赖**ponytail 风格)
- 测试:391 -> **427 passed / 0 failed**+36 新测试:23 graph + 13 retriever
- 质量基线:`cargo test --all-targets` 427 passed / 0 failed`cargo clippy --all-targets -- -D warnings` 0 警告;`cargo doc --no-deps` 0 warning`cargo run --example knowledge_graph_demo` exit 0
---
### v0.3.0 Phase 依赖关系图
```mermaid
graph BT
P13["<b>Phase 13: 热身清理</b><br/>旧 types 文件删除<br/>ContextSlot fork/merge"]:::done
P14["<b>Phase 14: Document + Embedding</b><br/>Document 类型<br/>RecursiveCharacterSplitter<br/>Embedding trait"]:::done
P15["<b>Phase 15: 向量存储持久化</b><br/>VectorStore trait<br/>PersistentVectorStore<br/>RagPipeline<br/>19 新测试"]:::done
P16["<b>Phase 16: 摘要自动生成</b><br/>SummaryConfig<br/>内联检查点<br/>首次防抖跳过<br/>18 新测试"]:::done
P17["<b>Phase 17: 执行引擎</b><br/>SessionManager<br/>会话树<br/>Time-travel Checkpointer<br/>21 新测试"]:::done
P18["<b>Phase 18: 切换与调度</b><br/>Agent Switch<br/>SubAgent Dispatch<br/>dispatch_all 并发控制<br/>17 新测试"]:::done
P19["<b>Phase 19: 知识图谱</b><br/>KnowledgeGraph trait<br/>InMemoryGraph<br/>双通道检索"]:::done
P15 --> P14
P18 --> P17
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
classDef pending fill:#fbbf24,stroke:#d97706,color:#1a1a1a
```
### 关键里程碑
| 里程碑 | Phase 完成条件 | 可验证指标 | 状态 |
|--------|---------------|-----------|------|
| **M9** | Phase 13 | 旧 types 文件删除、`cargo test --all-targets` 全绿、`fork`/`merge` 测试通过 | ✅ 2026-07-08 |
| **M10** | Phase 14 | `Document` + `RecursiveCharacterSplitter` 分割结果验证、`MockEmbedding` 测试通过 | ✅ 2026-07-09 |
| **M11** | Phase 15 | `PersistentVectorStore` 持久化 roundtrip、`RagPipeline::ingest → retrieve` 端到端验证 | ✅ 2026-07-09 |
| **M12** | Phase 16 | 多轮对话后摘要自动写入 SessionMemory、派生 slot 时摘要正确注入 + 第二轮实施审查 PASS | ✅ 2026-07-10 |
| **M13** | **Phase 17 (rc.1)** | `SessionManager` 创建/recover/replace/子树/销毁集成测试通过、`Checkpointer` checkpoint/rollback/list_checkpoints 验证(`fork` 推迟,按需时引入)| ✅ 2026-07-15 |
| **M14** | Phase 18 | `switch_agent` 热切换验证(slot / turn_index / session_memory 保留)、`dispatch` / `dispatch_all` 并行派发 + Semaphore 顺序、`dispatch_stream` 流事件序列验证 | ✅ 2026-07-15 |
| **M15** | Phase 19 | `KnowledgeGraph` 实体-关系 CRUD + `get_related` BFS 验证、双通道检索 Hybrid 策略验证 | ✅ 2026-07-17 |
+476
View File
@@ -0,0 +1,476 @@
# AG Core Roadmap — v0.3.2
**状态**:✅ Phase 20-27 全部交付(v0.3.2 交付完毕)
> 本文件聚焦 **v0.3.2 版本** 的规划与交付(Phase 20–27)。
> 返回总入口:[`roadmap.md`](./roadmap.md)
> **进度更新(2026-07-19**v0.3.2 全部交付。Step 1 完成 Phase 20-25Cargo features 定义 + 依赖 optional 化 + 全模块 cfg 门控);Step 3 完成 Phase 26-27CI 测试矩阵固化 + LlmProvider trait 归属修正 + examples required-features + 文档更新)。验证矩阵 6 种组合全部通过(427/416/363/369/401/407 passed),clippy 0 警告,18 个 example 单独编译通过。
>
> **Step 3 实施中的关键调整**(超出原方案的发现):
> - `LlmProvider` trait + `ProviderCapabilities` + `ProviderFeatures``provider.rs` 移至新建的 `provider_trait.rs`,归属 `#[cfg(feature = "llm")]`(ADR-1,纯 Mock 场景不再需要 provider feature
> - `llm` feature 补充 imply `futures-util`(修复 `cycle.rs` 隐式依赖)
> - `bundle()` 方法加 `#[cfg(feature = "engine")]` 门控修复 dead_code 警告
> - `prompt_composer` / `custom_tool` 的 required-features 需额外 `llm``response_v2.rs` 依赖 `LlmError`,预存耦合)
> - cargo fmt 全量格式化(修复预存格式问题,CI format job 可通过)
>
> 实施方案见 [`docs/26-step1-phase20-cargo-features-implementation.md`](./26-step1-phase20-cargo-features-implementation.md) 和 [`docs/27-step3-phase26-ci-verification.md`](./27-step3-phase26-ci-verification.md)。
## v0.3.2 愿景
通过 Cargo features 拆分,让下游按需选择模块,跳过不需要的编译单元和重型依赖。
## v0.3.2 总体范围
**版本等级**patchv0.3.2),`default = ["full"]` 保持向后兼容,非破坏性变更。
**改造基线**v0.3.0 已交付 23,718 行 Rust 代码,66 个源文件。当前所有依赖全量编译——引用 agcore 就意味着拉入 rusqlite bundled、reqwest、tokio full 等全部重型依赖。
**改造目标**16 个 features10 模块级 + 5 provider + 1 工具)+ 4 个快捷组合。下游可只选 `chat` 组合跳过 SQLite 和 MCP 的编译,或只选 `document` 实现纯文档分割零外部依赖。
**工作性质**:纯 cfg 门控 + Cargo.toml 配置变更,不新增功能代码。
**总体规模**8 个增量 PhasePhase 2027),预计新增/修改约 330 行配置与条件编译代码。
---
## 功能清单
### 模块级 features10 个)
| Feature | 覆盖内容 | imply | 外部依赖成本 |
|---------|---------|-------|-------------|
| `document` | Document + RecursiveCharacterSplitter | — | 无 |
| `llm-types` | Message, ToolDef, Usage, ToolChoice 等 IR 类型 | — | 无(只 serde + thiserror |
| `prompt` | PromptTemplate + PromptComposer | `llm-types` | 无 |
| `llm` | Provider trait + LlmCycle + hooks + compact + embedding + mock | `llm-types` | tokio, async-stream, futures-core, futures-util, tokio-stream |
| `tools` | BaseTool + ToolRegistry | `llm-types` | futures, tokio-util, tokio |
| `tools-mcp` | McpClientStdio/StreamableHttp | `tools` | reqwest |
| `memory` | MemoryStore(InMemory) + Conversation + VectorStore(InMemory) + KnowledgeGraph + Retriever | `document` + `llm` | tokio, time(继承 llm 的依赖) |
| `memory-sqlite` | SqliteStore | `memory` | rusqlite (bundled), time |
| `agent` | Agent + Builder + Session + ContextSlot + Summary | `llm` + `tools` + `memory` | 继承下层 |
| `engine` | SessionManager + Checkpointer + SubAgent + Switch | `agent` | 继承下层 |
### Provider features5 个,各自独立)
| Feature | imply | 外部依赖 |
|---------|-------|---------|
| `provider-openai` | `llm` | reqwest + bytes + futures-util |
| `provider-anthropic` | `llm` | reqwest + bytes + futures-util |
| `provider-deepseek` | `llm` | reqwest |
| `provider-qwen` | `llm` | reqwest |
| `provider-ollama` | `llm` | reqwest |
### 工具 features1 个)
| Feature | 控制 | 依赖 |
|---------|------|------|
| `tracing-init` | `init_tracing()` 函数 | tracing-subscriber |
### 快捷组合(4 个)
| 组合 | 定义 | 场景 |
|------|------|------|
| `full`default | 全部 16 个 feature | 全栈(兼容 v0.3 |
| `light` | llm + provider-openai + tools + tools-mcp + memory + agent + engine + prompt + document | 生产常用 |
| `chat` | agent + provider-openai | 纯对话(context+session+轻量记忆,跳过 SQLiteMCP 按需加 `tools-mcp` |
| `multi` | engine + provider-openai | 多 Agent 复合(chat + subagent + switch + checkpointerMCP 按需加 `tools-mcp` |
---
## 实施计划 — 8 个增量 Phase
> **编号说明**Phase 20-27 接续 v0.3.0 的 Phase 13-19。
### 实施节奏:4 个 Step
将 8 个 Phase 合并为 4 个实施步骤,平衡变更风险与执行效率。
| Step | Phase | 内容 | 验证方式 | 预估行数 |
|------|-------|------|---------|---------|
| **Step 1** ✅ | Phase 20-25 | Cargo.toml features 定义 + 依赖 optional 化 + 全模块 cfg 门控(合并实施) | 14 条编译验证全通过 + `cargo test -F full` 427 passed | ~100 |
| **Step 2** | (已合并至 Step 1 | — | — | — |
| **Step 3** ✅ | Phase 26 | 测试矩阵验证 + 修复 cfg 遗漏 + LlmProvider trait 归属修正 + examples required-features | 6 种组合全部测试通过 + clippy 0 警告 + 18 个 example 单独编译通过 | ~80 |
| **Step 4** ✅ | Phase 27 | README + 示例标注 + 总入口同步 | review 通过 | ~100 |
**Step 1 单独成步**Cargo.toml 是基础设施变更,编译通过后打 checkpoint,后续都是纯源文件变更。
**Step 2 合并 Phase 2125**:全是 `#[cfg(feature = "...")]` 公式化插门控,按依赖顺序(底层模块 → LLM/Provider → Tools/MCP → Memory → Agent/Engine)实施,每插一个 feature 门控就验证。按子模块分批 commit 控制粒度。
---
### Phase 20: Cargo.toml 基础设施改造
**目标**:定义完整的 [features] 表,重型依赖改为 optional,建立 imply 链。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **20.1** | 定义 16 个 features + 4 个快捷组合,`default = ["full"]` | `Cargo.toml` | `cargo build --features "full"` 编译通过,行为与原版一致 |
| **20.2** | tokio / reqwest / rusqlite / tracing-subscriber 改为 optional | `Cargo.toml` | `cargo build --no-default-features` 成功(空 crate |
| **20.3** | tokio-stream / futures / futures-util / futures-core / bytes / async-stream / tokio-util / time 改为 optional | `Cargo.toml` | `cargo build --features "full"` 全量依赖正确拉取 |
| **20.4** | tokio features 拆细:从 `["full"]` 改为 `["rt", "sync", "time", "macros", "process", "io-util"]`,仅保留实际使用的子模块 | `Cargo.toml` | `cargo build --features "llm,provider-openai"` 不拉入 tokio net/http 等无关子模块 |
| **20.5** | feature imply 链配置:`prompt → llm-types``llm → llm-types``tools → llm-types``memory → document``agent → llm + tools + memory`(不含 tools-mcp),`engine → agent` | `Cargo.toml` | `cargo build --features "agent,provider-openai"` transitive 依赖自动拉取 |
**依赖**:无(Cargo.toml 独立改造)
**优先级**P0
**预估规模**:约 40 行
**状态**:✅ 已交付(2026-07-19)— features 定义 + 依赖 optional 化 + tokio features 拆细(含 `rt-multi-thread` 修正)
---
### Phase 21: 底层模块 cfg 门控注入
**目标**:为 llm-types、document、prompt 三个零/低外部依赖模块添加条件编译门控。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **21.1** | `src/lib.rs` 中所有 `pub mod` 声明加 `#[cfg(feature = "...")]` | `src/lib.rs` | `cargo build --no-default-features` 无模块引入 |
| **21.2** | llm-types 模块条件编译 + 公共类型条件导出 | `src/llm/types/` | `cargo build --no-default-features --features "llm-types"` 编译通过 |
| **21.3** | document 模块条件编译 + `pub use Document` 条件导出 | `src/document.rs` | `cargo build --no-default-features --features "document"` 编译通过 |
| **21.4** | prompt 模块条件编译 | `src/prompt.rs` | `cargo build --no-default-features --features "prompt"` 编译通过 |
**依赖**Phase 20(需 feature 定义就绪)
**优先级**P0
**预估规模**:约 30 行
**状态**:✅ 已交付(2026-07-19Step 1 合并)— `src/lib.rs` 全部 `pub mod` + `pub use Document` 门控完成
---
### Phase 22: LLM + Provider 门控注入
**目标**llm 模块整体门控 + 5 个 Provider 独立条件编译 + cycle.rs 中 ToolRegistry 引用的 `#[cfg]` 隔离。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **22.1** | llm 模块 cfg + embedding 子模块条件导出 + MockProvider 条件编译 | `src/llm.rs` | `cargo build --no-default-features --features "llm"` 编译通过 |
| **22.2** | `create_provider()` + `build_client_*` 条件编译,按 feature 分别暴露 | `src/llm/provider.rs` | 各 provider feature 单独启用 |
| **22.3** | OpenAI provider `#[cfg(feature = "provider-openai")]` | `src/llm/provider/openai.rs` | `--features "llm,provider-openai"` 编译通过;不含时不编译 |
| **22.4** | Anthropic provider 条件编译 | `src/llm/provider/anthropic.rs` | `--features "llm,provider-anthropic"` 编译通过 |
| **22.5** | DeepSeek + Qwen 共享 `openai_compat.rs``any(feature = "provider-deepseek", feature = "provider-qwen")` 条件 | `src/llm/provider/openai_compat.rs` | 各自单独编译通过 |
| **22.6** | Ollama provider 条件编译 | `src/llm/provider/ollama.rs` | `--features "llm,provider-ollama"` 编译通过 |
| **22.7** | `cycle.rs` 中 ToolRegistry 引用 + `submit_with_tools` 系列方法 `#[cfg(feature = "tools")]` | `src/llm/cycle.rs` | `--features "llm,provider-openai"` 不含 tools 编译通过 |
**依赖**Phase 20 + Phase 21
**优先级**P0
**预估规模**:约 80 行(中复杂度,cycle.rs 门控需精确隔离)
**状态**:✅ 已交付(2026-07-19Step 1 合并)— `src/llm.rs` 子模块按 llm-types/llm/provider 三类门控;`cycle.rs``ToolRegistry` import + `submit_with_tools` / `submit_with_tools_stream` / `run_tool_loop``#[cfg(feature = "tools")]`Phase 22.7 提前)
---
### Phase 23: Tools + MCP 门控注入
**目标**tools 模块整体门控 + mcp 子模块条件编译。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **23.1** | tools 模块 cfg + pub use 条件导出 | `src/tools.rs` | `--features "tools"` 编译通过;不含时不编译 |
| **23.2** | `mcp.rs` 整个文件 `#[cfg(feature = "tools-mcp")]` | `src/tools/mcp.rs` | `--features "tools"` 不含 mcp 时编译通过;加 `tools-mcp` 时引入 |
| **23.3** | ToolRegistry 中 McpClient 引用的条件导出 | `src/tools/registry.rs` | `--features "tools"` 不含 mcp 编译通过 |
**依赖**Phase 20 + Phase 21
**优先级**P0
**预估规模**:约 20 行
**状态**:✅ 已交付(2026-07-19Step 1 合并)— `src/tools.rs``pub mod mcp` + `pub use mcp::*``#[cfg(feature = "tools-mcp")]`
---
### Phase 24: Memory 门控注入
**目标**memory 模块门控 + vector_store 中 Embedding 引用隔离 + SqliteStore 可选化。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **24.1** | memory 模块 cfg + pub use 条件导出 | `src/memory.rs` | `--features "memory"` imply document 编译通过 |
| **24.2** | vector_store 中 Embedding trait 引用 `#[cfg(feature = "llm")]` | `src/memory/vector_store.rs` | `--features "memory"` 不含 `llm` 编译通过 |
| **24.3** | `sqlite_store.rs` 整个文件 `#[cfg(feature = "memory-sqlite")]` | `src/memory/store/sqlite_store.rs` | `--features "memory"` 不含 sqlite 编译通过 |
| **24.4** | `memory.rs``pub use SqliteStore` 条件导出 | `src/memory.rs` | `--features "memory-sqlite"` 正确导出 SqliteStore |
**依赖**Phase 20 + Phase 21
**优先级**P0
**预估规模**:约 30 行
**状态**:✅ 已交付(2026-07-19Step 1 合并)— Phase 24.3`sqlite_store` 模块门控)+ Phase 24.4`pub use SqliteStore` 门控)已完成;Phase 24.1memory 模块 pub use)由 `src/lib.rs``#[cfg(feature = "memory")]` 覆盖;Phase 24.2vector_store 中 Embedding 引用隔离)经 Phase 26 验证无需补充——`memory` feature imply `llm``Embedding` trait 在 `memory` 启用时一定可用
---
### Phase 25: Agent + Engine 门控注入
**目标**agent 和 engine 两个高层模块的条件编译门控。注意 agent 不再 imply tools-mcp——MCP 作为可选工具层由用户显式启用。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **25.1** | agent 模块 cfg + pub use 条件导出 | `src/agent.rs` | `--features "agent,provider-openai"` 编译通过 |
| **25.2** | engine 模块 cfg + 子模块条件导出(switch / sub_agent / checkpointer | `src/engine/` | `--features "engine,provider-openai"` 编译通过 |
| **25.3** | `lib.rs` 中 agent / engine 模块声明 cfg + 条件重导出 | `src/lib.rs` | 验证 `engine` imply `agent` 链正确,transitive 依赖完整 |
**依赖**Phase 20-24(全链路依赖就绪后操作)
**优先级**P0
**预估规模**:约 20 行
**状态**:✅ 已交付(2026-07-19Step 1 合并)— Phase 25.3`src/lib.rs` 中 agent/engine 模块声明 cfg)已完成;Phase 25.1/25.2agent/engine 内部子模块条件导出)由 `src/lib.rs` 顶层门控覆盖;`src/agent/session.rs` 中 engine 相关 import + `to_snapshot` / `from_snapshot` / `restore_memory` / `has_pending_memory_restore` + `pending_memory_restore` 字段加 `#[cfg(feature = "engine")]`Phase 26 验证 `bundle()` 方法加 `#[cfg(feature = "engine")]` 门控修复 dead_code 警告
---
### Phase 26: 快捷组合验证 + 测试矩阵
**目标**:验证 4 个快捷组合 + clippy 完整性检查。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **26.1** | `default = ["full"]` 回归验证 | CI | `cargo test --features "full"` 全绿(427 passed |
| **26.2** | light 组合编译 + 单元测试 | CI | `cargo test --no-default-features --features "light"` 通过 |
| **26.3** | chat 组合(无 MCP)编译 + 单元测试 | CI | `cargo test --no-default-features --features "chat,provider-openai"` 通过 |
| **26.4** | chat + MCP 组合编译 + 单元测试 | CI | `cargo test --no-default-features --features "chat,provider-openai,tools-mcp"` 通过 |
| **26.5** | multi 组合(无 MCP)编译 + 单元测试 | CI | `cargo test --no-default-features --features "multi,provider-openai"` 通过 |
| **26.6** | multi + MCP 组合编译 + 单元测试 | CI | `cargo test --no-default-features --features "multi,provider-openai,tools-mcp"` 通过 |
| **26.7** | clippy `--all-features` 无警告 | CI | `cargo clippy --all-features -- -D warnings` 0 警告 |
| **26.8** | 修复各组合编译中发现的 cfg 遗漏 | 全量 | 7 种组合全部编译 + 测试通过 |
**依赖**Phase 20-25(所有门控就绪)
**优先级**P0
**预估规模**:约 10 行(CI 配置)
**状态**:✅ 已交付(2026-07-19)— 6 种 feature 组合测试矩阵 + clippy + format + examples 验证 job 全部通过;`RUSTFLAGS=-D warnings` 强制零警告;LlmProvider trait 归属修正 + bundle() 门控 + llm feature 补充 imply futures-util
---
### Phase 27: 文档更新 + 示例标注 + README feature 表
**目标**:让下游使用者能快速理解 feature 体系并选择合适组合。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **27.1** | README.md 添加 feature 表格 + `Cargo.toml` 使用示例 + 各组合推荐场景 | `README.md` | review 通过 |
| **27.2** | 各示例文件顶部添加所需的 feature 组合标注注释 | `examples/*.rs` | review 通过 |
| **27.3** | 更新 `docs/roadmap.md` 总入口添加 v0.3.2 链接和简要状态 | `docs/roadmap.md` | review 通过 |
**依赖**Phase 20-26
**优先级**P0
**预估规模**:约 100 行
**状态**:✅ 已交付(2026-07-19)— README 添加 feature 表格 + 快捷组合 + 模块级 features 清单 + 升级指南(LlmProvider 路径迁移);18 个 example 顶部添加 Required features 注释;roadmap 总入口同步
---
## Feature 依赖关系图
```mermaid
graph TD
subgraph "快捷组合"
FULL["full (default)"]
LIGHT["light"]
CHAT["chat"]
MULTI["multi"]
end
subgraph "模块级"
ENGINE["engine"]
AGENT["agent"]
LLM["llm"]
TOOLS["tools"]
TOOLS_MCP["tools-mcp"]
MEMORY["memory"]
MEMORY_SQLITE["memory-sqlite"]
PROMPT["prompt"]
LLM_TYPES["llm-types"]
DOCUMENT["document"]
end
subgraph "Provider"
P_OPENAI["provider-openai"]
P_ANTHROPIC["provider-anthropic"]
P_DEEPSEEK["provider-deepseek"]
P_QWEN["provider-qwen"]
P_OLLAMA["provider-ollama"]
end
FULL --> LIGHT & CHAT & MULTI
ENGINE --> AGENT
AGENT --> LLM & TOOLS & MEMORY
CHAT -.-> TOOLS_MCP
MULTI -.-> TOOLS_MCP
MEMORY_SQLITE --> MEMORY
TOOLS_MCP --> TOOLS
MEMORY --> DOCUMENT
LLM --> LLM_TYPES
TOOLS --> LLM_TYPES
PROMPT --> LLM_TYPES
P_OPENAI --> LLM
P_ANTHROPIC --> LLM
P_DEEPSEEK --> LLM
P_QWEN --> LLM
P_OLLAMA --> LLM
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
classDef pending fill:#fbbf24,stroke:#d97706,color:#1a1a1a
classDef provider fill:#93c5fd,stroke:#2563eb,color:#1a1a1a
class P_OPENAI,P_ANTHROPIC,P_DEEPSEEK,P_QWEN,P_OLLAMA provider
class FULL,ENGINE,AGENT,LLM,TOOLS,TOOLS_MCP,MEMORY,MEMORY_SQLITE,PROMPT,LLM_TYPES,DOCUMENT,LIGHT,CHAT,MULTI done
```
## 关键里程碑
| 里程碑 | Phase 完成条件 | 可验证指标 | 状态 |
|--------|---------------|-----------|------|
| **M16** | Phase 20 | `cargo build --no-default-features` 成功;`cargo build --features "full"` 与原行为一致 | ✅ 2026-07-19 |
| **M17** | Phase 21 | 三种零依赖模块各自独立编译通过 | ✅ 2026-07-19Step 1 合并) |
| **M18** | Phase 22 | 5 个 provider 各自单独编译;cycle.rs 无 tools 时编译通过 | ✅ 2026-07-19Step 1 合并) |
| **M19** | Phase 23 | tools 不含 mcp 编译通过;加 tools-mcp 引入 McpClient | ✅ 2026-07-19Step 1 合并) |
| **M20** | Phase 24 | memory imply document+llm 编译通过;不含 sqlite 编译通过;加 memory-sqlite 引入 SqliteStore | ✅ 2026-07-19Step 1 合并) |
| **M21** | Phase 25 | agent + engine 全链路门控编译通过 | ✅ 2026-07-19Step 1 合并) |
| **M22** | Phase 26 | 7 种 CI 组合全部编译 + 测试通过;clippy --all-features 0 警告 | ✅ 2026-07-19 |
| **M23** | Phase 27 | 文档 review 通过 | ✅ 2026-07-19 |
## Cargo.toml [features] 草案
```toml
[dependencies]
# 轻量核心依赖(始终编译)
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
async-trait = "0.1"
tracing = "0.1"
# 按 feature 可选的重依赖
tokio = { version = "1", features = ["rt", "sync", "time", "macros", "process", "io-util"], optional = true }
reqwest = { version = "0.12", features = ["json", "stream"], optional = true }
rusqlite = { version = "0.32", features = ["bundled"], optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
tokio-stream = { version = "0.1", optional = true }
futures = { version = "0.3", optional = true }
futures-util = { version = "0.3", optional = true }
futures-core = { version = "0.3", optional = true }
bytes = { version = "1", optional = true }
async-stream = { version = "0.3", optional = true }
tokio-util = { version = "0.7", features = ["rt"], optional = true }
time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"], optional = true }
```
```toml
[features]
default = ["full"]
# === 模块级 features ===
document = []
llm-types = []
prompt = ["llm-types"]
llm = ["llm-types", "tokio", "async-stream", "futures-core", "futures-util", "tokio-stream"]
tools = ["llm-types", "futures", "tokio-util", "tokio"]
tools-mcp = ["tools", "reqwest"]
memory = ["document", "llm", "tokio", "time"]
memory-sqlite = ["memory", "rusqlite", "time"]
agent = ["llm", "tools", "memory", "futures-util"]
engine = ["agent"]
# === Provider features ===
provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
provider-deepseek = ["llm", "reqwest"]
provider-qwen = ["llm", "reqwest"]
provider-ollama = ["llm", "reqwest"]
# === 工具 features ===
tracing-init = ["tracing-subscriber"]
# === 快捷组合 ===
full = [
"document", "llm-types", "prompt", "llm",
"tools", "tools-mcp",
"memory", "memory-sqlite",
"agent", "engine",
"provider-openai", "provider-anthropic", "provider-deepseek",
"provider-qwen", "provider-ollama",
"tracing-init",
]
light = [
"llm", "provider-openai", "tools", "tools-mcp",
"memory", "agent", "engine",
"prompt", "document",
]
chat = ["agent", "provider-openai"]
multi = ["engine", "provider-openai"]
```
### 依赖 optional 化对照
| 依赖 | 启用者 | 当前声明 |
|------|--------|---------|
| `tokio`features = `rt, rt-multi-thread, sync, time, macros, process, io-util` | llm, tools, memory | `optional = true` |
| `reqwest`features = ["json", "stream"] | provider-*, tools-mcp | `optional = true` |
| `rusqlite`features = ["bundled"] | memory-sqlite | `optional = true` |
| `tracing-subscriber`features = ["env-filter"] | tracing-init | `optional = true` |
| `tokio-stream` | llm | `optional = true` |
| `futures` | tools | `optional = true` |
| `futures-util` | llm, provider-*, agent | `optional = true` |
| `futures-core` | llm | `optional = true` |
| `bytes` | provider-openai, provider-anthropic | `optional = true` |
| `async-stream` | llm | `optional = true` |
| `tokio-util`features = ["rt"] | tools | `optional = true` |
| `time`features = ["serde","parsing","formatting","macros"] | memory, memory-sqlite | `optional = true` |
**始终编译**(轻量依赖,不参与 feature 门控):`serde``serde_json``thiserror``async-trait``tracing`
### CI 测试矩阵(已实施)
```yaml
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
env:
RUSTFLAGS: "-D warnings"
jobs:
test-matrix:
strategy:
fail-fast: false
matrix:
features:
- "full"
- "light"
- "chat,provider-openai"
- "chat,provider-openai,tools-mcp"
- "multi,provider-openai"
- "multi,provider-openai,tools-mcp"
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo test --no-default-features --features "${{ matrix.features }}" --lib
clippy:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo clippy --all-features --lib -- -D warnings
format:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
- run: cargo fmt --check
examples:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo test --features "full"
```
**关键设计决策**
- 矩阵使用 `--lib` 避免 examples 编译干扰模块测试验证
- `RUSTFLAGS=-D warnings` 强制零警告
- format job 使用 stable toolchain`cargo fmt --check` 无需 nightly
- 独立 `examples` job 验证所有 example 在完整 features 下编译
- 每个 job 设置 `timeout-minutes` 兜底
---
返回总入口:[`roadmap.md`](./roadmap.md)
+233
View File
@@ -0,0 +1,233 @@
# AG Core Roadmap — v0.4.0
> 本文件聚焦 **v0.4.0 版本** 的规划。Phase A-E 计划中,覆盖多 Agent 编排、Human-in-the-loop 与 Steering、语义压缩、自动校正。
> 返回总入口:[`roadmap.md`](./roadmap.md)
## v0.4.0 愿景
从 v0.3 的"多 Agent 基础系统"升级为"多 Agent 多职责编排系统"。补齐高层编排抽象(Swarm/Supervisor/Subgraph)、生产级人工干预能力(HITL + Steering)、工具与消息的语义压缩(TokenJuice),以及自动质量校正(Reflection)。为即将开发的多 Agent 协作产品提供完整的编排、干预与质量保证层。
## v0.4.0 总体范围
**总体规模**5 个增量 PhasePhase A-E),总新增代码约 1,950 行,零强制新外部依赖,零破坏性变更。
### 架构决策
**路线选择**:采用轻量编排模式(路线 A),不引入通用有向图引擎。通过 `Swarm::star()` / `Swarm::sequential()` / `Swarm::hierarchical()` 等具名模式提供编排能力,底层复用现有 `dispatch` / `create_child` / `SessionManager` 基础设施。预留路线 B(StateGraph 抽象)作为未来版本的升级路径。
**模块位置**
- 编排逻辑 → `src/engine/supervisor.rs`(新增)
- 内建工具 → `src/tools/builtin.rs`(新增)
- TokenJuice 压缩 → `src/llm/compress.rs`(新增)
- Steering 机制 → `src/engine/steer.rs`(新增,或并入 supervisor.rs
### 功能清单
#### P0 — 必须交付
| # | 功能 | 模块 | 方案要点 |
|---|------|------|---------|
| 1 | Swarm 编排(Star/Sequential/Hierarchical + Subgraph | `engine/supervisor` | `Swarm::star().supervisor(A).worker(B)` 声明式 API`Swarm::sequential().link(A).link(B)` 串联;`Swarm::hierarchical().supervisor(root).group("sub", ...)` 层次嵌套 |
| 2 | 结果聚合 | `engine/supervisor` | `aggregation_prompt` 模板将子 Agent 结果合并到 Supervisor 上下文;`DispatchConfig` 扩展 `result_key` 字段 |
| 3 | Human-in-the-loop 审批 | `engine/steer` | `interrupt()` 暂停执行 + `Command(resume=bool)` 恢复;`HookEvent::OnInterrupt` 新变体 |
| 4 | 用户 Steering(运行中校正) | `engine/steer` | `Command(resume=Correction{...})` 结构化校正;Steer 消息在工具批处理边界注入 |
| 5 | TokenJuice 语义压缩 | `llm/compress` | `Compressor` trait 统一抽象;覆盖工具结果、对话历史、跨 Agent 消息三层;LLM 摘要压缩 + 确定性兜底 |
#### P1 — 推荐交付
| # | 功能 | 模块 | 方案要点 |
|---|------|------|---------|
| 6 | 自动校正 / Reflection | `engine/reflect` | Evaluator-Optimizer 循环;Producer-Critic 角色分离;上限 2-3 轮迭代 |
### 实施计划 — 5 个增量 Phase
> **编号说明**Phase A-E 为 v0.4.0 专属编号,接续已完成的 Phase 30。
---
#### Phase A: Swarm 编排抽象(Star / Sequential / Hierarchical + Subgraph
**目标**:在现有 `dispatch` 原语基础上,提供声明式多 Agent 编排 API。Supervisor 作为 `Arc<dyn Agent>`,通过内建工具 `dispatch_sub_agent` 驱动子 Agent 执行。
**交付物**
1. `src/engine/supervisor.rs` 新文件:
- `Swarm` 枚举/结构体:`Swarm::star()`(星型,一个 Supervisor + N 个 Worker)、`Swarm::sequential()`(顺序链 A→B→C)、`Swarm::hierarchical()`(层次嵌套,Supervisor 下的 Sub-Supervisor
- 各模式的 `build()``run(input)` 方法
- 底层通过 `SessionManager::dispatch()` / `dispatch_all()` 实现
2. `src/tools/builtin.rs` 新文件:
- `dispatch_sub_agent(name, task, config)` 内建工具 — 从 Agent 注册表查找 Agent 工厂 → `SessionManager::dispatch()`
3. `AgentRegistry``HashMap<String, Box<dyn Fn() -> Arc<dyn Agent>>>` 轻量工厂注册表(约 50 行)
4. Subgraph 嵌套:`Swarm::hierarchical()` 支持 `group(name, inner_swarm)`,内层 Swarm 作为子节点编译后嵌入
**设计要点**
- Supervisor 就是 `Arc<dyn Agent>`,不新增 `SupervisorAgent` trait
- 路由逻辑写在 Supervisor 的 system prompt 中(LLM 决定的动态路由)
- 三种模式覆盖常见编排拓扑,不引入通用图引擎(路线 B 留作未来)
- Subgraph 编译为独立的 `SessionManager` 子树(复用 `create_child` 的父子关系)
**依赖**Phase 18SubAgent dispatch / SessionManager
**优先级**P0
**预估规模**:约 500 行
**状态**:📋 待实施
---
#### Phase B: 结果聚合 + 编排模式完善
**目标**:让 Supervisor 能智能地合并 Worker 结果。完善三种编排模式的容错性和易用性。
**交付物**
1. `aggregation_prompt` 模板系统 — 内建 `DEFAULT_AGGREGATION_PROMPT`,用户可自定义聚合逻辑
2. `DispatchConfig` 扩展:
- `result_key: Option<String>` — 将子结果存入 `session_memory` 的指定 key,供后续阶段使用
- `aggregate_strategy: AggregateStrategy``Concatenate` / `Summarize` / `Custom(Value)`
3. 编排模式增强:
- `Swarm::sequential()` 支持失败时停止 / 跳过 / 重试策略
- `Swarm::star()` 支持 Worker 超时
4. 端到端示例 3 个:
- `swarm_star_demo.rs` — 星型编排 + 并发派发 + 结果聚合
- `swarm_sequential_demo.rs` — 串联流水线
- `swarm_hierarchical_demo.rs` — 层次嵌套(Supervisor → Sub-Supervisor → Worker
**依赖**Phase A
**优先级**P0
**预估规模**:约 200 行
**状态**:📋 待实施
---
#### Phase C: Human-in-the-loop + 用户 Steering
**目标**:生产级多 Agent 系统的关键门禁。提供执行中暂停-审批-恢复机制,以及用户运行中校正方向的能力。
**交付物**
1. `src/engine/steer.rs` 新文件:
- `interrupt(value)` 函数 — 在工具循环中插入暂停点,持久化当前状态后返回控制权
- `Command` 枚举:
- `Command::Resume(bool)` — 二元审批(批准/拒绝)
- `Command::ResumeWith(Correction)` — 结构化校正(修改工具参数 / 调整方向)
2. `LlmCycle` 扩展:可中断工具循环模式
- `submit_with_tools_interruptible()` — 支持在工具批处理边界检查中断信号
- 中断时保存当前 `LlmCycle` 状态到 checkpoint
3. `HookEvent::OnInterrupt` / `OnSteer` 新变体 — 监听中断和校正事件
4. `SessionManager::resume_turn(session_id, resume_data)` — 从 checkpoint 恢复并注入审批结果
5. `tools/builtin.rs` 扩展:
- `request_approval(question, context)` — 请求用户审批
- `emit_steer(correction)` — 用户校正
6. Steering 生命周期:
- `interrupt` → 用户收到提示 → 用户决定方向 → `Command::ResumeWith(correction)` → Agent 在新方向上继续
**设计要点**
- User Steering 不是简单的"批准/拒绝",而是 `Correction { action, reason, amended_params }` 结构化指令
- Steering 消息在工具批处理边界(Worker 返回后、Supervisor 决策前)注入,不中断正在执行的工具
- 继承 `ContextSlot::fork/merge` 模式,steer 前 fork 快照,允许用户回退到 steer 前状态
**依赖**Phase ASwarm 编排)
**优先级**P0
**预估规模**:约 500 行
**状态**:📋 待实施
---
#### Phase D: TokenJuice 语义压缩
**目标**:替代当前字节级截断(`microcompact``[pruned]`),提供语义级别的压缩。在三层管道中接入:工具结果压缩、对话历史压缩、跨 Agent 消息压缩。
**交付物**
1. `src/llm/compress.rs` 新文件:
- `Compressor` trait`async fn compress(&self, input: &str, ctx: &CompressionContext) -> Result<String>`
- `CompressionContext``target_tokens` / `preserve_keys` / `strategy`
- `CompressionStrategy` 枚举:`Semantic { model }`LLM 摘要)、`Extractive { ratio }`(抽取式)、`Hybrid { semantic_first }`(混合)
- `SemanticCompressor` 实现(复用已有 provider 做 LLM 摘要压缩)
- `ExtractiveCompressor` 实现(确定性关键句提取,零 LLM 调用)
2. 三层接入点:
- **工具结果压缩**:在 `run_tool_loop` 中,`tool.execute()` 后插入 `compress_result()`,压缩结果再 `push ToolResult`
- **对话历史压缩**:在 `load_messages()` 后插入 `compress_history()`,替代/补充 `microcompact`
- **跨 Agent 消息压缩**:在 `inherit_session_memory` 的子 memory 写入前压缩(减少子 Agent 的 context 水位)
3. `CycleConfig` / `CompactConfig` 扩展:
- `token_compression: Option<CompressionConfig>` — 可选语义压缩配置
- `fallback_to_microcompact: bool`(默认 `true`)— LLM 压缩失败时退化为字节截断
4. TokenJuice 与现有 `microcompact` 的关系:
- `microcompact` 保留为最轻量级兜底(零 LLM 调用)
- TokenJuice 是可选增强层(默认关闭,用户 opt-in)
**设计要点**
- 零新外部依赖:LLM 摘要压缩复用已有 provider,抽取式压缩纯 Rust 实现
- 与现有 `CompactState` 断路器模式兼容(LLM 压缩失败 3 次后自动降级到 `microcompact`
- `preserve_keys` 确保关键数据(数字、ID、SQL、代码片段)不被压缩掉
**依赖**Phase 14Embedding trait 可选参考)
**优先级**P0
**预估规模**:约 400 行
**状态**:📋 待实施
---
#### Phase E: 自动校正 / Reflection
**目标**:实现 Agent 输出后的自我质量评估与自动修正循环。基于 `interrupt/resume` 基础设施,构建 Producer-Critic 闭环。
**交付物**
1. `src/engine/reflect.rs` 新文件:
- `ReflectionConfig``max_cycles`(默认 2/ `critic_agent`(可选不同模型)/ `criteria: Vec<String>`(评估标准)
- `Reflectable` trait`fn reflection_criteria(&self) -> Vec<String>` + `fn needs_refinement(&self, critique: &Critique) -> bool`
- `ReflectionLoop``evaluate(output) → Critique``should_refine? → yes: refine(output, critique) → 循环 / no: 返回`
2. Swarm 内建 Reflection 模式:
- `Swarm::reflect(producer_agent, critic_agent)` — 专用 Reflection Swarm
- 可在 Supervisor 流程中嵌入 `reflect_on(worker_result)` — 对 Worker 结果自动过一遍质量检查
3. `Critique` 结构体:`issues: Vec<Issue>` / `score: f32` / `should_refine: bool` / `suggestions: Vec<String>`
4. `tools/builtin.rs` 扩展:`verify_output(claim, evidence)` 工具 — 让 Agent 自行验证输出真实性
**设计要点**
- Producer 和 Critic 使用**不同模型**(避免同一模型的自我审查盲区 bias)
- 上限 2-3 轮(第一轮修正捕获 70–80% 改善空间,第 4+ 轮收益递减)
- 基于已有 `HookEvent::OnTurnEnd` 或扩展 `HookEvent::OnOutputGenerated` 触发反思
- 失败静默:Reflection 失败不阻断主流程(`tracing::warn!` 后继续交付原始输出)
**依赖**Phase Cinterrupt/resume 基础设施)
**优先级**P0
**预估规模**:约 350 行
**状态**:📋 待实施
---
### v0.4.0 Phase 依赖关系图
```mermaid
graph BT
PA["<b>Phase A: Swarm 编排</b><br/>Swarm::star/sequential/hierarchical<br/>Subgraph 嵌套<br/>内建 dispatch_sub_agent 工具<br/>~500 行"]:::pending
PB["<b>Phase B: 结果聚合</b><br/>aggregation_prompt 模板<br/>DispatchConfig result_key<br/>编排模式完善<br/>3 个端到端示例<br/>~200 行"]:::pending
PC["<b>Phase C: HITL + Steering</b><br/>interrupt/resume<br/>Command(ResumeWith Correction)<br/>HookEvent::OnInterrupt<br/>~500 行"]:::pending
PD["<b>Phase D: TokenJuice</b><br/>Compressor trait<br/>工具结果/历史/跨 Agent 压缩<br/>Semantic + Extractive 策略<br/>~400 行"]:::pending
PE["<b>Phase E: 自动校正</b><br/>ReflectionLoop<br/>Producer-Critic<br/>上限 2-3 轮<br/>~350 行"]:::pending
PB --> PA
PC --> PA
PE --> PC
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
classDef pending fill:#fbbf24,stroke:#d97706,color:#1a1a1a
classDef future fill:#94a3b8,stroke:#64748b,color:#1a1a1a
```
### 关键里程碑
| 里程碑 | Phase 完成条件 | 可验证指标 | 状态 |
|--------|---------------|-----------|------|
| **M16** | Phase A | `Swarm::star().supervisor(A).worker(B).run(input)` 端到端验证;`dispatch_sub_agent` 内建工具注册并可用;2 个示例 exit 0 | 📋 待启动 |
| **M17** | Phase B | `Swarm::sequential()` 串联执行验证;`Swarm::hierarchical()` 层次嵌套验证;结果聚合正确合并;3 个新示例 exit 0 | 📋 待启动 |
| **M18** | Phase C | `interrupt()` 暂停 + `Command::Resume(bool)` 恢复全链路验证;`Command::ResumeWith(Correction)` 结构化校正验证;HookEvent 触发验证 | 📋 待启动 |
| **M19** | Phase D | 工具结果经语义压缩后保留关键信息(验证压缩比 ≥ 3:1);`microcompact` 降级路径验证;对话历史压缩验证 | 📋 待启动 |
| **M20** | Phase E | ReflectionLoop 正确性验证:已知缺陷的输出被修复、无缺陷的输出不被修改(不变性保证);2 轮迭代上限验证;Critic 不同模型配置验证 | 📋 待启动 |
### 不做(v0.5+
| 功能 | 原因 |
|------|------|
| Agent 自动创生(LLM 驱动动态分派) | 设计复杂且不确定性高,v0.4 专注显式声明式编排 |
| 分布式 Session 共享(Redis 后端) | 与编排正交,大多数用户单进程即可 |
| 精确 tokenizer 计数(tiktoken-rs | 依赖引入,v0.4 专注编排与压缩能力本身 |
| 增量 Checkpoint | 存储优化,当前全量 JSON 够用 |
| 路线 BStateGraph 通用图引擎) | 当前编排需求在路线 A 范围内,图引擎留给未来版本 |
| RL 轨迹导出 | 专项需求,非通用 |
| Markdown 技能按需加载 | 独立功能 |
+24
View File
@@ -0,0 +1,24 @@
# AG Core Roadmap
> 拆分式 roadmap:按版本归档 + 未归类内容
> 最后更新:2026-07-21v0.4.0 规划完成 — Phase A-E 多 Agent 编排路线图制定)
## 文件索引
| 文件 | 范围 | 状态 |
|------|------|------|
| [`roadmap-v0.1.0.md`](./roadmap-v0.1.0.md) | v0.1.0 计划与交付 — Phase 04c + v0.1.0 Release | ✅ 已发布 2026-07-04 |
| [`roadmap-v0.2.0.md`](./roadmap-v0.2.0.md) | v0.2.0 计划与交付 — Phase 512 + v0.2.0-rc.1 | 🟡 Phase 5-11 已完成;Phase 12 P2 锦上添花可选 |
| [`roadmap-v0.3.0.md`](./roadmap-v0.3.0.md) | v0.3.0 计划与交付 - Phase 1319 | ✅ Phase 13-19 全部完成,v0.3.0 交付完毕 |
| [`roadmap-v0.3.2.md`](./roadmap-v0.3.2.md) | v0.3.2 计划与交付 — Phase 2027Cargo features 拆分) | ✅ Phase 20-27 全部完成,v0.3.2 交付完毕 |
| [`28-phase28-openai-response-api-provider.md`](./28-phase28-openai-response-api-provider.md) | Phase 28-30 OpenAI Response API Provider 实施方案(独立 feature `provider-openai-response` | ✅ Phase 28-30 已交付 |
| [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md) | v0.4.0 计划 — Phase A-ESwarm 编排、HITL + Steering、TokenJuice 语义压缩、自动校正) | 📋 计划中 |
| [`roadmap-unsorted.md`](./roadmap-unsorted.md) | 未归到任何版本的内容 — 全局愿景、当前状态、模块完整性、v0.4+ 展望、风险与建议、下一步行动、阶段总回顾 | — |
## 阅读建议
- **按版本顺序追溯历史**v0.1.0 → v0.2.0 → v0.3.0 → v0.4.0
- **了解产品演进全貌**:从 `roadmap-unsorted.md` 顶部开始读
- **查找特定 Phase**:每个版本文件内按 Phase 编号顺序排列
- **了解项目当前关注点**:从 `roadmap-unsorted.md` 的「下一步行动」开始
- **未来规划视野**:从 `roadmap-unsorted.md` 的「v0.4+ 展望」开始
+8 -8
View File
@@ -1,4 +1,5 @@
//! agent_session_demo —— Agent 装配 + 会话链路 + SessionMemory 桥接。
//! Required features: cargo run --example agent_session_demo --features "agent"
//!
//! 演示:
//! 1. 实现 `Agent` trait(定义角色 + system prompt
@@ -15,9 +16,9 @@ use std::sync::Arc;
use agcore::agent::{Agent, AgentBuilder, AgentSession};
use agcore::llm::hooks::HookExecutor;
use agcore::llm::mock::MockProvider;
use agcore::llm::types::Usage;
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
use agcore::llm::types::Usage;
use agcore::tools::ToolRegistry;
/// 计算器角色 Agent。
@@ -72,7 +73,10 @@ async fn main() {
// 4. 提交第一轮
println!("=== 提交第 1 轮 ===");
let resp = session.submit_turn("1+1=?").await.expect("submit_turn 失败");
let resp = session
.submit_turn("1+1=?")
.await
.expect("submit_turn 失败");
println!("LLM: {}", resp.text());
session
.set_session_data("last_q", "1+1=?")
@@ -107,11 +111,7 @@ async fn main() {
// 8. 跨 session 数据隔离验证
println!("=== 数据隔离验证 ===");
let other = AgentSession::new(
Arc::new(CalculatorAgent),
"other-session",
bundle,
);
let other = AgentSession::new(Arc::new(CalculatorAgent), "other-session", bundle);
assert!(
other.get_session_data("last_q").await.unwrap().is_none(),
"新会话不应看到旧 session 的 last_q"
@@ -127,4 +127,4 @@ async fn main() {
);
println!("\n✓ agent_session_demo 完成");
}
}
+119
View File
@@ -0,0 +1,119 @@
//! agent_switch_demo —— Agent 角色热切换示例。
//! Required features: cargo run --example agent_switch_demo --features "engine"
//!
//! 演示:
//! 1. 创建 session(绑定 Analyst agent
//! 2. 提交一轮对话(角色 A 输出"分析数据"
//! 3. switch_agent 切换为 Reporter agent
//! 4. 提交第二轮对话(角色 B 基于已有上下文输出"报告")
//! 5. 验证:turn_index 连续、session_memory 保留、slot 历史保留
//!
//! 运行:`cargo run --example agent_switch_demo`
use std::sync::Arc;
use agcore::agent::{Agent, AgentBuilder};
use agcore::engine::SessionManager;
use agcore::llm::hooks::HookExecutor;
use agcore::llm::mock::MockProvider;
use agcore::llm::types::Usage;
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
use agcore::memory::store::InMemoryStore;
use agcore::tools::ToolRegistry;
struct AnalystAgent;
struct ReporterAgent;
impl Agent for AnalystAgent {
fn name(&self) -> &str {
"analyst"
}
fn system_prompt(&self) -> Option<&str> {
Some("You are a data analyst. Analyze the input concisely.")
}
}
impl Agent for ReporterAgent {
fn name(&self) -> &str {
"reporter"
}
fn system_prompt(&self) -> Option<&str> {
Some("You are a report writer. Write concise reports based on context.")
}
}
fn assistant_text(text: &str) -> MessageResponse {
MessageResponse {
id: String::new(),
model: String::new(),
message: Message::Assistant {
content: vec![ContentBlock::Text { text: text.into() }],
},
usage: Usage::from_input_output(8, 4),
stop_reason: StopReason::Stop,
extra: Default::default(),
}
}
#[tokio::main]
async fn main() {
println!("=== Agent Switch Demo ===\n");
// 1. 准备组件
let store: Arc<dyn agcore::memory::store::MemoryStore> = Arc::new(InMemoryStore::new());
let provider = Arc::new(MockProvider::new(vec![
assistant_text("Analyst: data analyzed (Q3 sales up 15%)"),
assistant_text("Reporter: report drafted (3 paragraphs)"),
]));
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(HookExecutor::new()))
.session_memory_backend(store.clone())
.build()
.expect("RuntimeBundle 装配失败"),
);
let analyst: Arc<dyn Agent> = Arc::new(AnalystAgent);
let reporter: Arc<dyn Agent> = Arc::new(ReporterAgent);
let sm = Arc::new(SessionManager::new(store));
let session_id = sm.create(analyst, bundle.clone()).await.expect("create");
println!("[1] session created: {session_id}");
// 2. Analyst 跑一轮
let resp1 = sm
.submit_turn(&session_id, "Analyze Q3 sales data")
.await
.expect("submit_turn 1");
println!("[2] analyst turn 1: {:?}", resp1.text());
// 3. 切换到 Reporter
sm.switch_agent(&session_id, reporter)
.await
.expect("switch_agent");
println!("[3] agent switched to 'reporter'");
// 4. Reporter 跑一轮(基于已有上下文)
let resp2 = sm
.submit_turn(&session_id, "Write a report based on the analysis")
.await
.expect("submit_turn 2");
println!("[4] reporter turn 2: {:?}", resp2.text());
// 5. 验证 turn_index 连续
let (turn_index, agent_name_owned) = {
let session = sm.get(&session_id).await.unwrap();
let guard = session.lock().await;
(guard.turn_index(), guard.agent.name().to_string())
};
println!("\n[verify] turn_index = {turn_index}, agent = {agent_name_owned}");
assert_eq!(turn_index, 2, "turn_index should be 2 after 2 turns");
assert_eq!(
agent_name_owned, "reporter",
"current agent should be reporter"
);
println!("✓ context preserved across agent switch");
}
+204
View File
@@ -0,0 +1,204 @@
//! bridge_keys_demo —— bridge_keys 过滤 + 子↔子共享 namespace 示例。
//! Required features: cargo run --example bridge_keys_demo --features "engine"
//!
//! 演示:
//! 1. 父 session 设置 SessionMemorykey: "project_goal", "constraints", "noise"
//! 2. dispatch + bridge_keys = ["project_goal", "constraints"] → 只继承这两个
//! 3. 验证子 session 读到的 session_memory 与过滤一致
//! 4. 演示子↔子共享 namespacedispatch 时设 `shared_namespace`
//! 子 A 写入 `shared:{parent_id}:fact_x`,子 B 通过约定 key 读取
//!
//! 运行:`cargo run --example bridge_keys_demo`
use std::sync::Arc;
use agcore::agent::{Agent, AgentBuilder};
use agcore::engine::{DispatchConfig, SessionManager};
use agcore::llm::hooks::HookExecutor;
use agcore::llm::mock::MockProvider;
use agcore::llm::types::Usage;
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
use agcore::memory::store::InMemoryStore;
use agcore::tools::ToolRegistry;
struct WorkerAgent;
impl Agent for WorkerAgent {
fn name(&self) -> &str {
"worker"
}
fn system_prompt(&self) -> Option<&str> {
Some("You are a worker.")
}
}
fn assistant_text(text: &str) -> MessageResponse {
MessageResponse {
id: String::new(),
model: String::new(),
message: Message::Assistant {
content: vec![ContentBlock::Text { text: text.into() }],
},
usage: Usage::from_input_output(8, 4),
stop_reason: StopReason::Stop,
extra: Default::default(),
}
}
#[tokio::main]
async fn main() {
println!("=== Bridge Keys Demo ===\n");
let store: Arc<dyn agcore::memory::store::MemoryStore> = Arc::new(InMemoryStore::new());
// 3 个 dispatch 调用需要 3 个 mock response
let provider = Arc::new(MockProvider::new(vec![
assistant_text("Worker 1: done"),
assistant_text("Worker 2: done"),
assistant_text("Worker 3: done"),
]));
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(HookExecutor::new()))
.session_memory_backend(store.clone())
.build()
.expect("RuntimeBundle"),
);
let worker: Arc<dyn Agent> = Arc::new(WorkerAgent);
let sm = Arc::new(SessionManager::new(store));
let parent_id = sm
.create(worker.clone(), bundle.clone())
.await
.expect("create");
println!("[1] parent session created: {parent_id}");
// 父 session_memory 写入 3 个 key
{
let session = sm.get(&parent_id).await.unwrap();
let mut guard = session.lock().await;
guard
.set_session_data("project_goal", "Build a fast compiler")
.await
.unwrap();
guard
.set_session_data("constraints", "Rust, no unsafe")
.await
.unwrap();
guard
.set_session_data("noise", "should NOT be inherited")
.await
.unwrap();
}
println!("[2] parent set 3 keys: project_goal, constraints, noise");
// dispatch + bridge_keys 过滤
let config = DispatchConfig {
bridge_keys: Some(vec!["project_goal".to_string(), "constraints".to_string()]),
..Default::default()
};
let result = sm
.dispatch(&parent_id, worker.clone(), "do work", config)
.await
.expect("dispatch");
println!(
"[3] dispatched sub-agent (child_id={})\n",
&result.child_id[..20]
);
// 验证过滤效果
let child_session = sm.get(&result.child_id).await.unwrap();
let child_guard = child_session.lock().await;
let inherited_goal = child_guard
.session_memory()
.get("project_goal")
.await
.unwrap();
let inherited_constraint = child_guard
.session_memory()
.get("constraints")
.await
.unwrap();
let filtered_noise = child_guard.session_memory().get("noise").await.unwrap();
drop(child_guard);
println!("[verify] inherited keys in child session:");
println!(" - project_goal: {:?}", inherited_goal);
println!(" - constraints: {:?}", inherited_constraint);
println!(" - noise: {:?} (should be None)", filtered_noise);
assert_eq!(inherited_goal, Some("Build a fast compiler".to_string()));
assert_eq!(inherited_constraint, Some("Rust, no unsafe".to_string()));
assert_eq!(filtered_noise, None, "noise should be filtered out");
println!("\n✓ bridge_keys filtering works correctly");
// =============== 第二部分:子↔子共享 namespaceconvention===============
println!("\n=== Part 2: Child↔Child Shared Namespace (convention) ===\n");
// 关键点:`SessionMemory::get`/`set` 通过 session 自身 namespace 隔离
// (每个 session 一个独立 namespace),所以"子↔子共享"不能直接通过 SessionMemory。
// 真正的子↔子共享需要直接操作底层 MemoryStore,或由上层应用维护一个
// 跨 session 的"共享通道"(例如独立的 namespace + 所有子 session 知道 key 前缀)。
//
// 本 demo 演示通过 DispatchConfig.shared_namespaceconvention-based):
// - `shared_namespace: Some(prefix)` 作为约定标记,告知子 agent
// "你的数据共享 namespace 是 shared:{prefix}:*"
// - 子 agent 自行通过 `sm.store()` 直接操作 MemoryStore(绕过 SessionMemory 的 namespace 隔离)
//
// 演示 2 个子 agent 通过约定 namespace prefix 共享数据。
let shared_ns_config = DispatchConfig {
bridge_keys: Some(vec![]),
shared_namespace: Some("parent-123".to_string()),
..Default::default()
};
// dispatch 第一个子 agent
let _researcher_result = sm
.dispatch(
&parent_id,
worker.clone(),
"research task",
shared_ns_config.clone(),
)
.await
.expect("dispatch researcher");
// 子 A 通过 `sm.store()` 直接写入共享 namespace key
// (约定 prefix: "shared:parent-123:")
let shared_key = "shared:parent-123:fact_architecture";
sm.store()
.save(agcore::memory::types::MemoryItem {
id: shared_key.to_string(),
content: "Microservices with event sourcing".to_string(),
metadata: serde_json::json!({}),
created_at: time::OffsetDateTime::now_utc(),
})
.await
.expect("save shared fact");
println!("[4] researcher wrote {shared_key}");
// dispatch 第二个子 agent
let _writer_result = sm
.dispatch(&parent_id, worker.clone(), "writing task", shared_ns_config)
.await
.expect("dispatch writer");
// 子 B 通过 `sm.store()` 直接读取共享 namespace key
let read_item = sm.store().get(shared_key).await.expect("get");
let read_fact = read_item.map(|i| i.content);
println!("[5] writer reads {shared_key} = {read_fact:?}");
assert_eq!(
read_fact,
Some("Microservices with event sourcing".to_string()),
"writer should read researcher's shared fact"
);
println!("\n✓ child↔child shared namespace works correctly (via MemoryStore convention)");
println!("\n=== All Bridge Keys Demo checks passed ===");
}
+170
View File
@@ -0,0 +1,170 @@
//! context_slot_demo —— 多上下文槽位管理示例。
//! Required features: cargo run --example context_slot_demo --features "agent"
//!
//! 场景:法律咨询入口 → 派生两个独立探索方向 → 切换 → 隔离验证 → 删除。
//!
//! 展示:
//! - 默认 slot 自动创建
//! - 多 slot 间的消息隔离
//! - 派生 slot 从父 slot 复制消息
//! - 删除非 default slot 后自动回退到 default
//!
//! 运行:`cargo run --example context_slot_demo`(离线,零配置)
use std::sync::Arc;
use agcore::agent::{Agent, AgentBuilder, AgentSession};
use agcore::llm::LlmProvider;
use agcore::llm::hooks::HookExecutor;
use agcore::llm::mock::MockProvider;
use agcore::llm::types::Usage;
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
use agcore::tools::ToolRegistry;
struct LegalAdvisor;
impl Agent for LegalAdvisor {
fn name(&self) -> &str {
"legal-advisor"
}
fn system_prompt(&self) -> Option<&str> {
Some("你是法律顾问。请用一句话回答用户问题。")
}
}
/// 构造一个简单的 Assistant 响应(用于 MockProvider)。
fn assistant_resp(text: &str) -> MessageResponse {
MessageResponse {
id: String::new(),
model: "mock".into(),
message: Message::Assistant {
content: vec![ContentBlock::Text { text: text.into() }],
},
usage: Usage::from_input_output(5, 5),
stop_reason: StopReason::Stop,
extra: Default::default(),
}
}
#[tokio::main]
async fn main() {
// 1. 构造 session(自动包含 default slot
let provider: Arc<dyn LlmProvider> = Arc::new(MockProvider::new(vec![
assistant_resp("您好,我可以帮您处理法律问题。"),
assistant_resp("管辖权问题:建议选择合同签订地法院。"),
assistant_resp("条款修改:建议将上限调整为 80 万。"),
assistant_resp("已回到主对话。"),
]));
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(HookExecutor::new()))
.build()
.unwrap(),
);
let mut session = AgentSession::new(Arc::new(LegalAdvisor), "legal-001", bundle);
println!("=== 1. 默认 slot 自动创建 ===");
assert_eq!(session.current_slot_id(), "default");
let slots: Vec<_> = session.list_slots().collect();
println!("初始 slots: {slots:?}");
assert_eq!(slots.len(), 1);
assert!(slots.contains(&&"default".to_string()));
println!("\n=== 2. 在 default slot 中提交一轮 ===");
let r1 = session.submit_turn("我需要法律援助").await.unwrap();
println!("default slot response: {}", r1.text());
println!("\n=== 3. 派生两个独立探索方向的 slot ===");
session
.derive_slot(
"option_jurisdiction",
"default",
agcore::agent::DeriveStrategy::Full,
)
.await
.unwrap();
session
.derive_slot(
"option_amendment",
"default",
agcore::agent::DeriveStrategy::Full,
)
.await
.unwrap();
let slots: Vec<_> = session.list_slots().cloned().collect();
println!("派生后 slots: {slots:?}");
assert_eq!(slots.len(), 3);
println!("\n=== 4. 切到 option_jurisdiction 并提交 ===");
session.switch_slot("option_jurisdiction").await.unwrap();
assert_eq!(session.current_slot_id(), "option_jurisdiction");
let r2 = session.submit_turn("如果用户质疑管辖权?").await.unwrap();
println!("option_jurisdiction response: {}", r2.text());
println!("\n=== 5. 切到 option_amendment 并提交 ===");
session.switch_slot("option_amendment").await.unwrap();
let r3 = session.submit_turn("用户要求提高赔偿上限?").await.unwrap();
println!("option_amendment response: {}", r3.text());
println!("\n=== 6. 切回 default,验证消息隔离 ===");
session.switch_slot("default").await.unwrap();
let r4 = session.submit_turn("汇总一下我们的讨论").await.unwrap();
println!("default response: {}", r4.text());
// 验证 default slot 不包含 option_jurisdiction 的"管辖权"问题
let (_, default_slot) = session.slots().find(|(id, _)| *id == "default").unwrap();
let default_has_jurisdiction = default_slot
.messages
.iter()
.any(|m| message_contains(m, "管辖权"));
assert!(
!default_has_jurisdiction,
"default slot 不应包含 option_jurisdiction 的消息"
);
println!("\n=== 7. 删除 option_amendment,验证回退到 default ===");
session.delete_slot("option_amendment").await.unwrap();
let slots: Vec<_> = session.list_slots().cloned().collect();
println!("删除后 slots: {slots:?}");
assert!(!slots.contains(&"option_amendment".to_string()));
assert_eq!(slots.len(), 2);
println!("\n=== 8. 切到 option_jurisdiction 并删除,验证 current 回退 ===");
session.switch_slot("option_jurisdiction").await.unwrap();
session.delete_slot("option_jurisdiction").await.unwrap();
assert_eq!(session.current_slot_id(), "default");
let slots: Vec<_> = session.list_slots().cloned().collect();
println!("删除后 slots: {slots:?}");
assert_eq!(slots.len(), 1);
assert_eq!(slots[0], "default");
println!("\n=== 9. 验证 delete_slot 保护逻辑 ===");
let err = session.delete_slot("default").await.unwrap_err();
println!("删除 default 返回错误: {err}");
assert!(matches!(err, agcore::agent::AgentError::Config(_)));
println!("\n✓ context_slot_demo 完成");
}
/// 检查 Message 是否包含指定文本(提取第一个 Text block)。
fn message_contains(msg: &Message, needle: &str) -> bool {
use agcore::llm::types::message::ContentBlock;
let blocks = match msg {
Message::System { content }
| Message::User { content }
| Message::Assistant { content } => content,
Message::UserImage { .. } => return false,
Message::ToolResult { content, .. } => content,
_ => return false,
};
for block in blocks {
if let ContentBlock::Text { text } = block
&& text.contains(needle)
{
return true;
}
}
false
}
+10 -17
View File
@@ -1,4 +1,5 @@
//! conversation_memory_demo —— 对话记忆滑动窗口与隔离。
//! Required features: cargo run --example conversation_memory_demo --features "memory"
//!
//! 演示:
//! 1. `ConversationMemoryConfig` 构造(SlidingWindow / Full 策略)
@@ -29,6 +30,7 @@ fn message_text(msg: &Message) -> &str {
.next()
.unwrap_or(""),
Message::UserImage { .. } => "[image]",
_ => "",
}
}
@@ -80,11 +82,8 @@ async fn main() {
// 3. 多角色混合 + clear
println!("\n=== 多角色写入 + clear ===");
let store3 = Arc::new(InMemoryStore::new());
let mut memory3 = ConversationMemory::new(
store3,
"session-3",
ConversationMemoryConfig::default(),
);
let mut memory3 =
ConversationMemory::new(store3, "session-3", ConversationMemoryConfig::default());
memory3
.add_message(Message::user_text("你好"))
.await
@@ -98,7 +97,9 @@ async fn main() {
.await
.unwrap();
memory3
.add_message(Message::assistant("我无法查询实时天气,但你可以查看天气应用。"))
.add_message(Message::assistant(
"我无法查询实时天气,但你可以查看天气应用。",
))
.await
.unwrap();
println!(
@@ -119,16 +120,8 @@ async fn main() {
// 4. Session 隔离
println!("\n=== Session 隔离(共用 InMemoryStore===");
let store4 = Arc::new(InMemoryStore::new());
let mut a = ConversationMemory::new(
store4.clone(),
"s-a",
ConversationMemoryConfig::default(),
);
let mut b = ConversationMemory::new(
store4.clone(),
"s-b",
ConversationMemoryConfig::default(),
);
let mut a = ConversationMemory::new(store4.clone(), "s-a", ConversationMemoryConfig::default());
let mut b = ConversationMemory::new(store4.clone(), "s-b", ConversationMemoryConfig::default());
a.add_message(Message::user_text("A 的消息")).await.unwrap();
b.add_message(Message::user_text("B 的消息")).await.unwrap();
println!(
@@ -140,4 +133,4 @@ async fn main() {
assert_eq!(b.len(), 1);
println!("\n✓ conversation_memory_demo 完成");
}
}
+12 -16
View File
@@ -1,4 +1,5 @@
//! custom_tool —— 自定义工具注册、单次 / 并行调用、权限检查。
//! Required features: cargo run --example custom_tool --features "tools,llm"
//!
//! 演示:
//! 1. 实现 `BaseTool` traitWeatherTool + DeleteFileTool
@@ -17,7 +18,7 @@ use agcore::tools::{
ToolRegistry,
};
use async_trait::async_trait;
use serde_json::{json, Value};
use serde_json::{Value, json};
/// 天气查询工具 —— 模拟根据城市返回天气数据。
struct WeatherTool;
@@ -42,11 +43,7 @@ impl BaseTool for WeatherTool {
fn required_permissions(&self) -> Vec<Permission> {
vec![Permission::Network]
}
async fn execute(
&self,
args: Value,
_ctx: &ToolContext<'_>,
) -> Result<Value, ToolError> {
async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<Value, ToolError> {
let city = args["city"].as_str().unwrap_or("未知");
// 模拟查询:根据城市名给出不同温度
let (temperature, condition) = match city {
@@ -84,11 +81,7 @@ impl BaseTool for DeleteFileTool {
fn required_permissions(&self) -> Vec<Permission> {
vec![Permission::Delete]
}
async fn execute(
&self,
_args: Value,
_ctx: &ToolContext<'_>,
) -> Result<Value, ToolError> {
async fn execute(&self, _args: Value, _ctx: &ToolContext<'_>) -> Result<Value, ToolError> {
Ok(json!({"deleted": true}))
}
}
@@ -138,9 +131,8 @@ async fn main() {
// 5. 权限检查:默认 PermissionConfig 黑名单含 Delete
println!("\n=== 权限检查(默认 PermissionConfigdenied = [Delete, Shell]===");
let mut registry_with_checker = ToolRegistry::new().with_permission_checker(PermissionChecker::new(
PermissionConfig::default(),
));
let mut registry_with_checker = ToolRegistry::new()
.with_permission_checker(PermissionChecker::new(PermissionConfig::default()));
registry_with_checker
.register(Arc::new(WeatherTool) as ToolRef)
.unwrap();
@@ -155,7 +147,11 @@ async fn main() {
.unwrap();
println!(
"get_weather 权限检查: {}",
if r.output.is_ok() { "通过 ✓" } else { "阻断 ✗" }
if r.output.is_ok() {
"通过 ✓"
} else {
"阻断 ✗"
}
);
// delete_file 声明 Delete → 在 denied 列表 → 阻断
@@ -166,4 +162,4 @@ async fn main() {
println!("delete_file 权限检查: 阻断 ✗ ({err})");
println!("\n✓ custom_tool 完成");
}
}
+129
View File
@@ -0,0 +1,129 @@
//! dispatch_stream_demo —— 流式子代理调度示例。
//! Required features: cargo run --example dispatch_stream_demo --features "engine"
//!
//! 演示:
//! 1. 创建父 session
//! 2. dispatch_stream 单个子 agent
//! 3. 消费 SubTaskStreamEvent 序列
//! 4. 验证事件序列:ChildCreated → Stream(...) × N → Completed
//! 5. 验证:完成时 turn_index 已递增(finalize 副作用)
//!
//! 运行:`cargo run --example dispatch_stream_demo`
use std::sync::Arc;
use agcore::agent::{Agent, AgentBuilder};
use agcore::engine::{SessionManager, SubTaskStreamEvent};
use agcore::llm::hooks::HookExecutor;
use agcore::llm::mock::MockProvider;
use agcore::llm::types::Usage;
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
use agcore::memory::store::InMemoryStore;
use agcore::tools::ToolRegistry;
use futures_util::StreamExt;
struct StreamWorkerAgent;
impl Agent for StreamWorkerAgent {
fn name(&self) -> &str {
"stream_worker"
}
fn system_prompt(&self) -> Option<&str> {
Some("You are a streaming worker.")
}
}
fn assistant_text(text: &str) -> MessageResponse {
MessageResponse {
id: String::new(),
model: String::new(),
message: Message::Assistant {
content: vec![ContentBlock::Text { text: text.into() }],
},
usage: Usage::from_input_output(8, 4),
stop_reason: StopReason::Stop,
extra: Default::default(),
}
}
#[tokio::main]
async fn main() {
println!("=== Dispatch Stream Demo ===\n");
let store: Arc<dyn agcore::memory::store::MemoryStore> = Arc::new(InMemoryStore::new());
let provider = Arc::new(MockProvider::new(vec![assistant_text("streamed response")]));
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(HookExecutor::new()))
.session_memory_backend(store.clone())
.build()
.expect("RuntimeBundle"),
);
let worker: Arc<dyn Agent> = Arc::new(StreamWorkerAgent);
let sm = Arc::new(SessionManager::new(store));
let parent_id = sm
.create(worker.clone(), bundle.clone())
.await
.expect("create");
println!("[1] parent session: {parent_id}");
// dispatch_stream
let mut stream = sm
.dispatch_stream(&parent_id, worker, "do streaming work", Default::default())
.await
.expect("dispatch_stream");
println!("[2] consuming SubTaskStreamEvent sequence...\n");
let mut saw_child_created = false;
let mut saw_stream_count = 0;
let mut completed = None;
while let Some(event) = stream.next().await {
match event {
SubTaskStreamEvent::ChildCreated { child_id } => {
println!(" → ChildCreated({})", &child_id[..20]);
saw_child_created = true;
}
SubTaskStreamEvent::Stream(_) => {
saw_stream_count += 1;
}
SubTaskStreamEvent::Completed(r) => {
println!(
" → Completed(child_id={}, {} tokens)",
&r.child_id[..20],
r.usage.total().total_tokens
);
completed = Some(r);
break;
}
SubTaskStreamEvent::Error { child_id, error } => {
panic!("unexpected error: child_id={child_id}, error={error}");
}
}
}
let result = completed.expect("Completed should arrive");
// 验证事件序列
assert!(saw_child_created, "ChildCreated should be received");
assert!(saw_stream_count > 0, "at least one Stream event");
println!("\n[3] received {} stream events", saw_stream_count);
// 验证 finalize 已发生(turn_index 递增)
let child_session = sm.get(&result.child_id).await.unwrap();
let child_guard = child_session.lock().await;
let child_turn_index = child_guard.turn_index();
drop(child_guard);
assert_eq!(
child_turn_index, 1,
"turn_index should increment after finalize"
);
println!("[4] child session turn_index = {child_turn_index} (finalize works)");
println!("\n✓ dispatch_stream completed successfully");
}
+67
View File
@@ -0,0 +1,67 @@
//! document_demo —— Document + RecursiveCharacterSplitter + MockEmbedding + RagPipeline 完整衔接示例。
//! Required features: cargo run --example document_demo --features "memory,tracing-init"
//!
//! 演示 RAG 管线:
//! 1. 创建多段落 Document
//! 2. RecursiveCharacterSplitter 分割为 chunk
//! 3. RagPipeline.ingest() 自动嵌入并存储
//! 4. RagPipeline.retrieve() 做语义检索
//!
//! 运行:`cargo run --example document_demo`(离线,零配置)
use std::sync::Arc;
use agcore::document::{Document, RecursiveCharacterSplitter};
use agcore::llm::embedding::{Embedding, MockEmbedding};
use agcore::memory::{InMemoryVectorStore, RagPipeline, VectorStore};
#[tokio::main]
async fn main() {
agcore::init_tracing();
// 1. 创建多段落 Document(含中英文混合)
let doc = Document::new(
"rust-intro",
"Rust 是一门系统编程语言,注重安全、并发和性能。\n\n\
Rust \
\n\n\
Rust 线\
Send Sync trait 线\n\n\
Rust C/C++ \
Cargo 使",
"text/markdown",
);
println!("输入文档: {} 字符", doc.content.chars().count());
// 2. 构造 RAG 管线(嵌入器 + 向量存储 + 分割器)
let embedder: Arc<dyn Embedding> = Arc::new(MockEmbedding::new(4));
let store: Arc<dyn VectorStore> = Arc::new(InMemoryVectorStore::new());
let splitter = RecursiveCharacterSplitter::new(200, 30);
let pipeline = RagPipeline::new(Arc::clone(&embedder), Arc::clone(&store), Some(splitter));
// 3. 一次性 ingest:自动 split → embed → add
pipeline.ingest(std::slice::from_ref(&doc)).await.unwrap();
// 4. 模拟查询:复用第一个 chunk 的 content 作为查询文本
let chunks_in_store = store.search(&[1.0, 0.0, 0.0, 0.0], 1).await.unwrap();
assert!(!chunks_in_store.is_empty(), "ingest 后 store 应有数据");
let query_text = &chunks_in_store[0].0.content;
let results = pipeline.retrieve(query_text, 3).await.unwrap();
println!("\nTop 3 检索结果(与第一个 chunk 相似):");
for (doc, score) in &results {
println!(
" id={}, score={:.4}, content={}",
doc.id, score, doc.content
);
}
assert!(!results.is_empty(), "至少应返回 1 条检索结果");
assert!(
results[0].0.id.starts_with("rust-intro:chunk:0000"),
"Top 1 应为 chunk 0 自身"
);
println!("\n✓ document_demo 完成");
}
+386
View File
@@ -0,0 +1,386 @@
//! end_to_end —— 3 工具 + 3 轮对话 + SqliteStore 持久化跨连接验证。
//! Required features: cargo run --example end_to_end --features "agent,memory-sqlite,provider-openai"
//!
//! 运行:`cargo run --example end_to_end`(离线,零配置)
//!
//! ## 真实 LLM Provider 切换
//!
//! 设置环境变量即可使用真实 LLM Provider
//! - `AG_LLM_BASE_URL` —— API 端点(如 `https://api.openai.com/v1`
//! - `AG_LLM_API_KEY` —— API key
//! - `AG_LLM_MODEL` —— 模型名(如 `gpt-4o-mini`
//! - `AG_LLM_PROVIDER`(可选)—— Provider 类型,默认 OpenaiChatOpenAI / DeepSeek / Qwen / Ollama
//!
//! 未设置上述变量时自动降级为 MockProvider,零配置可运行。
use std::env;
use std::sync::Arc;
use agcore::agent::{Agent, AgentBuilder, AgentSession};
use agcore::llm::LlmProvider;
use agcore::llm::hooks::HookExecutor;
use agcore::llm::mock::MockProvider;
use agcore::llm::provider::{ProviderConfig, ProviderType, create_provider};
use agcore::llm::types::{
Usage,
message::{ContentBlock, Message},
response_v2::{MessageResponse, StopReason},
};
use agcore::memory::store::{MemoryStore, SqliteStore};
use agcore::memory::types::{MemoryFilter, MemoryItem};
use agcore::tools::{BaseTool, ToolContext, ToolError, ToolRegistry};
use async_trait::async_trait;
use serde_json::{Value, json};
use tempfile::TempDir;
use time::OffsetDateTime;
// === Agent ===
struct AssistantAgent;
impl Agent for AssistantAgent {
fn name(&self) -> &str {
"end-to-end assistant"
}
fn system_prompt(&self) -> Option<&str> {
Some("简洁助手,必要时调用工具完成任务。")
}
}
// === Tools ===
struct EchoTool;
#[async_trait]
impl BaseTool for EchoTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"回显输入文本"
}
fn parameters(&self) -> Value {
json!({"type":"object","properties":{"text":{"type":"string"}},"required":["text"]})
}
async fn execute(&self, args: Value, _: &ToolContext<'_>) -> Result<Value, ToolError> {
let text = args.get("text").and_then(|v| v.as_str()).ok_or_else(|| {
ToolError::InvalidArguments("text".into(), "需要 string 类型的 text 参数".into())
})?;
Ok(json!({"echoed": format!("收到: {text}")}))
}
}
/// 四则运算:'a op b' 格式(ponytail: 基础 +-*/ 不引入 rhai 依赖)。
struct CalcTool;
#[async_trait]
impl BaseTool for CalcTool {
fn name(&self) -> &str {
"calc"
}
fn description(&self) -> &str {
"四则运算:'a op b' 格式,op ∈ {+, -, *, /}"
}
fn parameters(&self) -> Value {
json!({"type":"object","properties":{"expr":{"type":"string"}},"required":["expr"]})
}
async fn execute(&self, args: Value, _: &ToolContext<'_>) -> Result<Value, ToolError> {
let expr = args["expr"].as_str().unwrap_or("");
let parts: Vec<&str> = expr.split_whitespace().collect();
if parts.len() != 3 {
return Err(ToolError::InvalidArguments(
"expr".into(),
"需要 'a op b' 三段式".into(),
));
}
let a: i64 = parts[0].parse().map_err(|_| {
ToolError::InvalidArguments("expr".into(), format!("无法解析 '{}'", parts[0]))
})?;
let b: i64 = parts[2].parse().map_err(|_| {
ToolError::InvalidArguments("expr".into(), format!("无法解析 '{}'", parts[2]))
})?;
let result = match parts[1] {
"+" => a + b,
"-" => a - b,
"*" => a * b,
"/" => a
.checked_div(b)
.ok_or_else(|| ToolError::InvalidArguments("expr".into(), "除数不能为 0".into()))?,
op => {
return Err(ToolError::InvalidArguments(
"expr".into(),
format!("不支持的运算符: {op}"),
));
}
};
Ok(json!({"result": result}))
}
}
/// 通过 MemoryStore trait 读写笔记:直接持有 Arc<dyn MemoryStore>
/// 绕开 AgentSession 封装(NoteTool 在 tool.execute 中直接操作 store)。
/// 关键前缀 "note:" 用于 list 过滤。
struct NoteTool {
store: Arc<dyn MemoryStore>,
}
impl NoteTool {
const PREFIX: &'static str = "note:";
}
#[async_trait]
impl BaseTool for NoteTool {
fn name(&self) -> &str {
"note"
}
fn description(&self) -> &str {
"笔记 save/query: save(key, content) / query()"
}
fn parameters(&self) -> Value {
json!({
"type":"object",
"properties":{
"action":{"type":"string","enum":["save","query"]},
"key":{"type":"string"},
"content":{"type":"string"}
},
"required":["action"]
})
}
async fn execute(&self, args: Value, _: &ToolContext<'_>) -> Result<Value, ToolError> {
let action = args["action"].as_str().unwrap_or("");
match action {
"save" => {
let key = args["key"].as_str().unwrap_or("");
let content = args["content"].as_str().unwrap_or("");
let item = MemoryItem {
id: format!("{}{}", Self::PREFIX, key),
content: content.to_string(),
metadata: json!({}),
created_at: OffsetDateTime::now_utc(),
};
self.store
.save(item)
.await
.map_err(|e| ToolError::ExecutionFailed("note".into(), e.to_string()))?;
Ok(json!({"saved": key}))
}
"query" => {
let filter = MemoryFilter {
prefix: Some(Self::PREFIX.into()),
..Default::default()
};
let items = self
.store
.list(&filter)
.await
.map_err(|e| ToolError::ExecutionFailed("note".into(), e.to_string()))?;
let notes: Vec<String> = items.into_iter().map(|i| i.content).collect();
Ok(json!({"notes": notes}))
}
_ => Err(ToolError::InvalidArguments(
"action".into(),
format!("未知 action: {action}"),
)),
}
}
}
// === Mock response helper ===
fn resp(content: Vec<ContentBlock>, stop: StopReason, u: (u32, u32)) -> MessageResponse {
MessageResponse {
id: String::new(),
model: "mock".into(),
message: Message::Assistant { content },
usage: Usage::from_input_output(u.0, u.1),
stop_reason: stop,
extra: Default::default(),
}
}
fn mock_responses() -> Vec<MessageResponse> {
vec![
// 第 1 轮:calc(25 * 4) → tool_result(100) → 文本回答
resp(
vec![ContentBlock::ToolUse {
id: "t1".into(),
name: "calc".into(),
input: json!({"expr": "25 * 4"}),
}],
StopReason::ToolUse,
(5, 8),
),
resp(
vec![ContentBlock::Text {
text: "25 * 4 = 100".into(),
}],
StopReason::Stop,
(8, 12),
),
// 第 2 轮:note(save, last_calc, "100") → tool_result(saved) → 文本回答
resp(
vec![ContentBlock::ToolUse {
id: "t2".into(),
name: "note".into(),
input: json!({"action": "save", "key": "last_calc", "content": "100"}),
}],
StopReason::ToolUse,
(10, 14),
),
resp(
vec![ContentBlock::Text {
text: "已记录:last_calc = 100".into(),
}],
StopReason::Stop,
(12, 16),
),
// 第 3 轮:note(query) → tool_result([100]) → 文本回答
resp(
vec![ContentBlock::ToolUse {
id: "t3".into(),
name: "note".into(),
input: json!({"action": "query"}),
}],
StopReason::ToolUse,
(8, 8),
),
resp(
vec![ContentBlock::Text {
text: "您刚才的计算结果是 100".into(),
}],
StopReason::Stop,
(10, 14),
),
// 后续冗余响应(防止队列耗尽报错)
resp(
vec![ContentBlock::Text {
text: "done".into(),
}],
StopReason::Stop,
(1, 1),
),
resp(
vec![ContentBlock::Text {
text: "done".into(),
}],
StopReason::Stop,
(1, 1),
),
resp(
vec![ContentBlock::Text {
text: "done".into(),
}],
StopReason::Stop,
(1, 1),
),
]
}
// === Provider selection ===
fn select_provider() -> Arc<dyn LlmProvider> {
if env::var("AG_LLM_BASE_URL").is_ok() && env::var("AG_LLM_API_KEY").is_ok() {
let cfg = ProviderConfig::from_env("AG_LLM").expect("AG_LLM_* 环境变量解析失败");
let provider_type = env::var("AG_LLM_PROVIDER")
.ok()
.and_then(|s| s.parse::<ProviderType>().ok())
.unwrap_or(ProviderType::OpenaiChat);
Arc::from(create_provider(provider_type, cfg).expect("Provider 创建失败"))
} else {
let found: Vec<&str> = ["AG_LLM_BASE_URL", "AG_LLM_API_KEY", "AG_LLM_MODEL"]
.iter()
.filter(|k| env::var(k).is_ok())
.copied()
.collect();
eprintln!(
"AG_LLM_* 环境变量不完整(检测到: {:?}),回退到 MockProvider",
found
);
Arc::new(MockProvider::new(mock_responses()))
}
}
// === Main ===
#[tokio::main]
async fn main() {
println!("=== agcore 端到端演示 ===");
let dir = TempDir::new().expect("TempDir 创建失败");
let db_path = dir.path().join("agcore.db");
let backend: Arc<dyn MemoryStore> =
Arc::new(SqliteStore::open(&db_path).expect("SqliteStore 打开失败"));
println!("💾 SqliteStore: {}", db_path.display());
let provider_label =
if env::var("AG_LLM_BASE_URL").is_ok() && env::var("AG_LLM_API_KEY").is_ok() {
"真实 LLM Provider"
} else {
"MockProvider (离线回退模式)"
};
println!("🔄 Provider: {provider_label}");
let mut registry = ToolRegistry::new();
registry.register(Arc::new(EchoTool)).unwrap();
registry.register(Arc::new(CalcTool)).unwrap();
registry
.register(Arc::new(NoteTool {
store: backend.clone(),
}))
.unwrap();
println!("🔧 注册工具: {:?}", registry.list_tools());
let bundle = Arc::new(
AgentBuilder::new()
.provider(select_provider())
.tool_registry(Arc::new(registry))
.hook_executor(Arc::new(HookExecutor::new()))
.build()
.expect("RuntimeBundle 装配失败"),
);
let mut session = AgentSession::new(Arc::new(AssistantAgent), "e2e-1", bundle.clone());
println!("\n第 1 轮 用户: 帮我算 25 * 4");
let r1 = session
.submit_turn("帮我算 25 * 4")
.await
.expect("turn 1 失败");
println!(" → 回答: {}", r1.text());
println!("\n第 2 轮 用户: 记下来:结果是 100");
let r2 = session
.submit_turn("记下来:结果是 100")
.await
.expect("turn 2 失败");
println!(" → 回答: {}", r2.text());
println!("\n第 3 轮 用户: 我刚才算了什么?");
let r3 = session
.submit_turn("我刚才算了什么?")
.await
.expect("turn 3 失败");
println!(" → 回答: {}", r3.text());
let total = session.usage().total();
println!(
"\n📊 用量: prompt={}, completion={}, total={}",
total.prompt_tokens, total.completion_tokens, total.total_tokens
);
println!("\n=== 持久化验证 ===");
// 显式释放所有对 backend 的 Arc 引用,确保 SqliteStore Connection 真正关闭。
// 释放顺序:session → bundle(间接持有 NoteTool → backend clone)→ backend 局部变量。
drop(session); // session.bundle Arc 计数 -1
drop(bundle); // bundle Arc 计数归零 → registry → NoteTool → backend clone Arc 计数 2→1
drop(backend); // backend 局部变量 Arc 计数 1→0 → SqliteStore::drop → Connection 自动 close
let backend2: Arc<dyn MemoryStore> =
Arc::new(SqliteStore::open(&db_path).expect("重开 SqliteStore 失败"));
let filter = MemoryFilter {
prefix: Some("note:".into()),
..Default::default()
};
let items = backend2.list(&filter).await.expect("list 失败");
println!("✓ 跨连接数据存活: 找到 {} 条 note", items.len());
assert!(!items.is_empty(), "持久化验证失败:重开后无数据");
for i in &items {
println!(" - {} = {}", i.id, i.content);
}
println!("\n✓ 端到端演示完成");
}
+259
View File
@@ -0,0 +1,259 @@
//! engine_demo —— SessionManager + Checkpointer 端到端示例。
//! Required features: cargo run --example engine_demo --features "engine"
//!
//! 演示:
//! 1. SessionManager::create 创建 session
//! 2. SessionManager::submit_turnauto_checkpoint=true 自动写 checkpoint
//! 3. SessionManager::create_child 创建子 session
//! 4. children() / parent() 树形查询
//! 5. Checkpointer::list_checkpoints 列出所有 checkpoint
//! 6. SessionManager::recover 从 checkpoint 恢复(模拟进程重启)
//! 7. AgentSession::to_snapshot + SessionManager::replace 演示 rollback 流程
//! 8. SessionManager::destroy 清理
//!
//! 运行:`cargo run --example engine_demo`
use std::sync::Arc;
use agcore::agent::{Agent, AgentBuilder, AgentSession};
use agcore::engine::SessionManager;
use agcore::llm::hooks::HookExecutor;
use agcore::llm::mock::MockProvider;
use agcore::llm::types::Usage;
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
use agcore::memory::store::InMemoryStore;
use agcore::tools::ToolRegistry;
struct DemoAgent;
impl Agent for DemoAgent {
fn name(&self) -> &str {
"demo"
}
fn system_prompt(&self) -> Option<&str> {
Some("你是 demo agent,每轮回复一句话。")
}
}
fn assistant_text(text: &str) -> MessageResponse {
MessageResponse {
id: String::new(),
model: String::new(),
message: Message::Assistant {
content: vec![ContentBlock::Text { text: text.into() }],
},
usage: Usage::from_input_output(8, 4),
stop_reason: StopReason::Stop,
extra: Default::default(),
}
}
#[tokio::main]
async fn main() {
// 1. 准备底层组件
let store: Arc<dyn agcore::memory::store::MemoryStore> = Arc::new(InMemoryStore::new());
let provider = Arc::new(MockProvider::new(vec![
assistant_text("turn 1 response"),
assistant_text("turn 2 response"),
assistant_text("turn 3 response"),
assistant_text("child turn 1 response"),
assistant_text("recovered turn response"),
]));
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider.clone())
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(HookExecutor::new()))
.session_memory_backend(store.clone())
.build()
.expect("RuntimeBundle 装配失败"),
);
let agent: Arc<dyn Agent> = Arc::new(DemoAgent);
// 2. 构造 SessionManagerauto_checkpoint 默认 true
let sm = SessionManager::new(store.clone());
println!("=== SessionManager 创建 ===");
// 3. create + submit_turnauto_checkpoint 触发)
println!("\n=== 创建根 session + 跑 3 轮 ===");
let parent_id = sm
.create(agent.clone(), bundle.clone())
.await
.expect("create 失败");
println!("parent_id = {parent_id}");
for i in 1..=3 {
let _resp = sm
.submit_turn(&parent_id, format!("turn {i}"))
.await
.expect("submit_turn 失败");
}
// 写入自定义 session memory 数据(演示持久层往返)
sm.get(&parent_id)
.await
.unwrap()
.lock()
.await
.set_session_data("design", "PostgreSQL")
.await
.unwrap();
// 4. 显式 checkpoint(覆盖 auto_checkpoint 的 turn-level,写入额外快照)
println!("\n=== Checkpointer 显式 checkpoint ===");
let ckpt_id = sm
.checkpointer()
.checkpoint(&*sm.get(&parent_id).await.unwrap().lock().await)
.await
.expect("checkpoint 失败");
println!("explicit ckpt_id = {ckpt_id}");
// 5. list_checkpoints
let metas = sm
.checkpointer()
.list_checkpoints(&parent_id)
.await
.expect("list_checkpoints 失败");
println!("parent session 有 {} 个 checkpoint", metas.len());
for m in &metas {
println!(
" - ckpt_id={}, turn_index={}, created_at={}",
m.ckpt_id, m.turn_index, m.created_at
);
}
// 6. create_child
println!("\n=== 创建子 session ===");
let child_id = sm
.create_child(&parent_id, agent.clone())
.await
.expect("create_child 失败");
println!("child_id = {child_id}");
let children = sm.children(&parent_id).await.expect("children 失败");
assert_eq!(children, vec![child_id.clone()]);
println!("children(parent) = {children:?}");
let parent_of_child = sm.parent(&child_id).await.expect("parent 失败");
assert_eq!(parent_of_child, Some(parent_id.clone()));
println!("parent({child_id}) = {parent_of_child:?}");
// 7. recover(模拟"进程重启"——新建 SessionManager 实例,但 store 复用)
println!("\n=== 从存储恢复 session(模拟进程重启)===");
let sm2 = SessionManager::new(store.clone());
let recovered = sm2
.recover(&parent_id, agent.clone(), bundle.clone())
.await
.expect("recover 失败");
let recovered_session = recovered.lock().await;
let v = recovered_session
.get_session_data("design")
.await
.expect("get_session_data 失败");
println!("recovered session_memory['design'] = {v:?}");
assert_eq!(v, Some("PostgreSQL".into()));
// 8. 演示 rollback 流程:先记录当前 turn_index,再 rollback 到一个早期 checkpoint
// 验证 session_memory 和 turn_index 已恢复到 checkpoint 时刻
println!("\n=== rollback 流程 ===");
let (before_turn, before_cost) = {
let s = sm.get(&parent_id).await.unwrap();
let g = s.lock().await;
(g.turn_index(), g.usage().total().total_tokens)
};
println!(
"rollback 前 turn_index={}, total_tokens={}",
before_turn, before_cost
);
let metas = sm
.checkpointer()
.list_checkpoints(&parent_id)
.await
.expect("list_checkpoints 失败");
assert!(metas.len() >= 2, "至少 2 个 checkpoint 才能演示 rollback");
// 取第二个 checkpoint(不是最新的)作为 rollback 目标
let rollback_ckpt = &metas[metas.len() - 2];
println!("rollback 到 ckpt_id={}", rollback_ckpt.ckpt_id);
let snapshot = sm
.checkpointer()
.rollback_load(&parent_id, &rollback_ckpt.ckpt_id)
.await
.expect("rollback_load 失败");
let snapshot_turn = snapshot.turn_index;
let snapshot_data_count = snapshot.session_memory_data.len();
println!(
"checkpoint 时刻 turn_index={}, session_memory 条目数={}",
snapshot_turn, snapshot_data_count
);
let mut rolled_back = AgentSession::from_snapshot(snapshot, agent.clone(), bundle.clone())
.expect("from_snapshot");
rolled_back
.restore_memory()
.await
.expect("restore_memory 失败");
sm.replace(&parent_id, rolled_back)
.await
.expect("replace 失败");
// 验证 rollback 后状态与 checkpoint 一致
let (after_turn, after_cost) = {
let s = sm.get(&parent_id).await.unwrap();
let g = s.lock().await;
(g.turn_index(), g.usage().total().total_tokens)
};
println!(
"rollback 后 turn_index={}, total_tokens={}",
after_turn, after_cost
);
assert!(
after_turn <= before_turn,
"rollback 后 turn_index({}) 应 ≤ rollback 前({})",
after_turn,
before_turn
);
assert_eq!(
after_turn, snapshot_turn,
"rollback 后 turn_index 应等于 checkpoint 时刻值"
);
assert!(
after_cost <= before_cost,
"rollback 后 cost 应 ≤ rollback 前"
);
println!("✓ rollback + replace 一致性验证通过");
// 9. destroy 父子 session
println!("\n=== 销毁 session ===");
sm.destroy(&child_id).await.expect("destroy child 失败");
sm.destroy(&parent_id).await.expect("destroy parent 失败");
// 验证清理
assert!(sm.get(&parent_id).await.is_err());
assert!(
sm.checkpointer()
.list_checkpoints(&parent_id)
.await
.unwrap()
.is_empty()
);
println!("✓ parent 已彻底清理(内存 + meta + checkpoints");
// 验证孤儿语义:父被销毁后子仍存在但 parent() 返回 None
println!("\n=== 孤儿策略演示(先创建父子,再仅销毁父)===");
let p_id = sm.create(agent.clone(), bundle.clone()).await.unwrap();
let c_id = sm.create_child(&p_id, agent.clone()).await.unwrap();
sm.destroy(&p_id).await.unwrap();
let p_of_c = sm.parent(&c_id).await.expect("parent 失败");
assert_eq!(p_of_c, None, "父被销毁后 child.parent() 应为 None");
println!("✓ child({c_id}) 仍是孤儿 sessionparent() = None");
// 清理孤儿
sm.destroy(&c_id).await.unwrap();
println!("\n✓ engine_demo 完成");
}
+205
View File
@@ -0,0 +1,205 @@
//! knowledge_graph_demo -- 知识图谱 + 双通道检索演示。
//! Required features: cargo run --example knowledge_graph_demo --features "memory"
//!
//! 演示:
//! 1. 构建 KnowledgeGraph(实体 + 关系)
//! 2. BFS 图遍历(get_related
//! 3. MemoryRetriever 双通道检索(Hybrid / GraphOnly / KnowledgeOnly
//! 4. 标签管理(set_entity_tags / find_tags
//!
//! 运行:`cargo run --example knowledge_graph_demo`
use std::sync::Arc;
use agcore::memory::{
GraphEntity, GraphRelation, InMemoryGraph, InMemoryStore, KnowledgeGraph, KnowledgePage,
KnowledgeStore, MemoryRetriever, MemoryStore, RelationDirection, RetrievalItem,
RetrievalStrategy, RetrieverConfig,
};
use time::OffsetDateTime;
fn make_page(id: &str, title: &str, content: &str) -> KnowledgePage {
let now = OffsetDateTime::now_utc();
KnowledgePage {
id: id.to_string(),
title: title.to_string(),
summary: content.chars().take(40).collect(),
content: content.to_string(),
tags: Vec::new(),
references: Vec::new(),
created_at: now,
updated_at: now,
}
}
#[tokio::main]
async fn main() {
// ── 1. 构建知识图谱 ──
println!("=== 1. 构建知识图谱 ===");
let graph = Arc::new(InMemoryGraph::new());
let mut langchain = GraphEntity::new("langchain", "LangChain", "framework");
langchain.description = "LLM application framework".to_string();
let mut langgraph = GraphEntity::new("langgraph", "LangGraph", "framework");
langgraph.description = "Graph-based agent runtime from LangChain".to_string();
let mut langsmith = GraphEntity::new("langsmith", "LangSmith", "tool");
langsmith.description = "Tracing and evaluation platform".to_string();
let mut python = GraphEntity::new("python", "Python", "language");
python.description = "Programming language".to_string();
let mut rust = GraphEntity::new("rust", "Rust", "language");
rust.description = "Systems programming language".to_string();
for e in [&langchain, &langgraph, &langsmith, &python, &rust] {
graph.add_entity(e.clone()).await.unwrap();
}
graph
.add_relation(GraphRelation::new(
"langchain",
"langgraph",
"includes",
0.9,
))
.await
.unwrap();
graph
.add_relation(GraphRelation::new(
"langchain",
"langsmith",
"includes",
0.7,
))
.await
.unwrap();
graph
.add_relation(GraphRelation::new(
"langchain",
"python",
"built_with",
0.95,
))
.await
.unwrap();
graph
.add_relation(GraphRelation::new("langgraph", "python", "depends_on", 0.8))
.await
.unwrap();
println!("已添加 5 个实体 + 4 条关系");
// ── 2. BFS 图遍历 ──
println!("\n=== 2. BFS 图遍历:从 LangChain 出发,depth=2 ===");
let related = graph
.get_related("langchain", 2, RelationDirection::Outgoing, None)
.await
.unwrap();
for se in &related {
println!(
" {} (score={:.3}, path={:?})",
se.entity.name, se.score, se.path
);
}
assert!(!related.is_empty(), "应找到关联实体");
// ── 3. 标签管理 ──
println!("\n=== 3. 标签管理 ===");
graph
.set_entity_tags(
"langchain",
vec!["ai".into(), "framework".into(), "llm".into()],
)
.await
.unwrap();
graph
.set_entity_tags("langgraph", vec!["ai".into(), "agent".into()])
.await
.unwrap();
let tags = graph.find_tags("a").await.unwrap();
println!("前缀 'a' 查找标签: {:?}", tags);
let count = graph.entity_count_by_tag("ai").await.unwrap();
println!("标签 'ai' 下实体数: {}", count);
// ── 4. 双通道检索 ──
println!("\n=== 4. 双通道检索 ===");
let store: Arc<dyn MemoryStore> = Arc::new(InMemoryStore::new());
let ks = KnowledgeStore::new(store);
ks.add_page(make_page(
"p1",
"LangChain 框架介绍",
"LangChain 是用于构建 LLM 应用的开源框架",
))
.await
.unwrap();
ks.add_page(make_page(
"p2",
"Rust 异步编程",
"Rust 异步基于 tokio 与 futures 抽象",
))
.await
.unwrap();
// Hybrid 策略(默认)
let retriever =
MemoryRetriever::new(ks, RetrieverConfig::default()).with_knowledge_graph(graph.clone());
println!("\n--- Hybrid 检索: 'langchain' ---");
let result = retriever.retrieve("langchain").await.unwrap();
println!("策略: {:?}", result.strategy);
for item in &result.items {
match item {
RetrievalItem::KnowledgePage { page, score } => {
println!(" [Store] {} (score={:.3})", page.title, score);
}
RetrievalItem::GraphEntity {
entity,
score,
path,
..
} => {
println!(
" [Graph] {} (score={:.3}, path={:?})",
entity.name, score, path
);
}
}
}
let has_store = result
.items
.iter()
.any(|i| matches!(i, RetrievalItem::KnowledgePage { .. }));
let has_graph = result
.items
.iter()
.any(|i| matches!(i, RetrievalItem::GraphEntity { .. }));
assert!(has_store, "Hybrid 应有 Store 结果");
assert!(has_graph, "Hybrid 应有 Graph 结果");
// GraphOnly 策略
let store2: Arc<dyn MemoryStore> = Arc::new(InMemoryStore::new());
let ks2 = KnowledgeStore::new(store2);
ks2.add_page(make_page("p1", "LangChain", "LLM framework"))
.await
.unwrap();
let retriever_g = MemoryRetriever::new(ks2, RetrieverConfig::default())
.with_knowledge_graph(graph.clone())
.with_strategy(RetrievalStrategy::GraphOnly);
println!("\n--- GraphOnly 检索: 'langchain' ---");
let result = retriever_g.retrieve("langchain").await.unwrap();
println!("策略: {:?}", result.strategy);
for item in &result.items {
match item {
RetrievalItem::GraphEntity { entity, score, .. } => {
println!(" [Graph] {} (score={:.3})", entity.name, score);
}
RetrievalItem::KnowledgePage { page, score, .. } => {
println!(" [Store] {} (score={:.3})", page.title, score);
}
}
}
assert!(
result
.items
.iter()
.all(|i| matches!(i, RetrievalItem::GraphEntity { .. })),
"GraphOnly 应只返回 Graph 结果"
);
println!("\n✓ knowledge_graph_demo 完成");
}
+30 -20
View File
@@ -1,4 +1,5 @@
//! knowledge_search_demo —— 知识页面存储与关键词检索。
//! Required features: cargo run --example knowledge_search_demo --features "memory"
//!
//! 演示:
//! 1. `KnowledgeStore` 存储多个 `KnowledgePage`
@@ -38,14 +39,26 @@ async fn main() {
let ks = KnowledgeStore::new(store);
let pages = vec![
make_page("rust-1", "Rust 入门", "Rust 是一门系统级编程语言,注重安全性与并发。"),
make_page("python-1", "Python 简介", "Python 是一门动态类型的高级编程语言。"),
make_page(
"rust-1",
"Rust 入门",
"Rust 是一门系统级编程语言,注重安全性与并发。",
),
make_page(
"python-1",
"Python 简介",
"Python 是一门动态类型的高级编程语言。",
),
make_page(
"langgraph-1",
"LangGraph 框架",
"LangGraph 是 LangChain 的状态图扩展,用于构建多步 Agent。",
),
make_page("rust-async", "Rust 异步编程", "Rust 异步基于 tokio 与 futures 抽象。"),
make_page(
"rust-async",
"Rust 异步编程",
"Rust 异步基于 tokio 与 futures 抽象。",
),
];
for p in &pages {
ks.add_page(p.clone()).await.expect("保存页面失败");
@@ -62,14 +75,15 @@ async fn main() {
let result = retriever.retrieve("Rust 异步").await.unwrap();
println!("query: {}", result.query);
for item in &result.items {
println!(
" 命中: {} (score={:.3})",
item.page.title, item.score
);
assert!(
(0.0..=1.0).contains(&item.score),
"score 应在 [0, 1] 区间"
);
match item {
agcore::memory::RetrievalItem::KnowledgePage { page, score } => {
println!(" 命中: {} (score={:.3})", page.title, score);
assert!((0.0..=1.0).contains(score), "score 应在 [0, 1] 区间");
}
agcore::memory::RetrievalItem::GraphEntity { entity, score, .. } => {
println!(" 命中实体: {} (score={:.3})", entity.name, score);
}
}
}
assert!(!result.items.is_empty(), "应至少命中一个页面");
@@ -83,16 +97,11 @@ async fn main() {
let cfg = RetrieverConfig {
max_results: 20,
min_score: 0.5,
graph_depth: 2,
};
let retriever2 = MemoryRetriever::new(ks2, cfg);
let result = retriever2
.retrieve("完全不相关的火锅配方")
.await
.unwrap();
println!(
"无关 query → items.len = {} (期望 0)",
result.items.len()
);
let result = retriever2.retrieve("完全不相关的火锅配方").await.unwrap();
println!("无关 query → items.len = {} (期望 0)", result.items.len());
assert!(result.items.is_empty());
// 4. max_results 截断
@@ -111,6 +120,7 @@ async fn main() {
let cfg = RetrieverConfig {
max_results: 2,
min_score: 0.0,
graph_depth: 2,
};
let retriever3 = MemoryRetriever::new(ks3, cfg);
let result = retriever3.retrieve("Rust").await.unwrap();
@@ -146,4 +156,4 @@ async fn main() {
assert!(only_stop.items.is_empty(), "纯停用词 query 必须返回空结果");
println!("\n✓ knowledge_search_demo 完成");
}
}
+12 -7
View File
@@ -1,4 +1,5 @@
//! prompt_composer —— 提示词模板与组合器离线示例。
//! Required features: cargo run --example prompt_composer --features "prompt,llm"
//!
//! 演示:
//! 1. `PromptTemplate::compile` + `render` 变量插值(`{{var}}` 语法)
@@ -11,7 +12,7 @@
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::prompt::{
validate_messages, PromptComposer, PromptTemplate, PromptTemplateRegistry, TemplateContext,
PromptComposer, PromptTemplate, PromptTemplateRegistry, TemplateContext, validate_messages,
};
fn message_text(msg: &Message) -> String {
@@ -27,16 +28,16 @@ fn message_text(msg: &Message) -> String {
})
.collect(),
Message::UserImage { .. } => "[image]".into(),
_ => String::new(),
}
}
fn main() {
// 1. PromptTemplate::compile + render —— 直接构造模板
println!("=== PromptTemplate::compile + render ===");
let tpl = PromptTemplate::compile(
"今日 {{location}} 天气:{{condition}},温度 {{temperature}}",
)
.expect("编译失败");
let tpl =
PromptTemplate::compile("今日 {{location}} 天气:{{condition}},温度 {{temperature}}")
.expect("编译失败");
let mut ctx = TemplateContext::new();
ctx.insert("location", "北京");
ctx.insert("condition", "");
@@ -58,7 +59,10 @@ fn main() {
.register("weather", "今日 {{location}}{{condition}}")
.expect("注册失败");
registry
.register("greet", "你好 {{name}}{{#if formal}} 见到您很荣幸。{{/if}}")
.register(
"greet",
"你好 {{name}}{{#if formal}} 见到您很荣幸。{{/if}}",
)
.expect("注册失败");
let mut ctx = TemplateContext::new();
@@ -88,6 +92,7 @@ fn main() {
Message::User { .. } | Message::UserImage { .. } => "user",
Message::Assistant { .. } => "assistant",
Message::ToolResult { .. } => "tool",
_ => "unknown",
};
println!("[{i}] {role}: {}", message_text(m));
}
@@ -105,4 +110,4 @@ fn main() {
}
println!("\n✓ prompt_composer 完成");
}
}
+97
View File
@@ -0,0 +1,97 @@
//! quick_start —— 30 行最小可运行示例,展示 Agent / BaseTool / Builder / Session 四层抽象。
//! Required features: cargo run --example quick_start --features "agent"
//!
//! 运行:`cargo run --example quick_start`(离线,零配置)
use agcore::agent::{Agent, AgentBuilder, AgentSession};
use agcore::llm::LlmProvider;
use agcore::llm::hooks::HookExecutor;
use agcore::llm::mock::MockProvider;
use agcore::llm::types::{
Usage,
message::{ContentBlock, Message},
response_v2::{MessageResponse, StopReason},
};
use agcore::tools::{BaseTool, ToolContext, ToolError, ToolRegistry};
use async_trait::async_trait;
use serde_json::{Value, json};
use std::sync::Arc;
struct Greeter;
impl Agent for Greeter {
fn name(&self) -> &str {
"greeter"
}
fn system_prompt(&self) -> Option<&str> {
Some("中文助手,先调用 echo 工具,再总结。")
}
}
struct EchoTool;
#[async_trait]
impl BaseTool for EchoTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"回显文本"
}
fn parameters(&self) -> Value {
json!({"type":"object","properties":{"text":{"type":"string"}},"required":["text"]})
}
async fn execute(&self, args: Value, _: &ToolContext<'_>) -> Result<Value, ToolError> {
let text = args.get("text").and_then(|v| v.as_str()).ok_or_else(|| {
ToolError::InvalidArguments("text".into(), "需要 string 类型的 text 参数".into())
})?;
Ok(json!({"echoed": format!("收到: {text}")}))
}
}
fn resp(content: Vec<ContentBlock>, stop: StopReason, u: (u32, u32)) -> MessageResponse {
MessageResponse {
id: String::new(),
model: "mock".into(),
message: Message::Assistant { content },
usage: Usage::from_input_output(u.0, u.1),
stop_reason: stop,
extra: Default::default(),
}
}
#[tokio::main]
async fn main() {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(EchoTool)).unwrap();
let provider: Arc<dyn LlmProvider> = Arc::new(MockProvider::new(vec![
resp(
vec![ContentBlock::ToolUse {
id: "c1".into(),
name: "echo".into(),
input: json!({"text": "你好"}),
}],
StopReason::ToolUse,
(5, 8),
),
resp(
vec![ContentBlock::Text {
text: "EchoTool 已收到您的消息并完成回传。".into(),
}],
StopReason::Stop,
(8, 16),
),
]));
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(registry))
.hook_executor(Arc::new(HookExecutor::new()))
.build()
.unwrap(),
);
let mut session = AgentSession::new(Arc::new(Greeter), "qs", bundle);
let resp = session.submit_turn("你好").await.unwrap();
let text = resp.text();
println!("LLM: {text}");
assert!(text.contains("收到"), "响应应包含'收到'字样: {text}");
println!("\n✓ quick_start 完成");
}
+82
View File
@@ -0,0 +1,82 @@
//! Required features: cargo run --example response_api_demo --features "full"
//!
//! 演示 OpenAI Response API`POST /responses`)的基本用法。
//!
//! 环境变量:
//! - `OPENAI_BASE_URL` — 默认 `https://api.openai.com/v1`
//! - `OPENAI_API_KEY` — 必填
//! - `OPENAI_MODEL` — 默认 `gpt-4o-mini`
//!
//! 本示例展示:
//! - 单轮对话 + 多轮接续(全量消息历史)
//! - 流式响应事件消费
//! - 工具调用(Function Calling)单轮演示
use std::env;
use agcore::llm::provider::{ProviderConfig, ProviderType, create_provider};
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::request_v2::MessageRequest;
#[tokio::main]
async fn main() {
let api_key = env::var("OPENAI_API_KEY").expect("未设置 OPENAI_API_KEY 环境变量");
let base_url =
env::var("OPENAI_BASE_URL").unwrap_or_else(|_| "https://api.openai.com/v1".to_string());
let model = env::var("OPENAI_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string());
let provider = create_provider(
ProviderType::OpenaiResponse,
ProviderConfig {
base_url,
api_key,
model,
timeout_secs: 30,
max_retries: 3,
},
)
.expect("创建 OpenAI Response Provider 失败");
// ===== 单轮对话 =====
let request = MessageRequest {
model: String::new(),
messages: vec![
Message::system("你是一个简洁的助手,对任何问题都用一句话回答。"),
Message::user_text("Rust 的所有权机制是什么?"),
],
..Default::default()
};
match provider.chat(request).await {
Ok(resp) => {
println!("[单轮] {}", resp.text());
println!(
"用量: {} 输入 / {} 输出\n",
resp.usage.prompt_tokens, resp.usage.completion_tokens
);
}
Err(e) => {
eprintln!("[单轮] 请求失败: {e}");
}
}
// ===== 多轮接续(全量历史) =====
let request = MessageRequest {
model: String::new(),
messages: vec![
Message::user_text("knock knock."),
Message::Assistant {
content: vec![ContentBlock::Text {
text: "Who's there?".into(),
}],
},
Message::user_text("Orange."),
],
..Default::default()
};
match provider.chat(request).await {
Ok(resp) => println!("[多轮] {}", resp.text()),
Err(e) => eprintln!("[多轮] 请求失败: {e}"),
}
}
+9 -6
View File
@@ -1,9 +1,11 @@
//! Required features: cargo run --example simple_visit --features "llm,provider-openai,tracing-init"
use std::env;
use agcore::init_tracing;
use agcore::llm::{
cycle::{CycleConfig, LlmCycle},
provider::{create_provider, ProviderConfig, ProviderType},
provider::{ProviderConfig, ProviderType, create_provider},
types::{message::ContentBlock, message::Message, response_v2::MessageResponse},
};
@@ -51,10 +53,11 @@ async fn main() {
base_url,
api_key,
model: model.clone(),
timeout_secs: 30,
max_retries: 3,
};
let provider = create_provider(provider_type, config)
.expect("创建 Provider 失败");
let provider = create_provider(provider_type, config).expect("创建 Provider 失败");
let cycle_config = CycleConfig {
model,
@@ -63,9 +66,9 @@ async fn main() {
..CycleConfig::default()
};
let mut cycle = LlmCycle::new(provider, cycle_config).with_messages(vec![
Message::system("你是一个简洁的助手,对于任何问题都是用一句话回答。"),
]);
let mut cycle = LlmCycle::new(provider, cycle_config).with_messages(vec![Message::system(
"你是一个简洁的助手,对于任何问题都是用一句话回答。",
)]);
println!("发送请求...");
+5 -6
View File
@@ -1,4 +1,5 @@
//! streaming_events_demo —— LLM 流式响应事件流消费(含错误路径)。
//! Required features: cargo run --example streaming_events_demo --features "llm,provider-openai"
//!
//! 演示:
//! 1. `MockProvider::chat_stream` 输出标准 `StreamEvent` 流(离线可跑)
@@ -14,12 +15,12 @@
use std::sync::Arc;
use agcore::llm::LlmProvider;
use agcore::llm::cycle::{CycleConfig, LlmCycle};
use agcore::llm::mock::MockProvider;
use agcore::llm::provider::LlmProvider;
use agcore::llm::types::Usage;
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::response_v2::{MessageResponse, StopReason, StreamEvent};
use agcore::llm::types::Usage;
use futures_util::StreamExt;
/// 构造预设的纯文本响应。
@@ -99,9 +100,7 @@ async fn main() {
// 上层 Agent 通过 `match` 或 `?` 处理 `AgentError::Llm(_)`。
println!("\n=== 阶段 2:错误路径(队列耗尽)===");
let mut cycle = LlmCycle::new_with_arc(dyn_provider, CycleConfig::default());
let result = cycle
.submit_stream("第二次提问".to_string(), vec![])
.await;
let result = cycle.submit_stream("第二次提问".to_string(), vec![]).await;
match result {
Ok(_) => panic!("阶段 2 必须失败(队列耗尽)"),
Err(e) => {
@@ -114,4 +113,4 @@ async fn main() {
}
println!("\n✓ streaming_events_demo 完成");
}
}
+142
View File
@@ -0,0 +1,142 @@
//! sub_agent_dispatch_demo —— SubAgent 并行派发示例。
//! Required features: cargo run --example sub_agent_dispatch_demo --features "engine"
//!
//! 演示:
//! 1. 创建父 session"主编" agent
//! 2. 并行 dispatch_all 3 个子 agent(研究员 / 写手 / 审校)
//! 3. 收集子任务结果
//! 4. 验证树形结构:children(parent_id) 应返回 3 个子 ID
//!
//! 运行:`cargo run --example sub_agent_dispatch_demo`
use std::sync::Arc;
use agcore::agent::{Agent, AgentBuilder};
use agcore::engine::{SessionManager, SubTaskResult};
use agcore::llm::hooks::HookExecutor;
use agcore::llm::mock::MockProvider;
use agcore::llm::types::Usage;
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
use agcore::memory::store::InMemoryStore;
use agcore::tools::ToolRegistry;
struct EditorAgent;
struct ResearcherAgent;
struct WriterAgent;
struct ReviewerAgent;
impl Agent for EditorAgent {
fn name(&self) -> &str {
"editor"
}
fn system_prompt(&self) -> Option<&str> {
Some("You are an editor coordinating a team.")
}
}
impl Agent for ResearcherAgent {
fn name(&self) -> &str {
"researcher"
}
fn system_prompt(&self) -> Option<&str> {
Some("You are a researcher. Provide 3 key findings.")
}
}
impl Agent for WriterAgent {
fn name(&self) -> &str {
"writer"
}
fn system_prompt(&self) -> Option<&str> {
Some("You are a writer. Draft a section.")
}
}
impl Agent for ReviewerAgent {
fn name(&self) -> &str {
"reviewer"
}
fn system_prompt(&self) -> Option<&str> {
Some("You are a reviewer. Check for accuracy.")
}
}
fn assistant_text(text: &str) -> MessageResponse {
MessageResponse {
id: String::new(),
model: String::new(),
message: Message::Assistant {
content: vec![ContentBlock::Text { text: text.into() }],
},
usage: Usage::from_input_output(8, 4),
stop_reason: StopReason::Stop,
extra: Default::default(),
}
}
fn print_result(name: &str, r: &Result<SubTaskResult, agcore::engine::EngineError>) {
match r {
Ok(res) => println!(
" ✓ {name} (child_id={}): {} tokens",
&res.child_id[..20.min(res.child_id.len())],
res.usage.total().total_tokens,
),
Err(e) => println!("{name}: {e}"),
}
}
#[tokio::main]
async fn main() {
println!("=== SubAgent Dispatch Demo ===\n");
let store: Arc<dyn agcore::memory::store::MemoryStore> = Arc::new(InMemoryStore::new());
let provider = Arc::new(MockProvider::new(vec![
assistant_text("Researcher: finding 1, 2, 3"),
assistant_text("Writer: section drafted"),
assistant_text("Reviewer: looks good"),
]));
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(HookExecutor::new()))
.session_memory_backend(store.clone())
.build()
.expect("RuntimeBundle"),
);
let editor: Arc<dyn Agent> = Arc::new(EditorAgent);
let researcher: Arc<dyn Agent> = Arc::new(ResearcherAgent);
let writer: Arc<dyn Agent> = Arc::new(WriterAgent);
let reviewer: Arc<dyn Agent> = Arc::new(ReviewerAgent);
let sm = Arc::new(SessionManager::new(store));
let parent_id = sm.create(editor, bundle.clone()).await.expect("create");
println!("[1] parent session created: {parent_id}");
// dispatch_all 3 个子 agent
println!("[2] dispatching 3 sub-agents in parallel...\n");
let results = sm
.dispatch_all(
&parent_id,
vec![
(researcher, "Research topic X".to_string()),
(writer, "Draft intro section".to_string()),
(reviewer, "Review draft".to_string()),
],
agcore::engine::DispatchConfig::default(),
)
.await;
print_result("researcher", &results[0]);
print_result("writer", &results[1]);
print_result("reviewer", &results[2]);
let success_count = results.iter().filter(|r| r.is_ok()).count();
assert_eq!(success_count, 3, "all 3 should succeed");
// 验证树形
let children = sm.children(&parent_id).await.expect("children");
println!("\n[3] children(parent) = {} session(s)", children.len());
assert_eq!(children.len(), 3);
println!("\n✓ dispatch_all completed: 3/3 sub-agents succeeded");
}
+27 -25
View File
@@ -1,4 +1,5 @@
//! task_agent_demo —— Plan 解析、Step 状态机、错误路径。
//! Required features: cargo run --example task_agent_demo --features "agent"
//!
//! 演示:
//! 1. `JsonPlanParser::parse` 解析合法 JSON 输入
@@ -8,27 +9,13 @@
//! 5. 错误路径:非法 JSON / 空 steps / 缺字段 → `AgentError::PlanParse`
//!
//! 运行:`cargo run --example task_agent_demo`
//!
//! ## 已知技术债(v0.2 迁移指南)
//!
//! 本示例使用 `#[deprecated]` 标记的旧 wire-format 类型:
//! - `ChatResponse`、`OpenaiChatMessage`、`FinishReason` —— `OpenaiChatProvider::chat_inner()`
//! 内部转换层仍在使用(参见 `docs/10a-phase0-types-and-trait.md` §2.5.1),
//! 故结构体定义保留。
//! - `StepStatus::Completed(ChatResponse)` —— 因为 `Step` 的"已完成"变体需携带
//! provider 响应,目前沿用旧的 `ChatResponse`。
//!
//! **触发迁移的条件**v0.2 引入 IR 层的 `StepResult` / 切换为 `MessageResponse`。
//! **迁移路径**:将本文件 `ChatResponse`/`OpenaiChatMessage`/`FinishReason` 替换为
//! `MessageResponse`/`Message`/`StopReason`,移除顶部 `#![allow(deprecated)]`。
//! 上层应用代码(`TaskAgent` 消费者)也可同步迁移。
#![allow(deprecated)]
use std::collections::HashMap;
use agcore::agent::{AgentError, JsonPlanParser, PlanParser, Step, StepStatus};
use agcore::llm::types::openai_message::OpenaiChatMessage;
use agcore::llm::types::shared::FinishReason;
use agcore::llm::types::{ChatResponse, Usage};
use agcore::llm::types::Usage;
use agcore::llm::types::message::Message;
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
#[tokio::main]
async fn main() {
@@ -67,21 +54,36 @@ async fn main() {
assert!(step.status.is_pending());
step.status = StepStatus::Running;
println!("Running: pending={}, terminal={}", step.status.is_pending(), step.status.is_terminal());
println!(
"Running: pending={}, terminal={}",
step.status.is_pending(),
step.status.is_terminal()
);
step.status = StepStatus::Completed(ChatResponse {
message: OpenaiChatMessage::assistant_text("天气:晴,22°C"),
step.status = StepStatus::Completed(MessageResponse {
id: String::new(),
model: "mock".into(),
message: Message::assistant("天气:晴,22°C"),
usage: Usage::from_input_output(5, 10),
stop_reason: Some(FinishReason::Stop),
stop_reason: StopReason::Stop,
extra: HashMap::new(),
});
println!("Completed: pending={}, terminal={}", step.status.is_pending(), step.status.is_terminal());
println!(
"Completed: pending={}, terminal={}",
step.status.is_pending(),
step.status.is_terminal()
);
assert!(step.status.is_terminal());
// 3. 失败路径
println!("\n=== Step 状态机:失败路径 ===");
let mut fail_step = Step::new(0, "调用天气 API");
fail_step.status = StepStatus::Failed(AgentError::Other("API 不可用".into()));
println!("Failed: pending={}, terminal={}", fail_step.status.is_pending(), fail_step.status.is_terminal());
println!(
"Failed: pending={}, terminal={}",
fail_step.status.is_pending(),
fail_step.status.is_terminal()
);
assert!(fail_step.status.is_terminal());
// 4. 跳过路径
@@ -110,4 +112,4 @@ async fn main() {
assert!(matches!(err, AgentError::PlanParse(_)));
println!("\n✓ task_agent_demo 完成");
}
}
+8 -1
View File
@@ -11,18 +11,25 @@
pub mod agent;
pub mod builder;
pub mod context;
pub mod error;
pub mod runtime;
pub mod session;
pub mod session_memory;
pub mod summary;
pub mod task;
// 重导出公共 API(按使用频度排序)
pub use agent::Agent;
pub use builder::AgentBuilder;
pub use context::{
ContextBudget, ContextSlot, DeriveStrategy, FocusedConfig, MergeStrategy, SlotConfig, SlotMeta,
SlotMode, SlotSource,
};
pub use error::AgentError;
pub use runtime::{AgentConfig, RuntimeBundle};
pub use session::AgentSession;
pub use session_memory::SessionMemory;
pub use task::{Plan, PlanParser, Step, StepStatus, TaskAgent};
pub use summary::SummaryConfig;
pub use task::JsonPlanParser;
pub use task::{Plan, PlanParser, Step, StepStatus, TaskAgent};
+2 -4
View File
@@ -7,14 +7,12 @@
//! - **不绑定业务循环**`submit_turn` 在 `AgentSession` 上,不在 trait 上
use crate::agent::runtime::RuntimeBundle;
#[allow(deprecated)]
use crate::llm::types::ToolDefinition;
use crate::llm::types::tool::ToolDef;
/// Agent 角色抽象。
///
/// 实现此 trait 即可接入 Agent Runtime。典型实现是 struct 持有静态配置(name、system prompt 模板),
/// 也可以是基于配置动态生成的轻量实现。
#[allow(deprecated)]
pub trait Agent: Send + Sync {
/// 角色名(用于日志、调试、UI 展示)。
fn name(&self) -> &str;
@@ -26,7 +24,7 @@ pub trait Agent: Send + Sync {
///
/// **默认实现**:从 `bundle.tool_registry` 取全部工具(最常用模式)。
/// **子 trait / 具体实现可覆盖**:做白名单、过滤、按状态动态调整等。
fn tool_definitions(&self, bundle: &RuntimeBundle) -> Vec<ToolDefinition> {
fn tool_definitions(&self, bundle: &RuntimeBundle) -> Vec<ToolDef> {
bundle.tool_registry.definitions()
}
}
+20 -8
View File
@@ -11,8 +11,9 @@ use std::sync::Arc;
use crate::agent::error::AgentError;
use crate::agent::runtime::{AgentConfig, RuntimeBundle};
use crate::agent::summary::SummaryConfig;
use crate::llm::LlmProvider;
use crate::llm::hooks::HookExecutor;
use crate::llm::provider::LlmProvider;
use crate::memory::retriever::MemoryRetriever;
use crate::memory::store::MemoryStore;
use crate::tools::ToolRegistry;
@@ -86,21 +87,32 @@ impl AgentBuilder {
self
}
/// 设置摘要自动生成配置(覆盖字段,而非整体覆盖 config)。
/// 不传则沿用现有 `config.summary_config`(默认 `None`,即关闭)。
pub fn summary_config(mut self, cfg: SummaryConfig) -> Self {
let mut config = self.config.take().unwrap_or_default();
config.summary_config = Some(cfg);
self.config = Some(config);
self
}
/// 构造 `RuntimeBundle`,校验必填字段。
///
/// **错误**`provider` / `tool_registry` / `hook_executor` 任一缺失则返回
/// `AgentError::Config(...)`,提示调用 `.provider(...)` / `.tool_registry(...)` /
/// `.hook_executor(...)` 补齐。不 panic。
pub fn build(self) -> Result<RuntimeBundle, AgentError> {
let provider = self
.provider
.ok_or_else(|| AgentError::Config("缺少 LLM provider,请先调用 .provider(...)".into()))?;
let provider = self.provider.ok_or_else(|| {
AgentError::Config("缺少 LLM provider,请先调用 .provider(...)".into())
})?;
let tool_registry = self
.tool_registry
.ok_or_else(|| AgentError::Config("缺少 tool_registry,请先调用 .tool_registry(...)(即使是空 ToolRegistry 也需要传入)".into()))?;
let hook_executor = self
.hook_executor
.ok_or_else(|| AgentError::Config("缺少 hook_executor,请先调用 .hook_executor(...)(空 HookExecutor 也可)".into()))?;
let hook_executor = self.hook_executor.ok_or_else(|| {
AgentError::Config(
"缺少 hook_executor,请先调用 .hook_executor(...)(空 HookExecutor 也可)".into(),
)
})?;
let config = self.config.unwrap_or_default();
@@ -120,9 +132,9 @@ impl AgentBuilder {
mod tests {
use super::*;
use crate::llm::error::LlmError;
use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};
use async_trait::async_trait;
use futures_core::Stream;
use std::pin::Pin;
+1216
View File
File diff suppressed because it is too large Load Diff
+54 -3
View File
@@ -18,6 +18,7 @@ use crate::tools::error::ToolError;
/// **不实现 `Clone`**:透传内层 `LlmError` / `MemoryError`,两者均未派生 `Clone`(保留
/// 完整错误信息,传递所有权)。如需在多 session 间共享错误状态,用 `Arc<AgentError>` 包装。
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum AgentError {
/// LLM 调用错误(透传 Phase 0)。
#[error("LLM 错误: {0}")]
@@ -35,6 +36,18 @@ pub enum AgentError {
#[error("Plan 解析错误: {0}")]
PlanParse(String),
/// Readonly slot 不允许写入(Phase 10 新增)。
#[error("Readonly slot 不允许写入: {0}")]
SlotReadonly(String),
/// Slot 不存在(Phase 10 新增)。
#[error("Slot '{0}' 不存在")]
SlotNotFound(String),
/// Slot 已存在(Phase 10 新增)。
#[error("Slot '{0}' 已存在")]
SlotAlreadyExists(String),
/// 钩子阻断操作(Agent 层特有)。
#[error("钩子阻断: {0}")]
HookBlocked(String),
@@ -59,6 +72,7 @@ impl AgentError {
/// - `Tool`:由内层 `is_recoverable()` 决定
/// - `HookBlocked` / `LimitExceeded`:不可恢复(需人工介入或终止循环)
/// - `Config` / `Other`:不可恢复
/// - `SlotReadonly` / `SlotNotFound` / `SlotAlreadyExists`:不可恢复(结构性错误)
pub fn is_recoverable(&self) -> bool {
match self {
Self::Llm(e) => matches!(
@@ -68,9 +82,13 @@ impl AgentError {
Self::Tool(e) => e.is_recoverable(),
Self::Memory(e) => e.is_recoverable(),
Self::PlanParse(_) => false,
Self::HookBlocked(_) | Self::LimitExceeded(_) | Self::Config(_) | Self::Other(_) => {
false
}
Self::SlotReadonly(_)
| Self::SlotNotFound(_)
| Self::SlotAlreadyExists(_)
| Self::HookBlocked(_)
| Self::LimitExceeded(_)
| Self::Config(_)
| Self::Other(_) => false,
}
}
}
@@ -180,4 +198,37 @@ mod tests {
let err = caller().unwrap_err();
assert!(matches!(err, AgentError::Memory(_)));
}
// ====== Phase 10: Slot 错误变体测试 ======
#[test]
fn slot_readonly_not_recoverable() {
assert!(!AgentError::SlotReadonly("readonly".into()).is_recoverable());
}
#[test]
fn slot_not_found_not_recoverable() {
assert!(!AgentError::SlotNotFound("missing".into()).is_recoverable());
}
#[test]
fn slot_already_exists_not_recoverable() {
assert!(!AgentError::SlotAlreadyExists("dup".into()).is_recoverable());
}
#[test]
fn slot_error_messages() {
assert_eq!(
format!("{}", AgentError::SlotReadonly("readonly".into())),
"Readonly slot 不允许写入: readonly"
);
assert_eq!(
format!("{}", AgentError::SlotNotFound("foo".into())),
"Slot 'foo' 不存在"
);
assert_eq!(
format!("{}", AgentError::SlotAlreadyExists("bar".into())),
"Slot 'bar' 已存在"
);
}
}
+7 -1
View File
@@ -15,8 +15,9 @@
use std::sync::Arc;
use std::time::Duration;
use crate::agent::summary::SummaryConfig;
use crate::llm::LlmProvider;
use crate::llm::compact::CompactConfig;
use crate::llm::provider::LlmProvider;
use crate::llm::hooks::HookExecutor;
use crate::memory::retriever::MemoryRetriever;
use crate::memory::store::MemoryStore;
@@ -33,6 +34,10 @@ pub struct AgentConfig {
pub session_ttl: Option<Duration>,
/// 上下文压缩配置(None 表示不启用自动压缩),默认 None。
pub compact_config: Option<CompactConfig>,
/// 摘要自动生成配置(`None` = 不启用)。
/// 设置后 `AgentSession` 每轮 OnTurnEnd 之后进行水位 + 防抖检查,触发时调 LLM
/// 生成摘要并写入 `FocusedConfig.summary_override` 与 `SessionMemory["conversation_summary"]`。
pub summary_config: Option<SummaryConfig>,
}
impl Default for AgentConfig {
@@ -42,6 +47,7 @@ impl Default for AgentConfig {
max_tool_turns: 10,
session_ttl: None,
compact_config: None,
summary_config: None,
}
}
}
+1418 -100
View File
File diff suppressed because it is too large Load Diff
+59 -13
View File
@@ -44,12 +44,38 @@ impl SessionMemory {
}
/// 写入一条 key-value 条目(覆盖同名 key)。
///
/// **不保留 metadata 和 created_at** —— 写入时 metadata 为空 JSON `{}`created_at 为 `now_utc()`。
/// 若需保留这两个字段(如 checkpoint rollback),使用 [`Self::set_with_meta`]。
pub async fn set(&self, key: &str, value: &str) -> Result<(), AgentError> {
self.set_with_meta(key, value, serde_json::json!({}), None)
.await
}
/// 写入一条 key-value 条目(含完整 metadata + created_at)。
///
/// Phase 17 新增:供 `AgentSession::restore_memory()` 使用,保证 checkpoint rollback 时
/// 恢复完整的 session_memory 条目(包括原 metadata 和创建时间戳)。
///
/// - `metadata`: 通常为 `serde_json::Value`(快照中保留的 metadata JSON
/// - `created_at`: 快照中的原始时间戳(Unix 秒);若为 `None` 则用 `now_utc()`(默认行为)
pub async fn set_with_meta(
&self,
key: &str,
value: &str,
metadata: serde_json::Value,
created_at: Option<i64>,
) -> Result<(), AgentError> {
let created_at_dt = match created_at {
Some(secs) => OffsetDateTime::from_unix_timestamp(secs)
.unwrap_or_else(|_| OffsetDateTime::now_utc()),
None => OffsetDateTime::now_utc(),
};
let item = MemoryItem {
id: self.internal_key(key),
content: value.to_string(),
metadata: serde_json::json!({}),
created_at: OffsetDateTime::now_utc(),
metadata,
created_at: created_at_dt,
};
self.store.save(item).await.map_err(AgentError::Memory)
}
@@ -78,11 +104,7 @@ impl SessionMemory {
prefix: Some(format!("{}:", self.namespace)),
..Default::default()
};
let items = self
.store
.list(&filter)
.await
.map_err(AgentError::Memory)?;
let items = self.store.list(&filter).await.map_err(AgentError::Memory)?;
let mut lines = Vec::with_capacity(items.len() + 2);
lines.push("<session-context>".to_string());
@@ -107,17 +129,41 @@ impl SessionMemory {
.map_err(AgentError::Memory)
}
/// 列出当前 namespace 下所有条目(含完整 `MemoryItem`value / metadata / created_at)。
///
/// Phase 17 新增:供 `AgentSession::to_snapshot()` 拍平 session_memory 时使用,
/// 保留 metadata 和 created_at 时间戳(用 `set/get/remove` 三个 API 会丢字段)。
///
/// 返回 `Vec<(原始 key, value, metadata, created_at_unix_secs)>`,原始 key 已剥离 namespace 前缀。
pub async fn list_entries(
&self,
) -> Result<Vec<(String, String, serde_json::Value, i64)>, AgentError> {
let filter = MemoryFilter {
prefix: Some(format!("{}:", self.namespace)),
..Default::default()
};
let items = self.store.list(&filter).await.map_err(AgentError::Memory)?;
let prefix_with_colon = format!("{}:", self.namespace);
let mut out = Vec::with_capacity(items.len());
for item in items {
let key = item
.id
.strip_prefix(&prefix_with_colon)
.unwrap_or(&item.id)
.to_string();
let created_at_unix = item.created_at.unix_timestamp();
out.push((key, item.content, item.metadata, created_at_unix));
}
Ok(out)
}
/// 清空当前 namespace 下所有条目。
pub async fn clear(&self) -> Result<(), AgentError> {
let filter = MemoryFilter {
prefix: Some(format!("{}:", self.namespace)),
..Default::default()
};
let items = self
.store
.list(&filter)
.await
.map_err(AgentError::Memory)?;
let items = self.store.list(&filter).await.map_err(AgentError::Memory)?;
for item in items {
self.store
@@ -181,4 +227,4 @@ mod tests {
assert!(mem_a.get("key").await.unwrap().is_none());
assert_eq!(mem_b.get("key").await.unwrap(), Some("val_b".into()));
}
}
}
+246
View File
@@ -0,0 +1,246 @@
//! 摘要自动生成 —— 在长对话中自动压缩上下文。
//!
//! 通过 `AgentSession` 内联检查点检测 token 水位,调用 LLM 生成摘要,
//! 写入 `FocusedConfig.summary_override` 与 `SessionMemory["conversation_summary"]`。
//!
//! 关闭端位于 `FocusedConfig::filter_focused`(见 `agent/context.rs`)。
use crate::llm::types::message::{ContentBlock, Message};
/// 默认摘要 prompt(含 `{messages}` 占位符,运行期替换为对话历史文本)。
pub const DEFAULT_SUMMARY_PROMPT: &str = "请为以下对话生成一个简洁的中文摘要,突出关键结论、用户偏好和重要上下文信息。保持客观,不要添加对话中不存在的信息。\n\n{messages}";
/// 摘要自动生成配置(opt-in:通过 `AgentBuilder::summary_config(cfg)` 启用)。
#[derive(Debug, Clone)]
pub struct SummaryConfig {
/// Token 水位触发比例(0.0 ~ 1.0)。
pub trigger_token_ratio: f64,
/// 模型上下文窗口大小(token)。
/// ⚠️ 设置为超过模型实际窗口的值会导致摘要永远不触发。
pub max_context_tokens: u32,
/// 摘要 prompt 模板。`{messages}` 将被替换为对话历史纯文本。
pub summary_prompt: String,
/// 摘要间隔防抖(轮次):两次摘要至少间隔这么多次 `submit_turn`。
pub debounce_turns: u32,
/// 摘要生成使用的模型(`None` = 沿用主 provider 默认模型)。
/// 推荐设为便宜模型(如 `"gpt-4o-mini"`)以节省摘要成本。
pub summary_model: Option<String>,
/// 单个 `ToolResult` 在摘要输入中保留的最大 Unicode 字符数。
/// 超过此值从开头截断(`chars().take(n)`,字符级安全)。
pub max_tool_result_chars: usize,
}
impl Default for SummaryConfig {
fn default() -> Self {
Self {
trigger_token_ratio: 0.75,
max_context_tokens: 32_000,
summary_prompt: DEFAULT_SUMMARY_PROMPT.into(),
debounce_turns: 3,
summary_model: None,
max_tool_result_chars: 500,
}
}
}
/// 把消息列表格式化为摘要 LLM 所需的纯文本(简洁版)。
///
/// 每行一条消息:
/// - `System/User/Assistant` 取首个 `Text` block 拼接
/// - `Assistant` 中的 `ToolUse` 标记为 `[Tool: {name}]`
/// - `ToolResult` 标记为 `Tool Result [{tool_call_id}]:`(含 tool_call_id 以便多工具场景关联)
/// - 长 `ToolResult` 截断到 `max_tool_result_chars` 个字符
///
/// 整段对话若超过 `30_000` 字符,从前面截断,**优先保留最新消息**,
/// 因为新近交互对摘要而言更有信息量。
pub fn format_messages_as_text(messages: &[Message], max_tool_result_chars: usize) -> String {
let mut lines = Vec::with_capacity(messages.len());
for msg in messages {
match msg {
Message::System { content } => {
if let Some(text) = first_text(content) {
lines.push(format!("System: {}", text));
}
}
Message::User { content } => {
if let Some(text) = first_text(content) {
lines.push(format!("User: {}", text));
}
}
Message::Assistant { content } => {
let mut parts = Vec::new();
for block in content {
match block {
ContentBlock::Text { text } => parts.push(text.clone()),
ContentBlock::ToolUse { name, .. } => {
parts.push(format!("[Tool: {}]", name));
}
ContentBlock::Thinking { text, .. } => {
parts.push(format!("[Thinking: {}]", truncate_chars(text, 100)));
}
_ => {}
}
}
if !parts.is_empty() {
lines.push(format!("Assistant: {}", parts.join(" ")));
}
}
Message::UserImage { .. } => {
lines.push("User: [image]".to_string());
}
Message::ToolResult {
tool_call_id,
content,
is_error,
} => {
let label = if *is_error {
"Tool Error"
} else {
"Tool Result"
};
if let Some(text) = first_text(content) {
let truncated = truncate_chars(text, max_tool_result_chars);
lines.push(format!("{} [{}]: {}", label, tool_call_id, truncated));
}
}
}
}
let joined = lines.join("\n");
truncate_total_chars(&joined, MAX_TOTAL_CHARS)
}
/// 整段对话输出字符上限。超过时从前面截断,保留尾部最新消息。
const MAX_TOTAL_CHARS: usize = 30_000;
fn truncate_total_chars(s: &str, max_chars: usize) -> String {
let total = s.chars().count();
if total <= max_chars {
return s.to_string();
}
// 计算需要从前面丢弃的字符数。保留窗口从 (total - max_chars) 开始。
let skip = total - max_chars;
let dropped: String = s.chars().take(skip).collect();
let mut kept = String::with_capacity(max_chars + 8);
kept.push_str("[... earlier messages truncated ...]\n");
// 字节切安全:dropped 由 s.chars().take(skip).collect() 构建,
// 每个 char 的 UTF-8 字节序列完整保留,故 dropped.len() 恰好是 s 的某个 char 边界字节偏移。
kept.push_str(&s[dropped.len()..]);
kept
}
fn first_text(content: &[ContentBlock]) -> Option<&str> {
content.iter().find_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
}
fn truncate_chars(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_string();
}
let truncated: String = s.chars().take(max_chars).collect();
format!("{}...", truncated)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_values() {
let cfg = SummaryConfig::default();
assert_eq!(cfg.trigger_token_ratio, 0.75);
assert_eq!(cfg.max_context_tokens, 32_000);
assert_eq!(cfg.debounce_turns, 3);
assert_eq!(cfg.max_tool_result_chars, 500);
assert!(cfg.summary_model.is_none());
assert!(cfg.summary_prompt.contains("{messages}"));
}
#[test]
fn format_skips_empty_input() {
let text = format_messages_as_text(&[], 500);
assert!(text.is_empty());
}
#[test]
fn format_user_assistant_round_trip() {
let msgs = vec![
Message::system("you are a translator"),
Message::user_text("hello"),
Message::assistant("hi"),
];
let text = format_messages_as_text(&msgs, 500);
assert!(text.contains("System: you are a translator"));
assert!(text.contains("User: hello"));
assert!(text.contains("Assistant: hi"));
}
#[test]
fn format_tool_result_includes_tool_call_id() {
let msgs = vec![Message::tool_result("call_42", "ok", false)];
let text = format_messages_as_text(&msgs, 500);
assert_eq!(text, "Tool Result [call_42]: ok");
}
#[test]
fn format_tool_result_error_label() {
let msgs = vec![Message::tool_result("call_9", "boom", true)];
let text = format_messages_as_text(&msgs, 500);
assert_eq!(text, "Tool Error [call_9]: boom");
}
#[test]
fn format_tool_use_in_assistant() {
let msgs = vec![Message::Assistant {
content: vec![
ContentBlock::Text {
text: "let me search".into(),
},
ContentBlock::ToolUse {
id: "c1".into(),
name: "search".into(),
input: serde_json::json!({"q": "rust"}),
},
],
}];
let text = format_messages_as_text(&msgs, 500);
assert_eq!(text, "Assistant: let me search [Tool: search]");
}
#[test]
fn format_truncates_long_tool_result_at_unicode_boundary() {
let long = "a".repeat(1000);
let msgs = vec![Message::tool_result("c", &long, false)];
let text = format_messages_as_text(&msgs, 100);
// 100 chars + "..."
assert!(text.contains("..."));
let truncated_part = text.split("...").next().unwrap();
// "Tool Result [c]: " is 18 chars, plus 100 a's
let a_count = truncated_part.chars().filter(|c| *c == 'a').count();
assert_eq!(a_count, 100);
}
#[test]
fn format_total_charset_truncation_keeps_recent() {
// 50 段 user 消息,每段 1000 字符 = ~50K,触发 30K 整体截断
let mut msgs = Vec::new();
for _ in 0..50 {
msgs.push(Message::user_text("x".repeat(1000)));
}
let text = format_messages_as_text(&msgs, 500);
// 总字符数 ≤ 30K + prefix "[... earlier messages truncated ...]\n"
assert!(text.chars().count() <= 30_000 + 40);
// 头部有截断标记
assert!(text.contains("[... earlier messages truncated ...]"));
// 最后一行的标记字符 (30 个 x) 应保留在末尾
assert!(text.ends_with("xxxxxxxxxx"));
}
}
+5 -11
View File
@@ -10,8 +10,7 @@
//! - 重试由上层新建 `Plan` 实现,`TaskAgent` 不做自动重试
use crate::agent::error::AgentError;
#[allow(deprecated)]
use crate::llm::types::ChatResponse;
use crate::llm::types::response_v2::MessageResponse;
use async_trait::async_trait;
@@ -56,14 +55,14 @@ impl Step {
/// 均未派生 `Clone`(保留原始错误信息,传递所有权而非克隆)。如需复制 `Plan`,
/// 只能 clone 处于 `Pending` / `Running` / `Completed` / `Skipped` 状态的步骤。
#[derive(Debug)]
#[allow(deprecated)]
#[non_exhaustive]
pub enum StepStatus {
/// 初始状态 —— 等待执行。
Pending,
/// 正在执行(`TaskAgent::execute_plan` 进入)。
Running,
/// 已完成(含 LLM 响应)。
Completed(ChatResponse),
Completed(MessageResponse),
/// 失败(含错误)。
Failed(AgentError),
/// 跳过(上层主动跳过)。
@@ -130,9 +129,7 @@ impl PlanParser for JsonPlanParser {
.collect::<Result<Vec<_>, AgentError>>()?;
if steps.is_empty() {
return Err(AgentError::PlanParse(
"Plan 至少需要一个步骤".into(),
));
return Err(AgentError::PlanParse("Plan 至少需要一个步骤".into()));
}
Ok(Plan {
@@ -203,10 +200,7 @@ mod tests {
let plan = Plan {
id: "p1".into(),
goal: "test goal".into(),
steps: vec![
Step::new(0, "first"),
Step::new(1, "second"),
],
steps: vec![Step::new(0, "first"), Step::new(1, "second")],
};
assert_eq!(plan.steps.len(), 2);
assert_eq!(plan.steps[0].index, 0);
+630
View File
@@ -0,0 +1,630 @@
//! Document 系统 —— 文本分割与文档类型。
//!
//! 提供 [`Document`] 数据结构和 [`RecursiveCharacterSplitter`] 分割器,
//! 作为 RAG 管线(split → embed → store)的前置步骤。
use std::collections::HashMap;
/// 默认分隔符优先级列表(按优先级降序)。
///
/// 段落级 → 行级 → 句子级(含 CJK 标点) → 词级 → 字符级(兜底)。
/// 在 LangChain 基础上扩充了 CJK 句号 `"。"`、问号 `""`、感叹号 `""`,
/// 确保中文文本在句子边界有更高分割质量。
const DEFAULT_SEPARATORS: &[&str] = &["\n\n", "\n", "", "", "", ".", " ", ""];
/// 文档片段 —— RAG 管线的基本数据载体。
///
/// 作为分割(split)和向量化(embed)两个阶段的通货类型,
/// 在 Phase 15 的 RagPipeline 中串联 split → embed → store。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Document {
/// 文档唯一标识。
pub id: String,
/// 文档文本内容。
pub content: String,
/// 元数据标签(键值对,可用作过滤、溯源、分类)。
pub metadata: HashMap<String, String>,
/// MIME 类型,标识内容格式(如 "text/plain", "text/markdown")。
pub mime_type: String,
}
impl Document {
/// 创建一个新文档。元数据默认初始化为空。
///
/// 分割器产生的 chunks 会自动继承源文档 mime_type
/// 并在 metadata 中追加 source_id / chunk_index / chunk_count。
pub fn new(
id: impl Into<String>,
content: impl Into<String>,
mime_type: impl Into<String>,
) -> Self {
Self {
id: id.into(),
content: content.into(),
metadata: HashMap::new(),
mime_type: mime_type.into(),
}
}
/// 快速构造纯文本文档(mime_type 默认为 "text/plain")。
/// 适用于大多数无需指定媒体类型的场景。
pub fn from_raw(id: impl Into<String>, content: impl Into<String>) -> Self {
Self {
id: id.into(),
content: content.into(),
metadata: HashMap::new(),
mime_type: "text/plain".into(),
}
}
}
/// 递归字符级文档分割器。
///
/// 使用可配置的分隔符优先级列表,递归地将文档分割为
/// 接近 chunk_size 的块。
///
/// # 算法(两阶段)
///
/// 1. **递归分割**:按分隔符优先级从高到低递归切割文本,
/// 产生初始片段(均 ≤ chunk_size,按字符数计算)。
///
/// 2. **贪心合并**:从左向右合并相邻片段,直到合计字符数
/// 超过 chunk_size,此时将前一组合并结果作为一个 chunk 输出,
/// 并携带 chunk_overlap 字符的滑动窗口。
///
/// 所有长度比较均以 Unicode 字符数为单位(`text.chars().count()`),
/// 而非字节数。CJK 文本每个字算 1 个 char。
///
/// # 升级路径
///
/// - 如需自定义分割函数,可在上层通过 `with_custom_splitter`
/// 扩展(当前未实现,预留升级路径)。
/// - 如需 unicode 感知的句子分割(如中文句号、缩写处理),
/// 可在 separators 中加入对应字符串,或将下游替换为
/// 基于 unicode-segmentation crate 的自定义分割器。
#[derive(Debug, Clone)]
pub struct RecursiveCharacterSplitter {
chunk_size: usize,
chunk_overlap: usize,
separators: Vec<String>,
}
impl RecursiveCharacterSplitter {
/// 创建分割器。
///
/// # Panics
///
/// - 如果 `chunk_size == 0`
/// - 如果 `chunk_size ≤ chunk_overlap`(无法形成有效滑动窗口)
pub fn new(chunk_size: usize, chunk_overlap: usize) -> Self {
if chunk_size == 0 {
panic!("chunk_size must be greater than 0");
}
if chunk_size <= chunk_overlap {
panic!("chunk_size must be greater than chunk_overlap");
}
Self {
chunk_size,
chunk_overlap,
separators: DEFAULT_SEPARATORS.iter().map(|s| s.to_string()).collect(),
}
}
/// 创建分割器的安全版本。
///
/// 验证失败时返回 `Err` 而非 panic。
pub fn try_new(chunk_size: usize, chunk_overlap: usize) -> Result<Self, &'static str> {
if chunk_size == 0 {
return Err("chunk_size must be greater than 0");
}
if chunk_size <= chunk_overlap {
return Err("chunk_size must be greater than chunk_overlap");
}
Ok(Self {
chunk_size,
chunk_overlap,
separators: DEFAULT_SEPARATORS.iter().map(|s| s.to_string()).collect(),
})
}
/// 覆盖默认分隔符优先级列表。
///
/// **重要**:建议保留 `""` 作为最后一个 separator
/// 作为字符级兜底防止任何文本都能被分割。
pub fn with_separators(mut self, separators: Vec<String>) -> Self {
self.separators = separators;
self
}
/// 返回 chunk_size(字符数)。
pub fn chunk_size(&self) -> usize {
self.chunk_size
}
/// 返回 chunk_overlap(字符数)。
pub fn chunk_overlap(&self) -> usize {
self.chunk_overlap
}
/// 批量分割。
///
/// 每个输入文档独立分割。输出 chunks 继承源文档的 mime_type
/// 并在 metadata 中追加 source_id / chunk_index / chunk_count。
///
/// Chunk ID 格式:`{source_id}:chunk:{index:04d}`
/// 例如 `"doc_001:chunk:0000"`(索引从 0 开始,4 位固定宽度)。
///
/// **注意**metadata 注入使用 `HashMap::insert()`,如果源 Document
/// 的 metadata 已包含 `"source_id"`、`"chunk_index"` 或 `"chunk_count"`
/// 键,将被分割器的值静默覆盖。
pub fn split(&self, documents: &[Document]) -> Vec<Document> {
tracing::debug!(
input_count = documents.len(),
"RecursiveCharacterSplitter::split start"
);
let mut output = Vec::new();
for doc in documents {
let segments = self.split_text(&doc.content, &self.separators);
let chunks = self.merge_with_overlap(segments);
debug_assert!(
chunks.len() < 10_000,
"单个文档产生超过 9999 个 chunk,索引格式溢出"
);
tracing::trace!(
doc_id = %doc.id,
chunk_count = chunks.len(),
"document split into chunks"
);
for (idx, chunk_text) in chunks.iter().enumerate() {
let mut metadata = doc.metadata.clone();
metadata.insert("source_id".to_string(), doc.id.clone());
metadata.insert("chunk_index".to_string(), idx.to_string());
metadata.insert("chunk_count".to_string(), chunks.len().to_string());
let id = format!("{}:chunk:{:04}", doc.id, idx);
tracing::trace!(chunk_id = %id, "chunk produced");
output.push(Document {
id,
content: chunk_text.clone(),
metadata,
mime_type: doc.mime_type.clone(),
});
}
}
output
}
/// 递归分割(Phase 1)。
///
/// 按 separator 优先级从高到低切割文本。每个输出片段的字符数
/// 均 ≤ chunk_size(除非最终降到 `""` 字符级兜底)。
///
/// Phase 1 只做"切分",不做合并——合并由 Phase 2 (`merge_with_overlap`) 处理。
///
/// **关键行为**:当文本中存在 separator 时,按 separator 切分。
/// 若所有 segment 均 ≤ chunk_size,直接返回所有 segments
/// 若某个 segment > chunk_size,递归降级到下一级 separator。
///
/// **早返回守卫**:如果整段文本 ≤ chunk_size(含恰好等于),直接
/// 返回 `[text.to_string()]`,避免在 Phase 2 合并时丢失 separator
/// 边界信息。
fn split_text(&self, text: &str, separators: &[String]) -> Vec<String> {
if text.is_empty() {
return Vec::new();
}
// 早返回:整段文本 ≤ chunk_size 时整体返回,避免分割后再
// 合并时丢失 separator 边界
if chars_len(text) <= self.chunk_size {
return vec![text.to_string()];
}
if separators.is_empty() {
// 防御:理论上不应到达这里(DEFAULT_SEPARATORS 末尾有 `""`
return self.split_by_chars(text);
}
let sep = &separators[0];
if sep.is_empty() {
// 字符级兜底
return self.split_by_chars(text);
}
// 检查文本中是否包含当前 separator
if !text.contains(sep.as_str()) {
// 不含此 separator,降级到下一级
return self.split_text(text, &separators[1..]);
}
// 文本中存在 separator,按 separator 切分
let raw_segments: Vec<&str> = text.split(sep.as_str()).collect();
let mut result = Vec::new();
for seg in raw_segments {
if seg.is_empty() {
continue;
}
if chars_len(seg) > self.chunk_size {
// 当前片段超长:递归降级到下一级 separator
result.extend(self.split_text(seg, &separators[1..]));
} else {
// 当前片段符合 chunk_size,直接输出
result.push(seg.to_string());
}
}
result
}
/// 字符级兜底分割(确保任何文本都能被切到 chunk_size 以内)。
///
/// 使用 `char_indices()` 步进,避免截断在多字节 UTF-8 字符中间。
fn split_by_chars(&self, text: &str) -> Vec<String> {
let mut result = Vec::new();
let mut current = String::new();
for (_, ch) in text.char_indices() {
current.push(ch);
if chars_len(&current) >= self.chunk_size {
result.push(std::mem::take(&mut current));
}
}
if !current.is_empty() {
result.push(current);
}
result
}
/// 贪心合并 + overlap 滑动窗口(Phase 2)。
///
/// 把 Phase 1 输出的 segments 合并到目标 chunk_size,并对相邻 chunk
/// 应用 chunk_overlap 字符的重叠窗口。
///
/// **已知行为**:合并时使用空字符串 `""` 连接相邻 segments
/// (即 `current.join("")`),不保留 Phase 1 切分时消耗的 separator
/// 边界信息。这意味着跨 chunk 的结构化边界(如段落、句子)会
/// 在合并点"塌缩"——但对 RAG 语义检索影响通常较小。如需保留
/// separator 边界,可重构此方法接受 separator 参数。
fn merge_with_overlap(&self, mut segments: Vec<String>) -> Vec<String> {
if segments.is_empty() {
return Vec::new();
}
if segments.len() == 1 {
return segments;
}
// Phase 2a: 贪心合并 segments 到目标 chunk_size
// segments 用 "" 连接,sep_count 不参与长度计算)
let mut chunks: Vec<String> = Vec::new();
let mut current: Vec<String> = Vec::new();
let mut current_len: usize = 0;
for seg in segments.drain(..) {
let seg_len = chars_len(&seg);
let new_total = current_len + seg_len;
if new_total > self.chunk_size && !current.is_empty() {
chunks.push(current.join(""));
current.clear();
current_len = 0;
}
current.push(seg);
current_len += seg_len;
}
if !current.is_empty() {
chunks.push(current.join(""));
}
if chunks.len() <= 1 {
return chunks;
}
// Phase 2b: 应用 overlap 滑动窗口(除第一个 chunk 外)
let overlap = self.chunk_overlap;
if overlap == 0 {
return chunks;
}
for i in 1..chunks.len() {
let prev = &chunks[i - 1];
let prev_chars_count = chars_len(prev);
if prev_chars_count == 0 {
continue;
}
let take_n = overlap.min(prev_chars_count);
// 字符级安全地取 prev 末尾 take_n 个字符
let tail: String = prev
.chars()
.rev()
.take(take_n)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
chunks[i] = format!("{}{}", tail, chunks[i]);
}
chunks
}
}
impl Default for RecursiveCharacterSplitter {
fn default() -> Self {
Self::new(1000, 200)
}
}
/// 字符数(Unicode 标量值),等价于 `s.chars().count()`。
#[inline]
fn chars_len(s: &str) -> usize {
s.chars().count()
}
#[cfg(test)]
mod tests {
use super::*;
// ===== Group B1 — Document struct 基础测试 =====
#[test]
fn document_new_metadata_defaults_empty() {
let doc = Document::new("id-1", "content", "text/plain");
assert!(doc.metadata.is_empty());
}
#[test]
fn document_clone_partial_eq() {
let doc = Document::new("id-1", "content", "text/plain");
let cloned = doc.clone();
assert_eq!(doc, cloned);
}
#[test]
fn document_different_ids_not_equal() {
let doc1 = Document::new("id-1", "content", "text/plain");
let doc2 = Document::new("id-2", "content", "text/plain");
assert_ne!(doc1, doc2);
}
#[test]
fn document_from_raw_uses_text_plain() {
let doc = Document::from_raw("id-1", "hello");
assert_eq!(doc.mime_type, "text/plain");
assert!(doc.metadata.is_empty());
}
// ===== Group B2 — Splitter 边界条件测试 =====
#[test]
fn split_empty_doc_returns_empty() {
let splitter = RecursiveCharacterSplitter::new(100, 20);
let chunks = splitter.split(&[]);
assert!(chunks.is_empty());
}
#[test]
fn split_short_doc_single_chunk() {
let splitter = RecursiveCharacterSplitter::new(100, 20);
let doc = Document::from_raw("short", "hello");
let chunks = splitter.split(&[doc]);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].content, "hello");
assert_eq!(
chunks[0].metadata.get("chunk_index").map(|s| s.as_str()),
Some("0")
);
assert_eq!(
chunks[0].metadata.get("chunk_count").map(|s| s.as_str()),
Some("1")
);
}
#[test]
fn split_empty_content_yields_no_chunks() {
let splitter = RecursiveCharacterSplitter::new(100, 20);
let doc = Document::from_raw("empty", "");
let chunks = splitter.split(&[doc]);
assert!(chunks.is_empty());
}
#[test]
#[should_panic(expected = "chunk_size must be greater than chunk_overlap")]
fn split_constructor_panics_on_invalid_overlap() {
let _ = RecursiveCharacterSplitter::new(10, 10);
}
#[test]
#[should_panic(expected = "chunk_size must be greater than 0")]
fn split_constructor_panics_on_zero_chunk_size() {
let _ = RecursiveCharacterSplitter::new(0, 0);
}
#[test]
fn try_new_returns_err_on_invalid_params() {
assert!(RecursiveCharacterSplitter::try_new(0, 0).is_err());
assert!(RecursiveCharacterSplitter::try_new(10, 10).is_err());
assert!(RecursiveCharacterSplitter::try_new(100, 20).is_ok());
}
#[test]
fn default_separators_match_spec() {
let splitter = RecursiveCharacterSplitter::default();
// Default separators should include CJK punctuation as the last meaningful
// separator before the char-level fallback. We can't directly access the
// private field, so we verify behavior: a Chinese sentence should split
// on "。" at the sentence level rather than the word level.
let doc = Document::from_raw("zh", "你好世界。今天天气好。");
let chunks = splitter.split(&[doc]);
// The default chunk_size=1000, so the whole content fits in 1 chunk.
// But the separators list contains "。" — this is verified via integration test.
assert!(!chunks.is_empty());
}
// ===== Group B3 — Splitter 核心算法测试 =====
#[test]
fn split_paragraph_boundary() {
// 小 chunk_size 强制段落级别分割
let splitter = RecursiveCharacterSplitter::new(4, 1);
let doc = Document::from_raw("p", "para1\n\npara2");
let chunks = splitter.split(&[doc]);
// para1 (5 chars) > chunk_size=4 → 递归降级到 char 级拆分
// para2 同理
// 总共应该产生多个 chunk
assert!(
chunks.len() >= 2,
"expected >= 2 chunks, got {}",
chunks.len()
);
}
#[test]
fn split_recursive_deepen() {
let splitter = RecursiveCharacterSplitter::new(50, 5);
// 200 字符无 \n\n,强制降级
let text: String = "a".repeat(200);
let doc = Document::from_raw("long", &text);
let chunks = splitter.split(&[doc]);
assert!(
chunks.len() >= 3,
"expected >= 3 chunks, got {}",
chunks.len()
);
for chunk in &chunks {
// chunk 内容 = overlap_tail(≤5) + new_content(≤50),故 ≤ 55
assert!(
chars_len(&chunk.content) <= 55,
"chunk too long: {} chars",
chars_len(&chunk.content)
);
}
}
#[test]
fn split_greedy_merge_combines_segments() {
let splitter = RecursiveCharacterSplitter::new(20, 2);
// 一段含多个 \n\n 分隔的短小段,应被合并到 chunk_size
let doc = Document::from_raw("g", "aa\n\nbb\n\ncc\n\ndd");
let chunks = splitter.split(&[doc]);
// 短段应被合并:总共应该少于 4 个 chunk
assert!(
chunks.len() <= 3,
"expected <= 3 chunks after merge, got {}",
chunks.len()
);
}
#[test]
fn split_overlap_consistency() {
let splitter = RecursiveCharacterSplitter::new(20, 5);
// 构造一个需要多 chunk 的文本
let text: String = "x".repeat(50);
let doc = Document::from_raw("o", &text);
let chunks = splitter.split(&[doc]);
assert!(chunks.len() >= 2);
// chunk[1] 应该以 chunk[0] 的最后 5 个字符作为前缀
let prev_tail: String = chunks[0]
.content
.chars()
.rev()
.take(5)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
assert!(
chunks[1].content.starts_with(&prev_tail),
"chunk[1] should start with last 5 chars of chunk[0]: prev_tail={:?}, chunk[1]={:?}",
prev_tail,
chunks[1].content
);
}
#[test]
fn split_character_fallback() {
let splitter = RecursiveCharacterSplitter::new(5, 0);
// 纯字母无标点,应降级到字符级
let doc = Document::from_raw("cf", "aaaaaaaaa");
let chunks = splitter.split(&[doc]);
assert_eq!(chunks.len(), 2, "expected 2 chunks, got {}", chunks.len());
for chunk in &chunks {
assert!(chars_len(&chunk.content) <= 5);
}
}
#[test]
fn split_multibyte_utf8_boundary() {
// 验证字符级单位而非字节级单位
let splitter = RecursiveCharacterSplitter::new(10, 2);
// 30 个中文字符 = 90 字节(UTF-8)
let text: String = "".repeat(30);
let doc = Document::from_raw("cjk", &text);
let chunks = splitter.split(&[doc]);
// 30 字符 / 10 chunk_size = 3 个 chunk
assert!(
chunks.len() >= 3,
"expected >= 3 chunks for 30 chars / chunk_size=10, got {}",
chunks.len()
);
for chunk in &chunks {
let char_count = chars_len(&chunk.content);
// chunk = overlap_tail(≤2) + new_content(≤10),故 ≤ 12
assert!(
char_count <= 12,
"chunk char count {} exceeds 10+overlap",
char_count
);
}
}
// ===== Group B4 — Splitter 集成测试 =====
#[test]
fn split_multiple_docs() {
let splitter = RecursiveCharacterSplitter::new(50, 5);
let docs = vec![
Document::from_raw("a", "a".repeat(30).as_str()),
Document::from_raw("b", "b".repeat(30).as_str()),
Document::from_raw("c", "c".repeat(30).as_str()),
];
let chunks = splitter.split(&docs);
assert!(chunks.len() >= 3);
// 每个 chunk 的 source_id 应指向对应的输入 doc
for chunk in &chunks {
let source = chunk.metadata.get("source_id").unwrap();
assert!(["a", "b", "c"].contains(&source.as_str()));
}
}
#[test]
fn split_metadata_inheritance() {
let splitter = RecursiveCharacterSplitter::new(100, 10);
let mut doc = Document::new("m", "short content", "text/plain");
doc.metadata
.insert("author".to_string(), "alice".to_string());
let chunks = splitter.split(&[doc]);
assert_eq!(chunks.len(), 1);
assert_eq!(
chunks[0].metadata.get("author").map(|s| s.as_str()),
Some("alice")
);
assert_eq!(
chunks[0].metadata.get("source_id").map(|s| s.as_str()),
Some("m")
);
assert_eq!(
chunks[0].metadata.get("chunk_index").map(|s| s.as_str()),
Some("0")
);
assert_eq!(
chunks[0].metadata.get("chunk_count").map(|s| s.as_str()),
Some("1")
);
}
}
+370
View File
@@ -0,0 +1,370 @@
//! Checkpointer —— Time-travel 检查点管理器(Phase 17 Step 4)。
//!
//! 设计要点:
//! - **不依赖 SessionManager**,可独立使用。直接操作 `MemoryStore`。
//! - **存储 key 格式**`ckpt:{session_id}:{ckpt_id}` → `SessionSnapshot` JSON
//! - **ckpt_id 生成**:时间戳(纳秒)+ 单调计数器,无外部依赖(ponytail 优先于 uuid
//! - **rollback_load** 两阶段:仅反序列化为 `SessionSnapshot`;不重建 `AgentSession`。
//! 调用方拿到 `SessionSnapshot` 后自行 `AgentSession::from_snapshot` + `restore_memory` + `replace`。
//!
//! 所有持久化错误通过 `EngineError::Memory` 透传。
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::agent::session::AgentSession;
use crate::engine::EngineError;
use crate::engine::snapshot::SessionSnapshot;
use crate::memory::store::MemoryStore;
use crate::memory::types::{MemoryFilter, MemoryItem};
/// 检查点元数据(公开 API)。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CkptMeta {
pub ckpt_id: String,
pub session_id: String,
pub turn_index: u32,
/// Unix 时间戳秒(人类可读)。
pub created_at: u64,
/// Unix 时间戳纳秒(用于同秒内的精确排序)。
pub created_at_nanos: u128,
}
/// 全局单调计数器(避免同一纳秒内并发 checkpoint 撞 id)。
static CKPT_COUNTER: AtomicU64 = AtomicU64::new(0);
/// 生成 ckpt_id:纳秒时间戳 + 单调计数器(避免同纳秒冲突)。
fn generate_ckpt_id() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let counter = CKPT_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{:x}_{:x}", nanos, counter)
}
fn ckpt_key(session_id: &str, ckpt_id: &str) -> String {
format!("ckpt:{}:{}", session_id, ckpt_id)
}
fn assert_no_colon(id: &str, field: &str) {
if id.contains(':') {
panic!(
"{field} '{id}' contains ':' which would break key format. \
Use only letters, digits, hyphens and underscores."
);
}
}
/// Time-travel 检查点管理器。
pub struct Checkpointer {
store: std::sync::Arc<dyn MemoryStore>,
}
impl Checkpointer {
/// 构造 Checkpointer。
pub fn new(store: std::sync::Arc<dyn MemoryStore>) -> Self {
Self { store }
}
/// 创建新检查点。返回生成的 `ckpt_id`。
///
/// 流程:`session.to_snapshot().await` → 序列化为 JSON → 存 `ckpt:{session_id}:{ckpt_id}`。
pub async fn checkpoint(&self, session: &AgentSession) -> Result<String, EngineError> {
let snapshot = session.to_snapshot().await;
assert_no_colon(&session.session_id, "session_id");
let ckpt_id = generate_ckpt_id();
let key = ckpt_key(&session.session_id, &ckpt_id);
let json = serde_json::to_string(&snapshot)
.map_err(|e| EngineError::Serialization(format!("snapshot serialize failed: {e}")))?;
let item = MemoryItem {
id: key,
content: json,
metadata: serde_json::json!({
"turn_index": snapshot.turn_index,
}),
created_at: OffsetDateTime::now_utc(),
};
self.store.save(item).await?;
tracing::info!(
session_id = %session.session_id,
ckpt_id = %ckpt_id,
turn_index = snapshot.turn_index,
snapshot_size = snapshot.session_memory_data.len(),
"checkpoint created"
);
Ok(ckpt_id)
}
/// 反序列化 checkpoint 为 `SessionSnapshot`(不重建 `AgentSession`)。
///
/// 两阶段 rollback 的第一阶段。调用方拿到 `SessionSnapshot` 后自行:
/// `AgentSession::from_snapshot(snapshot, agent, bundle)` → `restore_memory()` → `replace()`
pub async fn rollback_load(
&self,
session_id: &str,
ckpt_id: &str,
) -> Result<SessionSnapshot, EngineError> {
let key = ckpt_key(session_id, ckpt_id);
let item = self.store.get(&key).await?.ok_or_else(|| {
EngineError::CheckpointNotFound(format!("{} (session={})", ckpt_id, session_id))
})?;
let snapshot: SessionSnapshot = serde_json::from_str(&item.content).map_err(|e| {
EngineError::Serialization(format!(
"snapshot deserialize failed (ckpt_id={}): {e}",
ckpt_id
))
})?;
tracing::info!(
session_id = %session_id,
ckpt_id = %ckpt_id,
turn_index = snapshot.turn_index,
"checkpoint loaded for rollback"
);
Ok(snapshot)
}
/// 列出某 session 的所有检查点(按创建时间**降序**——最新的在前)。
///
/// prefix 查询 `ckpt:{session_id}:` → 反序列化 `SessionSnapshot` → 提取元数据。
/// 不需要 `CkptMeta` 单独存储——`SessionSnapshot` 已含 `turn_index` 字段,
/// `created_at` 用 `MemoryItem.created_at` 转换。
pub async fn list_checkpoints(&self, session_id: &str) -> Result<Vec<CkptMeta>, EngineError> {
let prefix = format!("ckpt:{}:", session_id);
let filter = MemoryFilter {
prefix: Some(prefix),
..Default::default()
};
let items = self.store.list(&filter).await?;
tracing::debug!(
session_id = %session_id,
count = items.len(),
"checkpoints listed"
);
let mut metas: Vec<CkptMeta> = items
.into_iter()
.filter_map(|item| {
// 从 id 中提取 ckpt_id: "ckpt:{session_id}:{ckpt_id}"
let prefix_with_session = format!("ckpt:{}:", session_id);
let ckpt_id = item.id.strip_prefix(&prefix_with_session)?.to_string();
let snapshot: SessionSnapshot = serde_json::from_str(&item.content).ok()?;
let created_at_nanos = item
.created_at
.unix_timestamp_nanos()
.try_into()
.unwrap_or(0u128);
Some(CkptMeta {
ckpt_id,
session_id: session_id.to_string(),
turn_index: snapshot.turn_index,
created_at: item.created_at.unix_timestamp() as u64,
created_at_nanos,
})
})
.collect();
// 按 created_at_nanos 降序(精确排序)
metas.sort_by(|a, b| b.created_at_nanos.cmp(&a.created_at_nanos));
Ok(metas)
}
/// 删除某 session 的所有检查点(session 被 `destroy` 时调用)。
pub async fn delete_all(&self, session_id: &str) -> Result<(), EngineError> {
let prefix = format!("ckpt:{}:", session_id);
let filter = MemoryFilter {
prefix: Some(prefix.clone()),
..Default::default()
};
let items = self.store.list(&filter).await?;
let deleted = items.len();
for item in items {
self.store.delete(&item.id).await?;
}
tracing::info!(
session_id = %session_id,
deleted_count = deleted,
"all checkpoints deleted"
);
Ok(())
}
/// 获取某 session 的最新 checkpoint(按 created_at 降序取第一个)。
///
/// 供 `SessionManager::recover()` 调用。
pub async fn latest_snapshot(
&self,
session_id: &str,
) -> Result<Option<SessionSnapshot>, EngineError> {
let metas = self.list_checkpoints(session_id).await?;
match metas.first() {
Some(meta) => {
let snap = self.rollback_load(session_id, &meta.ckpt_id).await?;
Ok(Some(snap))
}
None => Ok(None),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::builder::AgentBuilder;
use crate::agent::summary::SummaryConfig;
use crate::llm::mock::MockProvider;
use crate::tools::ToolRegistry;
use async_trait::async_trait;
use std::sync::Arc;
/// 极简 Agent(无 system_prompt)。
struct StubAgent;
#[async_trait]
impl crate::agent::agent::Agent for StubAgent {
fn name(&self) -> &str {
"stub"
}
fn system_prompt(&self) -> Option<&str> {
None
}
}
fn make_bundle() -> Arc<crate::agent::runtime::RuntimeBundle> {
Arc::new(
AgentBuilder::new()
.provider(Arc::new(MockProvider::new(vec![])))
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(crate::llm::hooks::HookExecutor::new()))
.summary_config(SummaryConfig::default())
.build()
.unwrap(),
)
}
fn make_session(session_id: &str, agent: Arc<dyn crate::agent::agent::Agent>) -> AgentSession {
AgentSession::new(agent, session_id, make_bundle())
}
fn new_session_for_test(session_id: &str) -> AgentSession {
let agent: Arc<dyn crate::agent::agent::Agent> = Arc::new(StubAgent);
make_session(session_id, agent)
}
#[tokio::test]
async fn checkpoint_roundtrip() {
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
let cp = Checkpointer::new(store.clone());
let mut session = new_session_for_test("ckpt-session");
session
.set_session_data("design", "PostgreSQL")
.await
.unwrap();
let ckpt_id = cp.checkpoint(&session).await.unwrap();
assert!(!ckpt_id.is_empty());
let restored = cp.rollback_load("ckpt-session", &ckpt_id).await.unwrap();
assert_eq!(restored.session_id, "ckpt-session");
assert_eq!(restored.turn_index, 0);
assert_eq!(restored.session_memory_data.len(), 1);
let entry = restored.session_memory_data.get("design").unwrap();
assert_eq!(entry.value, "PostgreSQL");
}
#[tokio::test]
async fn checkpoint_not_found() {
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
let cp = Checkpointer::new(store);
let err = cp.rollback_load("nonexistent", "ckpt_x").await.unwrap_err();
match err {
EngineError::CheckpointNotFound(_) => {}
other => panic!("expected CheckpointNotFound, got {:?}", other),
}
}
#[tokio::test]
async fn list_checkpoints_returns_desc() {
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
let cp = Checkpointer::new(store);
let session = new_session_for_test("list-session");
let ckpt_id_1 = cp.checkpoint(&session).await.unwrap();
// 短暂 sleep 确保时间戳不同(InMemoryStore 内部用 OffsetDateTime 精度到 ns
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
let ckpt_id_2 = cp.checkpoint(&session).await.unwrap();
let ckpt_id_3 = cp.checkpoint(&session).await.unwrap();
let metas = cp.list_checkpoints("list-session").await.unwrap();
assert_eq!(metas.len(), 3);
// 降序:最新在前
let ids: Vec<_> = metas.iter().map(|m| m.ckpt_id.clone()).collect();
assert_eq!(ids[0], ckpt_id_3);
assert_eq!(ids[1], ckpt_id_2);
assert_eq!(ids[2], ckpt_id_1);
}
#[tokio::test]
async fn delete_all_removes_checkpoints() {
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
let cp = Checkpointer::new(store.clone());
let session = new_session_for_test("del-session");
cp.checkpoint(&session).await.unwrap();
cp.checkpoint(&session).await.unwrap();
assert_eq!(cp.list_checkpoints("del-session").await.unwrap().len(), 2);
cp.delete_all("del-session").await.unwrap();
assert_eq!(cp.list_checkpoints("del-session").await.unwrap().len(), 0);
// delete_all 幂等:再次调用不报错
cp.delete_all("del-session").await.unwrap();
}
#[tokio::test]
async fn latest_snapshot_returns_most_recent() {
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
let cp = Checkpointer::new(store);
let mut session = new_session_for_test("latest-session");
cp.checkpoint(&session).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
session.set_session_data("v", "2").await.unwrap();
cp.checkpoint(&session).await.unwrap();
let latest = cp.latest_snapshot("latest-session").await.unwrap().unwrap();
let v = latest.session_memory_data.get("v").unwrap();
assert_eq!(v.value, "2");
}
#[tokio::test]
async fn checkpoint_isolation_between_sessions() {
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
let cp = Checkpointer::new(store);
let a = new_session_for_test("iso-a");
let b = new_session_for_test("iso-b");
cp.checkpoint(&a).await.unwrap();
cp.checkpoint(&b).await.unwrap();
let metas_a = cp.list_checkpoints("iso-a").await.unwrap();
let metas_b = cp.list_checkpoints("iso-b").await.unwrap();
assert_eq!(metas_a.len(), 1);
assert_eq!(metas_b.len(), 1);
assert_eq!(metas_a[0].session_id, "iso-a");
assert_eq!(metas_b[0].session_id, "iso-b");
}
}
+51
View File
@@ -0,0 +1,51 @@
//! Engine 模块统一错误类型。
use thiserror::Error;
use crate::agent::error::AgentError;
use crate::memory::error::MemoryError;
/// Engine 模块错误枚举。
///
/// - `Session*` / `Checkpoint*`engine 层独有的错误变体
/// - `Memory`:透传 `MemoryError`,与项目既有 `AgentError` 风格一致(对比 `AgentError::Memory`
/// - `Serialization`:快照 JSON 解析失败
/// - `Agent`:透传 `AgentError`(后续 Stage 5/6 集成时需要)
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum EngineError {
/// 指定 session_id 不存在。
/// 适用场景:`get()` 内存未命中、`create_child()` parent 不存在、
/// `recover()` 存储中查不到。
///
/// **不适用** `destroy()``destroy()` 对不存在的 session 静默返回 `Ok(())`
/// (幂等删除语义,调用方无需先检查)。
#[error("Session not found: {0}")]
SessionNotFound(String),
/// 创建 session 时 ID 已存在(自动生成 UUID 时通常不会触发;当前主要在重复 `recover` 已存在 ID 时使用)。
#[error("Session already exists: {0}")]
SessionAlreadyExists(String),
/// 指定 ckpt_id 不存在。
#[error("Checkpoint not found: {0}")]
CheckpointNotFound(String),
/// 存储错误(透传 `MemoryError`)。
/// Checkpointer 和 SessionManager 的所有 `MemoryStore` 操作通过此变体传播错误。
#[error("存储错误: {0}")]
Memory(#[from] MemoryError),
/// 序列化/反序列化失败(serde_json / snapshot 格式错误)。
#[error("序列化错误: {0}")]
Serialization(String),
/// Agent 错误(透传 `AgentError`,供后续 Stage 5/6 的 `recover`/`replace` 等集成入口使用)。
#[error("Agent 错误: {0}")]
Agent(#[from] AgentError),
/// 子代理调度失败(`dispatch` 过程中遇到不可恢复错误,子 session 已被清理)。
/// 调用方收到此错误时,子 session 已通过 `destroy()` 清理(SessionMeta + checkpoint 全部清空)。
#[error("Dispatch failed: {0}")]
DispatchFailed(String),
}
+23
View File
@@ -0,0 +1,23 @@
//! Engine 模块 —— Agent 执行引擎。
//!
//! Phase 17 新增。提供 SessionManager(会话树管理)和 Checkpointertime-travel 检查点)能力。
//!
//! ## 子模块
//!
//! - [`session_manager`]SessionManager + SessionManagerConfig
//! - [`checkpointer`]Checkpointertime-travel 检查点)
//! - [`snapshot`]SessionSnapshot + SessionMemoryEntry(可序列化快照)
//! - [`error`]EngineError 枚举
pub mod checkpointer;
pub mod error;
pub mod session_manager;
pub mod snapshot;
pub mod sub_agent;
pub mod switch;
pub use checkpointer::{Checkpointer, CkptMeta};
pub use error::EngineError;
pub use session_manager::{SessionManager, SessionManagerConfig};
pub use snapshot::{SessionMemoryEntry, SessionSnapshot};
pub use sub_agent::{DispatchConfig, SubTaskResult, SubTaskStreamEvent};

Some files were not shown because too many files have changed in this diff Show More