chore(docs): 将设计文档从 docs 移至 design 目录
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
# Agent Harness 参考项目调研笔记
|
||||
|
||||
> 调研日期:2026-06-09
|
||||
> 用途:为 AG Core Phase 4(Agent Runtime)及后续 v0.2+ 扩展提供设计参考。
|
||||
> 关联:`docs/roadmap.md` Phase 4 / 扩展计划(v0.2+)小节。
|
||||
|
||||
本笔记调研了 4 个 2026 年公开的 AI Agent 项目,对比其核心架构与 AG Core 已完成模块的交集,作为 Phase 4 设计与未来扩展的输入。
|
||||
|
||||
> **重要事实**:4 个项目**均非 Rust 写的**(OpenHuman 虽是 Rust + Tauri,但定位是 desktop 应用)。其价值不在"抄代码",而在参考**经过生产验证的 Agent Harness 架构模式**。AG Core 处于"core 库"层,是这些项目"最底层依赖"的角色。
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目概览
|
||||
|
||||
| 项目 | 类型 | 语言 | GitHub | Stars(调研时) | 定位 |
|
||||
|------|------|------|--------|--------------|------|
|
||||
| **OpenClaw** | Gateway 网关 | TypeScript / Node 24 | `openclaw/openclaw` | — | 自托管消息平台 ↔ AI Agent 桥接 |
|
||||
| **Hermes Agent** | 自主学习智能体 | Python 3.11 | `NousResearch/hermes-agent` | — | 随使用成长的个人数字员工 |
|
||||
| **OpenHuman** | 桌面助手 | Rust + Tauri | `tinyhumansai/openhuman` | 2.3k+ | 记忆驱动的跨工具私人助理 |
|
||||
| **OpenHarness** | Agent Harness 框架 | Python | `HKUDS/OpenHarness` | 12.2k+ | 对标 Claude Code 的轻量级基础设施 |
|
||||
|
||||
## 2. 核心架构对照
|
||||
|
||||
| 维度 | OpenClaw | Hermes Agent | OpenHuman | OpenHarness |
|
||||
|------|----------|--------------|-----------|-------------|
|
||||
| **Agent Loop 形态** | 外部 Pi 二进制进程 | 内置 while 循环 | 内置循环 | 70 行 `run_query` |
|
||||
| **记忆模型** | 跨平台 session | MEMORY.md + 技能库 | Memory Tree(SQLite 分层摘要) | MEMORY.md + Auto-Compaction |
|
||||
| **工具机制** | MCP + 插件 | 40+ 内置技能 + 自动技能生成 | 118+ 集成 + Native Toolbelt | 43 工具 + BaseTool Pydantic |
|
||||
| **多 Agent** | 消息平台多 gateway | 并行子 Agent + RPC | Agent Coordination | Swarm 子代理委派 |
|
||||
| **权限/治理** | `allowFrom` + 提及规则 | 容器加固 + Cron 审批 | 本地优先 + 隐私 | 三级权限 + 钩子 |
|
||||
| **规划/任务** | 无显式规划 | 自然语言驱动 | 无显式 | 隐式(LLM 自我规划) |
|
||||
| **持久化** | 外部进程状态 | `~/.hermes/` 目录 | `~/.openhuman/` SQLite | MEMORY.md + state |
|
||||
| **Hook 体系** | 渠道适配器 | cron + 自定义钩子 | 集成触发 | PreToolUse / PostToolUse |
|
||||
| **干运行模式** | ❌ | ❌ | ❌ | ✅ `--dry-run` |
|
||||
| **流式 TUI** | ✅ 控制 UI | ✅ 完整 TUI | ✅ 桌面应用 | ✅ React/Ink |
|
||||
|
||||
## 3. 共同分层(5 层架构)
|
||||
|
||||
4 个项目都能切成这 5 层,**OpenHarness 的分层最清晰**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ L4 扩展层 多 Agent / 渠道网关 / 插件 / 任务调度 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ L3 治理层 权限 / Hook / 审批 / 安全策略 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ L2 知识层 提示词工厂 / 技能库 / 持久记忆 / 摘要 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ L1 执行层 Agent Loop / 工具注册 / 流式事件 / 重试 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ L0 模型层 LLM Provider / Provider Registry / 鉴权 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**关键观察**:
|
||||
|
||||
- 4 个项目都假定 L0/L1 是"基础设施",不在 core 层重新发明
|
||||
- AG Core 在 Phase 0-3 已完成 L0 / L1 / L2(部分) / L3(部分)
|
||||
- Phase 4 处于 L1 与 L2 的衔接处
|
||||
- L4(Multi-Agent / Gateway)属于应用层,应由上层 crate / 二进制承担
|
||||
|
||||
## 4. AG Core 已具备的对应能力
|
||||
|
||||
对照 5 层架构,AG Core 已就绪情况:
|
||||
|
||||
| 层 | AG Core 对应 | 完成状态 | 文档 |
|
||||
|----|------------|---------|------|
|
||||
| **L0 模型层** | `llm::provider` + `llm::ProviderRegistry` | ✅ Phase 0 | `docs/2-llm-call-lifecycle.md` |
|
||||
| **L1 执行层** | `llm::cycle` + `llm::stream` + `tools::ToolRegistry` | ✅ Phase 0/2 | `docs/5-tool-system.md` |
|
||||
| **L2 知识层** | `prompt` + `memory::store/conversation/knowledge/retriever` | ✅ Phase 1/3 | `docs/4-prompt-engineering.md` / `docs/6-memory-system.md` |
|
||||
| **L3 治理层** | `llm::hooks` + `tools::PermissionChecker` | ✅ Phase 0/2 | `docs/3-phase0-remaining.md` |
|
||||
| **L1→L2 衔接(Agent Runtime)** | — | ❌ **Phase 4 待实现** | — |
|
||||
|
||||
## 5. 借鉴到 Phase 4 的核心模式
|
||||
|
||||
### 5.1 OpenHarness 风格:显式依赖注入容器
|
||||
|
||||
```rust
|
||||
// 核心思想:所有运行时依赖打包成一个对象,沿调用链显式传递
|
||||
pub struct RuntimeBundle {
|
||||
pub provider: Arc<dyn LlmProvider>,
|
||||
pub tool_registry: Arc<ToolRegistry>,
|
||||
pub hook_executor: Arc<HookExecutor>,
|
||||
// ... 可选:memory_store / retriever
|
||||
}
|
||||
```
|
||||
|
||||
**好处**:测试时可注入 mock bundle;支持同时跑多 session;依赖关系显式可追踪。
|
||||
|
||||
**AG Core 决策**:采纳,命名为 `agent::RuntimeBundle`(详见 Phase 4 设计决策记录)。
|
||||
|
||||
### 5.2 Hermes 风格:实体与会话解耦
|
||||
|
||||
```rust
|
||||
pub trait Agent: Send + Sync { /* 角色定义,不绑定 session */ }
|
||||
pub struct AgentSession { /* 绑定 session_id + bundle + 状态 */ }
|
||||
```
|
||||
|
||||
**好处**:同一 `Agent` 可被多个 `AgentSession` 复用(多用户、多会话);session 状态(cost、turn index)独立追踪。
|
||||
|
||||
**AG Core 决策**:采纳,详见 Phase 4 §接口签名草案。
|
||||
|
||||
### 5.3 OpenHarness 风格:Agent Loop 的极简本质
|
||||
|
||||
OpenHarness 的 `run_query` 核心只有 70 行,本质是一个 `while` 循环 + 一个 `if not tool_uses: return` 的判断。
|
||||
|
||||
**AG Core 现状**:`llm::cycle::submit_with_tools()` 已经在 Phase 2 末实现了这个循环,Phase 4 不应重新实现。
|
||||
|
||||
**AG Core 决策**:Phase 4 只在 `AgentSession::submit_turn()` 提供 30 行的 reference impl,组装 `LlmCycle` 并暴露其能力,业务循环留给上层。
|
||||
|
||||
### 5.4 OpenHuman 风格:分层摘要记忆树
|
||||
|
||||
OpenHuman 的 Memory Tree 创新点:
|
||||
- 多源数据(Gmail / Slack / GitHub 等)→ 规范化 Markdown → ≤3k token chunks → 打分 → 折叠成 per-source / per-topic / per-day 摘要树
|
||||
- 存储在本地 SQLite
|
||||
- Auto-fetch 每 20 分钟拉取新数据
|
||||
|
||||
**AG Core 现状**:`memory::KnowledgeStore` 已是 LLM Wiki 风格的抽象层。Phase 4 v1 不引入 SQLite 实现(属于 L4 应用层)。
|
||||
|
||||
**AG Core 决策**:v0.2+ 考虑 `note-knowledge-graph-design.md` 已记录的 KnowledgeGraph / RecallBased 淘汰等深度记忆能力。
|
||||
|
||||
## 6. 反模式(不要照搬)
|
||||
|
||||
| 反模式 | 出现项目 | 不要照搬的理由 |
|
||||
|--------|---------|--------------|
|
||||
| 双进程架构(Node UI + Python 后端) | OpenClaw | 应用层架构,core 库不涉及 |
|
||||
| SQLite 持久化细节 | OpenHuman | 属于 L4 应用层具体实现 |
|
||||
| Pydantic 工具校验 | OpenHarness | Python 生态强项;Rust 已有 `serde_json::Value` + JSON Schema,足够 |
|
||||
| 43 工具内置 | OpenHarness | 应用层选型,core 库应保持"零内置工具" |
|
||||
| 单进程内多平台消息网关 | OpenClaw / Hermes | 属于 L4 应用层 |
|
||||
|
||||
## 7. 与 AG Core 现有模块的接口对齐
|
||||
|
||||
下表列出 4 个项目中被 AG Core **已经覆盖**或**即将在 Phase 4 覆盖**的能力,避免重复造轮子:
|
||||
|
||||
| 4 项目中的能力 | AG Core 对应 | 状态 |
|
||||
|--------------|-------------|------|
|
||||
| 工具注册表 | `tools::ToolRegistry` | ✅ Phase 2 已实现 |
|
||||
| 权限检查 | `tools::PermissionChecker` | ✅ Phase 2 已实现 |
|
||||
| 生命周期钩子 | `llm::HookExecutor` | ✅ Phase 0 已实现,Phase 4 扩展 3 个事件 |
|
||||
| 自动 tool 循环 | `llm::cycle::submit_with_tools()` | ✅ Phase 2 末已实现 |
|
||||
| Auto-Compaction | `llm::compact` | ✅ Phase 0 已实现 |
|
||||
| 对话记忆 | `memory::ConversationMemory` | ✅ Phase 3 已实现 |
|
||||
| 知识库 | `memory::KnowledgeStore` | ✅ Phase 3 已实现 |
|
||||
| 关键词检索 | `memory::MemoryRetriever` | ✅ Phase 3 已实现 |
|
||||
| 提示词模板 | `prompt::PromptTemplate` + `PromptComposer` | ✅ Phase 1 已实现 |
|
||||
| 用量追踪 | `llm::cycle::usage::CostTracker` | ✅ Phase 0 已实现 |
|
||||
| **显式依赖注入容器** | — | ⏳ **Phase 4 新增 `RuntimeBundle`** |
|
||||
| **Agent ↔ Session 分离** | — | ⏳ **Phase 4 新增 `Agent` + `AgentSession`** |
|
||||
| **任务规划** | — | ⏳ **Phase 4 新增 `TaskAgent` + `Plan`** |
|
||||
| **结构化 Plan 解析** | — | ⏳ **Phase 4 新增 `PlanParser` trait** |
|
||||
|
||||
## 8. v0.2+ 扩展项与参考项目的对应
|
||||
|
||||
`docs/roadmap.md` 扩展计划(v0.2+)表中的项,在 4 个项目中的对应实现:
|
||||
|
||||
| 扩展项 | OpenClaw | Hermes | OpenHuman | OpenHarness |
|
||||
|--------|----------|--------|-----------|-------------|
|
||||
| Multi-Agent / Swarm | ❌ | ✅ 并行子 Agent | ✅ Agent Coordination | ✅ Swarm |
|
||||
| Markdown 技能 | ❌ | ✅ SKILL.md | ❌ | ✅ prompts/*.md |
|
||||
| 多通道检索(vector + keyword) | ❌ | ❌ | ✅ Memory Tree | ❌ |
|
||||
| KnowledgeGraph | ❌ | ❌ | ✅ Memory Graph | ❌ |
|
||||
| TokenJuice 智能压缩 | ❌ | ✅ 轨迹压缩 | ✅ TokenJuice | ✅ Auto-Compaction |
|
||||
| TUI / Gateway | ✅ 控制 UI | ✅ 完整 TUI | ✅ 桌面应用 | ✅ React/Ink |
|
||||
| 训练 / RL 轨迹 | ❌ | ✅ Atropos | ❌ | ❌ |
|
||||
| 人类审批(Human-in-the-loop) | ❌ | ✅ Cron 审批 | ❌ | ✅ 权限弹窗 |
|
||||
|
||||
## 9. 参考资源
|
||||
|
||||
- **OpenClaw 文档**:<https://docs.openclaw.ai/zh-CN>
|
||||
- **Hermes Agent 官网**:<https://hermes-agent.org/zh/>
|
||||
- **Hermes Agent GitHub**:<https://github.com/NousResearch/hermes-agent>
|
||||
- **OpenHuman GitHub**:<https://github.com/tinyhumansai/openhuman>
|
||||
- **OpenHuman 中文站**:<https://openhumanai.cn/docs/>
|
||||
- **OpenHarness GitHub**:<https://github.com/HKUDS/OpenHarness>
|
||||
- **OpenHarness 深度学习笔记**:<https://www.joyehuang.me/blog/20260410---openharnessphase1/post>
|
||||
|
||||
## 10. 一句话总结
|
||||
|
||||
> **4 个项目都不在 L0/L1 重新发明轮子——它们都假定基础设施已就绪。AG Core 在 Phase 0-3 已经把这 4 层全做完了。Phase 4 的核心价值是把它们"装配起来",同时为未来 v0.2+ 的 L4 扩展(Multi-Agent / Skills / TUI)留好接口。**
|
||||
@@ -0,0 +1,335 @@
|
||||
# Phase 4 Agent Runtime — 设计决策记录
|
||||
|
||||
> 决策固化日期:2026-06-09
|
||||
> 用途:记录 Phase 4 设计阶段的关键决策、接口签名草案、文件清单,作为 `docs/7-agent-runtime.md` 方案文档的输入约束。
|
||||
> 关联:
|
||||
> - `docs/7-agent-runtime.md` — 完整方案文档(待写 / 已写)
|
||||
> - `docs/note-agent-harness-references.md` — 参考项目调研(OpenClaw / Hermes / OpenHuman / OpenHarness)
|
||||
> - `docs/roadmap.md` — 项目总 Roadmap
|
||||
> - `docs/2-llm-call-lifecycle.md` / `3-phase0-remaining.md` / `4-prompt-engineering.md` / `5-tool-system.md` / `6-memory-system.md` — Phase 0-3 方案
|
||||
|
||||
本文件是 Phase 4 设计阶段的"事实基础"——所有决策都有明确的对话出处与依据。后续 Phase 4 实施时应与本记录保持一致;如需调整,应先更新本记录再改代码。
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计目标
|
||||
|
||||
AG Core Phase 4 的定位是**「Phase 0-3 的薄胶水层 + 一组 trait 抽象」**,遵循 OpenHarness 的"显式依赖注入"模式 + Hermes 的"两层实体/会话"模型。**不**实现业务循环,**不**做产品级功能,**不**假设上层如何使用 memory。
|
||||
|
||||
## 2. 范围与边界
|
||||
|
||||
### 2.1 必须实现(12 项)
|
||||
|
||||
| # | 交付物 | 文件 | 关键决策 |
|
||||
|---|--------|------|---------|
|
||||
| 1 | `Agent` trait | `src/agent/agent.rs` | 角色定义:name / system_prompt / 工具集 / 引用 session 句柄 |
|
||||
| 2 | `RuntimeBundle` | `src/agent/runtime.rs` | 依赖注入容器(OpenHarness 风格) |
|
||||
| 3 | `AgentSession` | `src/agent/session.rs` | 会话实例 + **最小 reference impl**(`submit_turn` ~30 行) |
|
||||
| 4 | `TaskAgent` + `Plan` / `Step` | `src/agent/task.rs` | 双入口:`run(goal)` 自主式 + `execute_plan(plan)` 外部驱动式 |
|
||||
| 5 | `PlanParser` trait + `JsonPlanParser` 参考实现 | `src/agent/task.rs` | 注入式(澄清 3 选项 C) |
|
||||
| 6 | `AgentError` | `src/agent/error.rs` | 聚合 LlmError / ToolError / MemoryError,含 `is_recoverable()` |
|
||||
| 7 | `AgentConfig` / `AgentBuilder` | `src/agent/builder.rs` | 链式构造 `RuntimeBundle` |
|
||||
| 8 | Hook 事件扩展 | `src/llm/hooks.rs` | 追加 `OnTurnStart` / `OnTurnEnd` / `OnPlanStepComplete` 3 个事件 + 上下文扩展(澄清 4) |
|
||||
| 9 | `lib.rs` 导出 | `src/lib.rs` | 一行 `pub mod agent;` |
|
||||
| 10 | 烟雾测试 | `src/agent/tests.rs` 或内联 | 2-3 个:trait 可装配 / RuntimeBundle 可构造 / `submit_turn` 跑通 mock |
|
||||
| 11 | 方案文档 | `docs/7-agent-runtime.md` | 编号 `7`(最大编号是 `6`,已确认无冲突) |
|
||||
| 12 | Roadmap 同步 | `docs/roadmap.md` | 状态从 ❌ 缺失 改为 ✅ |
|
||||
|
||||
### 2.2 明确不做(v0.2+ 边界)
|
||||
|
||||
| 推迟项 | 理由 |
|
||||
|--------|------|
|
||||
| 完整 `BasicAgent` 多轮 turn 循环 | core 库不假设业务循环 |
|
||||
| `ConversationAgent` 自动回写 | 记忆在独立 task 处理,由上层回写 |
|
||||
| 强绑定 `ConversationMemory` 字段 | 改 `Option<Arc<dyn MemoryStore>>` 弱引用 |
|
||||
| Plan 拆解的提示词模板 | 由上层注入 `PlanParser` |
|
||||
| Multi-Agent / Swarm | 接口未稳定,独立 phase |
|
||||
| Markdown 技能按需加载 | 属于知识层 |
|
||||
| 三级权限模式 UI | 应用层 |
|
||||
| 干运行 / TUI / Gateway | 应用层 |
|
||||
| 完整的集成测试套件 | 2-3 个烟雾测试足够(呼应"最小范围") |
|
||||
|
||||
详细 v0.2+ 候选项见 `docs/roadmap.md` 扩展计划(v0.2+)小节与 `docs/note-agent-harness-references.md` 第 8 节。
|
||||
|
||||
## 3. 核心架构
|
||||
|
||||
### 3.1 分层(与 OpenHarness 5 层一致)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 应用层 (上层 crate / 二进制 / Gateway) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Agent Runtime ← Phase 4:trait + RuntimeBundle + Session │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ LLM / Tool / Prompt / Memory ← Phase 0/1/2/3(已完成) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 实体关系
|
||||
|
||||
```
|
||||
┌────────────┐ ┌──────────────────┐
|
||||
│ Agent │ 1 * │ AgentSession │
|
||||
│ (trait) ├────────►│ (struct) │
|
||||
│ - name │ │ - session_id │
|
||||
│ - prompt │ │ - bundle: Arc │
|
||||
│ - tools │ │ - turn_index │
|
||||
└────────────┘ │ - cost_so_far │
|
||||
└──────────────────┘
|
||||
│
|
||||
▼ 共享
|
||||
┌──────────────────┐
|
||||
│ RuntimeBundle │
|
||||
│ - provider │
|
||||
│ - tool_registry │
|
||||
│ - hook_executor │
|
||||
│ - memory_store? │ ◄── 弱引用(澄清 2 选项 B)
|
||||
│ - retriever? │
|
||||
│ - config │
|
||||
└──────────────────┘
|
||||
│
|
||||
▼ 注册为 tool
|
||||
┌──────────────────┐
|
||||
│ "retrieve" tool │ ◄── 如果 retriever 存在则自动注册
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
### 3.3 决策对照表
|
||||
|
||||
| 决策点 | 选择 | 来源 |
|
||||
|--------|------|------|
|
||||
| 实体 vs 会话 | 两层模型(`Agent` + `AgentSession`) | 讨论第 4 轮第 1 条 / OpenHarness / Hermes |
|
||||
| 范围控制 | trait + 最小 reference impl(~30 行) | 讨论第 4 轮第 2 条 / 澄清 1 选项 B |
|
||||
| 记忆处理 | 弱引用 + 自动注册 retriever 为 tool | 讨论第 4 轮第 3 条 / 澄清 2 选项 B |
|
||||
| Hook 扩展 | 3 个新事件 + 上下文扩展 | 讨论第 4 轮第 4 条 / 澄清 4 |
|
||||
| 方案文档位置 | `docs/7-agent-runtime.md` | 讨论第 4 轮第 5 条 |
|
||||
| TaskAgent 入口 | 双入口(自主 + 外部驱动) | 讨论第 4 轮第 6 条 |
|
||||
| 自主式 Plan 解析 | 注入式 `PlanParser` trait + `JsonPlanParser` 参考实现 | 澄清 3 选项 C |
|
||||
| 依赖注入 | `RuntimeBundle` 显式容器 | 讨论第 4 轮第 7 条 / OpenHarness |
|
||||
| 文档撰写 | 推迟到 Proposal 阶段 | 讨论第 4 轮第 8 条 |
|
||||
|
||||
## 4. 接口签名草案
|
||||
|
||||
> ⚠️ **这些是"设计约束",不是最终代码**。方案文档(`docs/7-agent-runtime.md`)与实施阶段可微调字段顺序、文档注释、错误变体等,但**核心 trait 形状**和**方法名**应保持稳定。
|
||||
|
||||
### 4.1 `Agent` trait
|
||||
|
||||
```rust
|
||||
pub trait Agent: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn system_prompt(&self) -> Option<&str>;
|
||||
/// 列出该 Agent 想要暴露给 LLM 的工具定义。
|
||||
/// 默认实现:从 RuntimeBundle.tool_registry 取全部(最常用)。
|
||||
/// 子 trait 可覆盖做白名单/过滤。
|
||||
fn tool_definitions(&self, bundle: &RuntimeBundle) -> Vec<ToolDefinition>;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `RuntimeBundle`
|
||||
|
||||
```rust
|
||||
pub struct RuntimeBundle {
|
||||
pub provider: Arc<dyn LlmProvider>,
|
||||
pub tool_registry: Arc<ToolRegistry>,
|
||||
pub hook_executor: Arc<HookExecutor>,
|
||||
pub memory_store: Option<Arc<dyn MemoryStore>>, // 弱引用(澄清 2 选项 B)
|
||||
pub retriever: Option<Arc<MemoryRetriever>>, // 弱引用(澄清 2 选项 B)
|
||||
pub config: AgentConfig,
|
||||
}
|
||||
|
||||
impl RuntimeBundle {
|
||||
/// 构造时如果 retriever 存在,自动注册为 "retrieve" tool。
|
||||
pub fn new(/* ... */) -> Self;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 `AgentSession`
|
||||
|
||||
```rust
|
||||
pub struct AgentSession {
|
||||
pub session_id: String,
|
||||
pub agent_name: String,
|
||||
bundle: Arc<RuntimeBundle>,
|
||||
turn_index: u32,
|
||||
cost_so_far: CostTracker,
|
||||
}
|
||||
|
||||
impl AgentSession {
|
||||
pub fn new(agent: &dyn Agent, session_id: impl Into<String>, bundle: Arc<RuntimeBundle>) -> Self;
|
||||
|
||||
/// 最小 reference impl:组装 LlmCycle + submit + 累计 cost。
|
||||
/// 不做 memory 回写(呼应"记忆在独立 task 处理"原则)。
|
||||
pub async fn submit_turn(
|
||||
&mut self,
|
||||
user_input: impl Into<String>,
|
||||
) -> Result<ChatResponse, AgentError>;
|
||||
|
||||
pub fn usage(&self) -> &CostTracker;
|
||||
pub fn turn_index(&self) -> u32;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 `TaskAgent` + `Plan` + `Step`
|
||||
|
||||
```rust
|
||||
pub struct Plan {
|
||||
pub id: String,
|
||||
pub goal: String,
|
||||
pub steps: Vec<Step>,
|
||||
}
|
||||
|
||||
pub struct Step {
|
||||
pub index: usize,
|
||||
pub description: String,
|
||||
pub status: StepStatus,
|
||||
}
|
||||
|
||||
pub enum StepStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed(ChatResponse),
|
||||
Failed(AgentError),
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// 注入式 Plan 解析器。
|
||||
#[async_trait]
|
||||
pub trait PlanParser: Send + Sync {
|
||||
async fn parse(&self, raw: &str, goal: &str) -> Result<Plan, AgentError>;
|
||||
}
|
||||
|
||||
/// 基于 serde_json 的参考实现(约 20 行)。
|
||||
pub struct JsonPlanParser;
|
||||
|
||||
#[async_trait]
|
||||
impl PlanParser for JsonPlanParser { /* ... */ }
|
||||
|
||||
/// TaskAgent 双入口。
|
||||
#[async_trait]
|
||||
pub trait TaskAgent: Agent {
|
||||
/// 自主式:内部用 LLM 拆 Plan → execute_plan
|
||||
async fn run(&mut self, session: &mut AgentSession, goal: &str) -> Result<Plan, AgentError>;
|
||||
|
||||
/// 外部驱动式:用户预定义 Plan → 逐步执行
|
||||
async fn execute_plan(
|
||||
&mut self,
|
||||
session: &mut AgentSession,
|
||||
plan: Plan,
|
||||
) -> Result<Plan, AgentError>;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 `AgentError`
|
||||
|
||||
```rust
|
||||
pub enum AgentError {
|
||||
Llm(LlmError),
|
||||
Tool(ToolError),
|
||||
Memory(MemoryError),
|
||||
PlanParse(String),
|
||||
HookBlocked(String),
|
||||
LimitExceeded(String),
|
||||
Config(String),
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl AgentError {
|
||||
pub fn is_recoverable(&self) -> bool { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 `AgentConfig` + `AgentBuilder`
|
||||
|
||||
```rust
|
||||
pub struct AgentConfig {
|
||||
pub max_turns: u32,
|
||||
pub max_tool_turns: u32,
|
||||
pub session_ttl: Option<Duration>,
|
||||
pub compact_config: Option<CompactConfig>,
|
||||
}
|
||||
|
||||
pub struct AgentBuilder { /* ... */ }
|
||||
|
||||
impl AgentBuilder {
|
||||
pub fn new() -> Self;
|
||||
pub fn provider(self, p: Arc<dyn LlmProvider>) -> Self;
|
||||
pub fn tool_registry(self, r: Arc<ToolRegistry>) -> Self;
|
||||
pub fn hook_executor(self, h: Arc<HookExecutor>) -> Self;
|
||||
pub fn memory_store(self, m: Arc<dyn MemoryStore>) -> Self; // 选填
|
||||
pub fn retriever(self, r: Arc<MemoryRetriever>) -> Self; // 选填
|
||||
pub fn config(self, c: AgentConfig) -> Self;
|
||||
pub fn build(self) -> Result<RuntimeBundle, AgentError>;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.7 Hook 扩展(`src/llm/hooks.rs` 改动)
|
||||
|
||||
```rust
|
||||
pub enum HookEvent {
|
||||
// ... 现有 4 个 ...
|
||||
|
||||
// 新增 3 个:
|
||||
OnTurnStart,
|
||||
OnTurnEnd,
|
||||
OnPlanStepComplete,
|
||||
}
|
||||
|
||||
// HookContext 扩展 2 个 Option 字段(澄清 4):
|
||||
pub struct HookContext {
|
||||
// ... 现有字段 ...
|
||||
pub turn_index: Option<u32>, // OnTurnStart/End 用
|
||||
pub plan_step_index: Option<usize>, // OnPlanStepComplete 用
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 文件清单
|
||||
|
||||
### 5.1 新增文件(7 个)
|
||||
|
||||
```
|
||||
src/agent.rs # 模块根 + pub use 重导出
|
||||
src/agent/agent.rs # Agent trait
|
||||
src/agent/runtime.rs # RuntimeBundle + AgentConfig
|
||||
src/agent/session.rs # AgentSession
|
||||
src/agent/task.rs # TaskAgent trait + Plan/Step + PlanParser + JsonPlanParser
|
||||
src/agent/builder.rs # AgentBuilder
|
||||
src/agent/error.rs # AgentError
|
||||
```
|
||||
|
||||
### 5.2 修改文件(3 个)
|
||||
|
||||
```
|
||||
src/lib.rs # + pub mod agent;
|
||||
src/llm/hooks.rs # + 3 个事件变体 + 2 个上下文字段(极小改)
|
||||
docs/roadmap.md # 状态翻转 + Phase 4 交付物清单更新(实施时再做)
|
||||
```
|
||||
|
||||
### 5.3 关联文档(已存在 / 待写)
|
||||
|
||||
```
|
||||
docs/note-agent-harness-references.md # 参考项目调研(已存在)
|
||||
docs/7-agent-runtime.md # 完整方案文档(路径 A 输出)
|
||||
docs/note-agent-runtime-design.md # 本文件
|
||||
```
|
||||
|
||||
## 6. 预估规模
|
||||
|
||||
- **新增代码**:约 **600-700 行**(含 2-3 个烟雾测试)
|
||||
- **修改代码**:约 **10-20 行**(`hooks.rs` 改动 + `lib.rs` + `roadmap.md`)
|
||||
- **方案文档**:约 **450-550 行 Markdown**(沿用 6-memory-system.md 的 6 段式结构)
|
||||
|
||||
## 7. 待办事项(按依赖顺序)
|
||||
|
||||
1. ✅ Phase 4 范围已收窄(§2.1)
|
||||
2. ✅ 核心架构已对齐 OpenHarness / Hermes(§3)
|
||||
3. ✅ 接口签名草案已固化(§4)
|
||||
4. ✅ 文件清单已确定(§5)
|
||||
5. ✅ 编号冲突已验证(最大是 `6`,新文件用 `7`)
|
||||
6. ⏳ 写 `docs/7-agent-runtime.md` 方案文档
|
||||
7. ⏳ 按文档实施 7 个新文件 + 3 个修改
|
||||
8. ⏳ 跑通 2-3 个烟雾测试
|
||||
9. ⏳ 更新 `docs/roadmap.md` 状态翻转
|
||||
|
||||
## 8. 一句话总结
|
||||
|
||||
> **Phase 4 = Phase 0-3 的薄胶水层 + 一组 trait 抽象**。**不**实现业务循环,**不**做产品级功能,**不**假设上层如何使用 memory。借鉴 OpenHarness 的"显式依赖注入容器"与 Hermes 的"实体/会话分离"模型,记忆以弱引用方式接入,`MemoryRetriever` 在 `RuntimeBundle::new()` 时自动注册为 LLM 可调用的 `retrieve` 工具。
|
||||
@@ -0,0 +1,222 @@
|
||||
# Context 切换方案设计备忘
|
||||
|
||||
> 创建日期:2026-06-10
|
||||
> 状态:备忘(Phase 4 不实现)
|
||||
> 关联文档:
|
||||
> - `docs/7-agent-runtime.md` — Phase 4 方案(含 SessionMemory 设计)
|
||||
> - `docs/note-opencode-agent-switching.md` — OpenCode 切换机制调研
|
||||
> - `docs/roadmap.md` — 项目总 Roadmap
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景
|
||||
|
||||
### 1.1 问题
|
||||
|
||||
在调研 OpenCode 的 Agent 切换机制后(详见 `docs/note-opencode-agent-switching.md`),发现其做法是:
|
||||
|
||||
- 切换 agent 时**不动消息历史**
|
||||
- 在 user message 末尾追加 `synthetic: true` 的 `<system-reminder>` 提醒
|
||||
- 同时**完全重新计算** system prompt
|
||||
|
||||
这个方法的问题是:**长上下文中频繁切换 agent 容易给 LLM 造成身份困惑**。同一个消息列表里有 `system: build` 的 identity,又出现 `system: plan` 的 identity,LLM 容易"串味"。
|
||||
|
||||
### 1.2 核心思路
|
||||
|
||||
以一个 session 里存在**多个独立的 context** 来解决,每个 context 有自己独立的 system prompt + 消息列表:
|
||||
|
||||
```
|
||||
OpenCode 模式(一条流):
|
||||
[system: build, user: A, ass: A', user: <切plan>, system: plan, user: B]
|
||||
↑ 身份困惑
|
||||
|
||||
建议的多 context 模式:
|
||||
session {
|
||||
context_a: [system: build, user: A, ass: A'] ← 只有 build 的 identity
|
||||
context_b: [system: plan, user: B, ass: B'] ← 只有 plan 的 identity
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 适用范围
|
||||
|
||||
| 场景 | 适用性 | 说明 |
|
||||
|------|--------|------|
|
||||
| Agent 切换(build ↔ plan) | ✅ 核心场景 | 同一 session 内更换角色 |
|
||||
| 主从 Agent 协作 | ✅ 核心场景 | primary 委派子任务给 subagent,subagent 独立运作 |
|
||||
| 长 session 上下文压缩 | ✅ 附带收益 | 拆分 context 后,每个 context 独立累积消息,不会互相拖长 |
|
||||
| 并行 context 执行 | ⚠️ 拓展场景 | context_a 和 context_b 可各自独立推进 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 三个候选方案
|
||||
|
||||
### 方案 A:OpenCode 式(system prompt 重算 + synthetic 追加)
|
||||
|
||||
**做法**:
|
||||
- 单一消息列表
|
||||
- 切换时重算 system prompt
|
||||
- user message 末尾追加 `<system-reminder>` 标签
|
||||
|
||||
**优点**:
|
||||
- 实现简单
|
||||
- 消息历史完整可见
|
||||
|
||||
**缺点**:
|
||||
- 长上下文身份困惑
|
||||
- context 互相污染(每个 context 都要看全部历史)
|
||||
|
||||
**结论**:❌ 否决。不解决身份困惑问题。
|
||||
|
||||
### 方案 B:信息池 + 切换不重置(借鉴 OpenCode + 增强)
|
||||
|
||||
**做法**:
|
||||
- 切换时保留历史
|
||||
- 使用 `<system-reminder>` 标签
|
||||
- 靠 prompt 工程让 LLM 理解身份变更
|
||||
|
||||
**优点**:
|
||||
- 历史连贯
|
||||
- 改动最小
|
||||
|
||||
**缺点**:
|
||||
- 仍然有身份困惑风险
|
||||
- 上下文不受控增长
|
||||
|
||||
**结论**:❌ 否决。治标不治本。
|
||||
|
||||
### 方案 C:多 context 隔离 + SessionMemory 桥接(推荐)
|
||||
|
||||
**做法**:
|
||||
- 每个 agent 切换创建一个新的 context(独立消息列表 + 独立 system prompt)
|
||||
- context 之间通过 `SessionMemory` 桥接关键信息
|
||||
- 切换时新 context 的 system prompt 末尾注入 `SessionMemory::snapshot()`
|
||||
|
||||
```
|
||||
context_a (build)
|
||||
→ 对话 50 轮
|
||||
→ 写入 SessionMemory: {"design_decision": "用 PostgreSQL",
|
||||
"files_changed": "src/db.rs"}
|
||||
→ 销毁(或沉睡)
|
||||
|
||||
创建 context_b (plan)
|
||||
→ system_prompt += snapshot()
|
||||
→ "<session-context>
|
||||
design_decision: 用 PostgreSQL
|
||||
files_changed: src/db.rs
|
||||
</session-context>"
|
||||
→ 对话 10 轮(不需要看 context_a 的 50 轮历史)
|
||||
→ 读 SessionMemory: get("design_decision") → "用 PostgreSQL"
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 身份稳定:每个 context 只有一套 system prompt
|
||||
- ✅ 上下文隔离:context_b 不受 context_a 的消息量影响
|
||||
- ✅ 信息桥接:关键结论通过 SessionMemory 显式传递
|
||||
- ✅ 并行潜力:两个 context 可各自运行
|
||||
|
||||
**缺点**:
|
||||
- ❌ 实现复杂度:从"一个消息列表"到"多个消息列表 + 桥接"
|
||||
- ❌ 信息完整性:LLM 自主决定"什么值得记",可能遗漏细节
|
||||
- ❌ 上层理解成本:应用层需要理解 context 概念
|
||||
|
||||
**结论**:✅ 推荐。架构上最干净,但 Phase 4 不做全部实现。
|
||||
|
||||
---
|
||||
|
||||
## 3. SessionMemory 桥接机制(方案 C 的核心)
|
||||
|
||||
### 3.1 设计决策
|
||||
|
||||
| 决策 | 结论 | 理由 |
|
||||
|------|------|------|
|
||||
| 复用 Phase 3 `MemoryStore` | ✅ 是 | 不引入新存储机制 |
|
||||
| 跨进程支持 | ✅ 是 | 换后端即可(Redis / SQLite),`InMemoryStore` 兜底 |
|
||||
| namespace 隔离 | ✅ 是 | `_session_{session_id}` 命名空间 |
|
||||
| 谁写 SessionMemory | LLM 通过 tool 显式写(v0.2+)或上层应用 API 写 | 不支持自动写——避免 "写太多 = 噪音,写太少 = 遗漏" |
|
||||
| snapshot 格式 | `<session-context>` XML 风格 | 专为注入 system prompt 设计 |
|
||||
|
||||
### 3.2 谁写 SessionMemory 的三种选项
|
||||
|
||||
| 选项 | 描述 | 评估 |
|
||||
|------|------|------|
|
||||
| **选项 1:AgentSession 自动写** | 每轮对话后自动摘录关键信息 | ❌ 摘录什么?容易变成精简版对话历史,失去"关键信息"的定位 |
|
||||
| **选项 2:LLM 通过 tool 显式写** | 把 `SessionMemory::set` 暴露为 Tool 供 LLM 调用 | ✅ LLM 自主决定什么值得记;v0.2+ 实现自动注册 |
|
||||
| **选项 3:上层应用 API 写** | `agent_session.session_memory.set("k", "v")` | ✅ Phase 4 即可用,最透明 |
|
||||
|
||||
**Phase 4 实现选项 3**,v0.2+ 补充选项 2(tool 自动注册)。
|
||||
|
||||
### 3.3 三层记忆体系
|
||||
|
||||
```
|
||||
持久层(Phase 3) MemoryStore / KnowledgeStore ── 跨 session 持久,长期知识
|
||||
会话层(Phase 4) SessionMemory ── 单 session 内共享,context 桥接
|
||||
对话层(Phase 3) ConversationMemory ── 单 context 内消息历史
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 4 范围 vs v0.2+ 范围
|
||||
|
||||
### ✅ Phase 4 做
|
||||
|
||||
| 组件 | 状态 | 行数 |
|
||||
|------|------|------|
|
||||
| `SessionMemory` struct | ✅ 做 | ~40 行 |
|
||||
| `AgentSession` + `session_memory` 字段 | ✅ 做 | ~3 行 |
|
||||
| `AgentSession` 持 `Arc<dyn Agent>` 替代 `agent_name: String` | ✅ 做 | ~3 行 |
|
||||
| `RuntimeBundle` + `session_memory_backend` 字段 | ✅ 做 | ~1 行 |
|
||||
| `AgentBuilder` + `.session_memory_backend()` | ✅ 做 | ~3 行 |
|
||||
|
||||
### ❌ 延后到 v0.2+
|
||||
|
||||
| 组件 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| Context 切换管理(`switch_context` / `create_context`) | ❌ 延后 | 需要 `ContextManager` 包装 |
|
||||
| 多 context 生命周期管理 | ❌ 延后 | context 的创建/销毁/切换策略 |
|
||||
| `"session_memory_set"` tool 自动注册 | ❌ 延后 | 在 `ToolRegistry` 里注册特殊 tool |
|
||||
| Context 级别的 `ConversationMemory` 自动管理 | ❌ 延后 | 每个 context 独立消息历史 |
|
||||
|
||||
### 延后的理由
|
||||
|
||||
1. **最小范围原则**:Phase 4 定位是"薄胶水层 + trait 抽象",多 context 管理属于业务编排的范畴
|
||||
2. **稳定 API 优先**:先把 `AgentSession` / `RuntimeBundle` / `SessionMemory` 的 API 定稳,v0.2+ 在上面搭建 context 切换
|
||||
3. **降低实施风险**:Phase 4 已有 13 个交付任务,加 context 切换会增加 2-3 倍复杂度
|
||||
|
||||
---
|
||||
|
||||
## 5. v0.2+ Context 切换的设想接口
|
||||
|
||||
> 以下为未来实现的草案,非承诺。记录在这里避免 v0.2+ 重新设计时丢失上下文。
|
||||
|
||||
```rust
|
||||
pub struct ContextManager {
|
||||
contexts: HashMap<String, AgentSession>,
|
||||
active_context: String,
|
||||
session_memory: SessionMemory,
|
||||
}
|
||||
|
||||
impl ContextManager {
|
||||
/// 创建一个新的 context,绑定指定 agent
|
||||
pub fn create_context(&mut self, id: &str, agent: Arc<dyn Agent>) -> Result<(), AgentError>;
|
||||
|
||||
/// 切换到已有 context
|
||||
pub fn switch_context(&mut self, id: &str) -> Result<&mut AgentSession, AgentError>;
|
||||
|
||||
/// 销毁 context
|
||||
pub fn destroy_context(&mut self, id: &str) -> Result<(), AgentError>;
|
||||
|
||||
/// 从 context_a 桥接关键信息到 context_b 的 system prompt
|
||||
pub fn bridge(&mut self, from: &str, to: &str) -> Result<(), AgentError>;
|
||||
}
|
||||
```
|
||||
|
||||
切换流程:
|
||||
1. `context_manager.create_context("plan", plan_agent)` — 新 context 的 system prompt 自动附加 `session_memory.snapshot()`
|
||||
2. `context_manager.switch_context("plan")` — 返回 context 的 `AgentSession`,应用层调 `submit_turn`
|
||||
3. context 销毁时,关键信息经由 LLM 或上层应用写入 `SessionMemory`
|
||||
|
||||
---
|
||||
|
||||
## 6. 一句话总结
|
||||
|
||||
> **多 context 切换方案 = `SessionMemory`(Phase 4 做信息桥接基础) + `ContextManager`(v0.2+ 做切换管理)。Phase 4 只铺"水管接口",不装"水循环系统"。**
|
||||
@@ -0,0 +1,266 @@
|
||||
# 知识图谱与高级检索设计(Phase 4 备用)
|
||||
|
||||
> 本文记录 Phase 3 设计过程中裁剪的内容,待 Phase 4(Agent 运行时)制定时参考。
|
||||
> 来源:`docs/6-memory-system.md` v1 版本,2026-06-07
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
Phase 3 记忆系统方案做减法后,以下设计被推迟到 Phase 4。这些组件需要 Agent 的编排能力(LLM 提取标签、自动维护知识图谱、智能检索策略)才能真正产生价值,因此不适合在 Phase 3 的存储层实现。
|
||||
|
||||
---
|
||||
|
||||
## 1. KnowledgeGraph(知识图谱)
|
||||
|
||||
### 1.1 设计意图
|
||||
|
||||
实体-关系图存储,用于关联检索。与 KnowledgeStore(内容/页面级)互补,提供实体级 + 关系维度的检索能力。
|
||||
|
||||
```
|
||||
KnowledgeStore: 页面级内容("什么是 X")
|
||||
KnowledgeGraph: 实体级关系("X 与什么相关")
|
||||
```
|
||||
|
||||
### 1.2 接口设计(原方案)
|
||||
|
||||
```rust
|
||||
pub struct GraphEntity {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub entity_type: String, // "person" | "concept" | "project" | ...
|
||||
pub description: String,
|
||||
pub tags: Vec<String>, // 检索标签(全小写,原子词)
|
||||
}
|
||||
|
||||
pub struct GraphRelation {
|
||||
pub source_id: String,
|
||||
pub target_id: String,
|
||||
pub relation_type: String, // "works_on" | "part_of" | "related_to" | ...
|
||||
pub weight: f32, // 关系强度 [0.0, 1.0]
|
||||
}
|
||||
|
||||
pub enum RelationDirection {
|
||||
Outgoing, // source_id -> target_id(默认)
|
||||
Incoming, // target_id -> source_id
|
||||
Both, // 双向遍历
|
||||
}
|
||||
|
||||
pub struct ScoredEntity {
|
||||
pub entity: GraphEntity,
|
||||
pub score: f32, // 基于图距离的评分 [0.0, 1.0]
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait KnowledgeGraph: Send + Sync {
|
||||
// 实体管理
|
||||
async fn add_entity(&self, entity: GraphEntity) -> Result<(), MemoryError>;
|
||||
async fn get_entity(&self, id: &str) -> Result<Option<GraphEntity>, MemoryError>;
|
||||
async fn remove_entity(&self, id: &str) -> Result<(), MemoryError>;
|
||||
|
||||
// 关系管理
|
||||
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>;
|
||||
async fn get_related(
|
||||
&self,
|
||||
entity_id: &str,
|
||||
depth: usize,
|
||||
direction: RelationDirection,
|
||||
relation_types: Option<&[&str]>,
|
||||
) -> Result<Vec<ScoredEntity>, MemoryError>;
|
||||
|
||||
// 检索
|
||||
async fn find_by_keywords(&self, keywords: &[String]) -> Result<Vec<GraphEntity>, MemoryError>;
|
||||
|
||||
// 标签管理
|
||||
async fn find_tags(&self, prefix: &str) -> Result<Vec<String>, MemoryError>;
|
||||
async fn entity_count_by_tag(&self, tag: &str) -> Result<usize, MemoryError>;
|
||||
async fn set_entity_tags(&self, entity_id: &str, tags: Vec<String>) -> Result<usize, MemoryError>;
|
||||
fn tag_constraints(&self) -> TagConstraints;
|
||||
}
|
||||
|
||||
pub struct TagConstraints {
|
||||
pub max_tags_per_entity: usize, // 默认 8
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 标签复用原则
|
||||
|
||||
标签不应随意增长,应优先复用已有标签。流程:
|
||||
|
||||
```
|
||||
LLM 提取候选标签 → 对每个候选:
|
||||
graph.find_tags(candidate.lowercase())
|
||||
├─ 命中已有标签 → 复用
|
||||
└─ 无匹配 → 注册新标签
|
||||
```
|
||||
|
||||
### 1.4 标签容量与精炼
|
||||
|
||||
每个实体最多 `max_tags_per_entity`(默认 8)个标签,按关联度降序排列。超出上限时保留关联度最高的标签。
|
||||
|
||||
### 1.5 InMemoryGraph 实现
|
||||
|
||||
```rust
|
||||
pub struct InMemoryGraph {
|
||||
entities: Mutex<HashMap<String, GraphEntity>>,
|
||||
relations: Mutex<Vec<GraphRelation>>,
|
||||
tag_index: Mutex<HashMap<String, HashSet<String>>>, // tag → entity_ids
|
||||
}
|
||||
```
|
||||
|
||||
图遍历使用 BFS/DFS 算法,需用 `HashSet<String>` 防环。
|
||||
|
||||
---
|
||||
|
||||
## 2. 高级评分策略
|
||||
|
||||
### 2.1 ScoringStrategy
|
||||
|
||||
Phase 3 仅使用内部的简单 TextOverlap 评分(Dice 系数)。Phase 4 可引入以下策略:
|
||||
|
||||
```rust
|
||||
pub struct ScoreWeights {
|
||||
pub overlap: f32, // 默认 0.5 — 文本重叠度,以原始 query 为基准
|
||||
pub graph: f32, // 默认 0.2 — 图距离
|
||||
pub temporal: f32, // 默认 0.1 — 时间衰减
|
||||
pub reference: f32, // 默认 0.2 — 引用计数
|
||||
}
|
||||
|
||||
pub enum ScoringStrategy {
|
||||
TextOverlap, // 以原始 query 为准绳的文本重叠度(默认)
|
||||
GraphDistance,
|
||||
TemporalWeight,
|
||||
ReferenceCount,
|
||||
Hybrid(ScoreWeights),
|
||||
}
|
||||
|
||||
pub struct ScoreBreakdown {
|
||||
pub overlap_score: f32,
|
||||
pub graph_score: f32,
|
||||
pub temporal_score: f32,
|
||||
pub reference_score: f32,
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 TextOverlap 算法
|
||||
|
||||
基于 Dice 系数计算 query 与召回内容的文本重叠度:
|
||||
|
||||
```
|
||||
Dice = 2 × |intersect(bigrams)| / (|bigrams_query| + |bigrams_content|)
|
||||
```
|
||||
|
||||
标题权重大于摘要,摘要权重大于正文。
|
||||
|
||||
---
|
||||
|
||||
## 3. 高级检索(MemoryRetriever 双通道版)
|
||||
|
||||
Phase 3 仅保留单通道(只搜 KnowledgeStore)。Phase 4 可恢复双通道:
|
||||
|
||||
```rust
|
||||
pub struct MemoryRetriever {
|
||||
knowledge_store: KnowledgeStore,
|
||||
knowledge_graph: Arc<dyn KnowledgeGraph>,
|
||||
keyword_extractor: Arc<dyn KeywordExtractor>,
|
||||
config: RetrieverConfig,
|
||||
}
|
||||
|
||||
pub enum RetrievalStrategy {
|
||||
Hybrid, // 结合所有通道 + 评分排序(默认)
|
||||
KnowledgeOnly, // 仅 KnowledgeStore
|
||||
GraphOnly, // 仅 KnowledgeGraph
|
||||
}
|
||||
```
|
||||
|
||||
检索流程:
|
||||
|
||||
```
|
||||
1. 关键词提取(KeywordExtractor)
|
||||
2. 并行召回:
|
||||
- KnowledgeStore.find_by_keywords(keywords)
|
||||
- KnowledgeGraph.find_by_keywords(keywords) → get_related() 图遍历
|
||||
3. 逐条评分(ScoringStrategy)
|
||||
4. 过滤 score < min_score
|
||||
5. 排序 → 截取 top-N
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. KeywordExtractor
|
||||
|
||||
```rust
|
||||
pub trait KeywordExtractor: Send + Sync {
|
||||
fn extract(&self, query: &str) -> Vec<String>;
|
||||
}
|
||||
|
||||
pub struct SimpleKeywordExtractor {
|
||||
stop_words: HashSet<String>,
|
||||
}
|
||||
```
|
||||
|
||||
默认实现:按非字母数字字符分割,过滤停用词和单字符词。停用词表应包含英语常用停用词(约 80-100 个)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 基于召回价值的淘汰(RecallBased)
|
||||
|
||||
### 5.1 记忆价值评分
|
||||
|
||||
每条记忆维护召回统计,计算综合价值分数:
|
||||
|
||||
```rust
|
||||
pub struct RecallStats {
|
||||
pub recall_count: u64, // 累计召回次数
|
||||
pub total_score: f64, // 累计评分(平均分 = total_score / recall_count)
|
||||
pub last_recall_at: i64, // 最后一次被召回的时间戳(秒)
|
||||
}
|
||||
|
||||
// 记忆价值公式:
|
||||
// value = ln(1 + recall_count) × w_recall + avg_score × w_score + recency × w_recency
|
||||
```
|
||||
|
||||
### 5.2 record_recall()
|
||||
|
||||
```rust
|
||||
// MemoryStore trait 可选方法
|
||||
async fn record_recall(&self, id: &str, score: f32) -> Result<(), MemoryError> {
|
||||
Ok(()) // 默认空实现,需覆盖
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 淘汰策略
|
||||
|
||||
```rust
|
||||
pub enum EvictionPolicy {
|
||||
// ...Phase 3 已有: None, Ttl, Capacity...
|
||||
|
||||
RecallBased {
|
||||
max_items: usize,
|
||||
recall_weight: f32, // 默认 0.3
|
||||
score_weight: f32, // 默认 0.5
|
||||
recency_weight: f32, // 默认 0.2
|
||||
},
|
||||
Hybrid {
|
||||
ttl_secs: Option<u64>,
|
||||
max_items: Option<usize>,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 3 → Phase 4 迁移建议
|
||||
|
||||
| 组件 | Phase 3 状态 | Phase 4 迁移方式 |
|
||||
|------|-------------|-----------------|
|
||||
| KnowledgeStore | 具体 struct(基于 MemoryStore) | 保持 struct,新增知识图谱数据入口 |
|
||||
| KnowledgeGraph | 不存在 | 新建 `memory/graph.rs`,实现 trait + InMemoryGraph |
|
||||
| MemoryRetriever | 单通道(仅 KnowledgeStore) | 增加 KnowledgeGraph 通道,恢复双通道检索 |
|
||||
| ScoringStrategy | 内部 TextOverlap | 恢复枚举策略 + MemoryRetriever 配置 |
|
||||
| KeywordExtractor | MemoryRetriever 内部拆分逻辑 | 抽取为独立 struct |
|
||||
| RecallBased 淘汰 | 不存在 | 恢复 EvictionPolicy 变体 + RecallStats |
|
||||
| 标签管理 | 不存在 | 恢复 tag_index + find_tags + set_entity_tags |
|
||||
|
||||
**关键依赖**:Phase 4 的 Agent 编排是 KnowledgeGraph 和标签管理的驱动者。如果没有 Agent 的 LLM 调用来提取标签、维护知识,KnowledgeGraph 只是一个空的图存储。
|
||||
@@ -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 framework:abstractions and integrations for models, tools, and agent loops.
|
||||
> **LangGraph** = orchestration runtime:durable 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 GA(2025-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 调试** | ✅ 稳定 | 回滚任意 checkpoint,fork 重放 |
|
||||
| **流式输出** | ✅ 稳定 | 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 GA(2025-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,174 @@
|
||||
# OpenCode Agent 切换机制调研笔记
|
||||
|
||||
> 调研日期:2026-06-09
|
||||
> 调研方式:直接读 `sst/opencode` 源码(本地路径 `/Users/midnite/Samples/opencode`)
|
||||
> 调研目标:OpenCode 在切换 agent 时,整个上下文(系统提示词 + agent 提示词)是如何注入的
|
||||
> 关联:
|
||||
> - `docs/note-agent-harness-references.md` — 之前的参考项目调研
|
||||
> - `docs/note-agent-runtime-design.md` — AG Core Phase 4 设计决策
|
||||
> - `docs/7-agent-runtime.md` — AG Core Phase 4 方案文档
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目背景
|
||||
|
||||
| 维度 | 详情 |
|
||||
|------|------|
|
||||
| 项目 | `sst/opencode`(GitHub),不是 npm `opencode-ai`(npm 只发编译产物) |
|
||||
| 规模 | 160k+ stars、900+ contributors、13k+ commits |
|
||||
| 语言 | TypeScript + Bun 运行时 + Effect(依赖注入 / Layer) |
|
||||
| 定位 | 开源 AI 编程代理(TUI / Desktop / IDE 插件),与 Claude Code / Cursor 对标 |
|
||||
| Agent 切换 | 终端按 **Tab 键** 在 primary agent 之间循环(build / plan) |
|
||||
|
||||
## 2. Agent 分类
|
||||
|
||||
OpenCode 把 agent 严格分为三类:
|
||||
|
||||
| 类型 | 内置 | 触发方式 | 用途 |
|
||||
|------|------|---------|------|
|
||||
| **Primary(主代理)** | build / plan | Tab 键循环切换 | 用户直接交互 |
|
||||
| **Subagent(子代理)** | general / explore / scout | 主代理自动调 / 用户 `@提及` | 专门任务 |
|
||||
| **Hidden system(隐藏)** | compaction / title / summary | 框架自动调,用户不可见 | 系统级 |
|
||||
|
||||
源码定义在 `packages/opencode/src/agent/agent.ts` 的 `Agent.Info` schema:
|
||||
|
||||
```typescript
|
||||
{ name, description, mode, native, hidden,
|
||||
topP, temperature, color,
|
||||
permission: PermissionV1.Ruleset,
|
||||
model, variant, prompt, options, steps }
|
||||
```
|
||||
|
||||
每个 agent 是**纯配置对象**,可由用户 `opencode.json` 覆盖或自定义 `.md` 文件定义。
|
||||
|
||||
## 3. 核心:System Prompt 完整拼接机制
|
||||
|
||||
OpenCode 把 system prompt 分为 **3 层**,每次 LLM 调用时**完整重新计算**(不缓存)。
|
||||
|
||||
### 3.1 拼接顺序
|
||||
|
||||
源码:`packages/opencode/src/session/llm/request.ts:58-66`
|
||||
|
||||
```typescript
|
||||
const system = [
|
||||
// Layer1:主 agent 提示词
|
||||
...(input.agent.prompt
|
||||
? [input.agent.prompt] // ① agent 自带 prompt(如 PROMPT_EXPLORE)
|
||||
: SystemPrompt.provider(input.model)), // ② 或按 model 选择 provider prompt
|
||||
|
||||
// Layer2:动态上下文(prompt.ts:1408-1414)
|
||||
...input.system, // ③ env + instructions + skills
|
||||
|
||||
// Layer3:用户自定义 system
|
||||
...(input.user.system ? [input.user.system] : []), // ④ 单次 user 消息的 system 字段
|
||||
]
|
||||
.filter(x => x)
|
||||
.join("\n")
|
||||
```
|
||||
|
||||
### 3.2 Layer 2 的内部构成
|
||||
|
||||
源码:`packages/opencode/src/session/prompt.ts:1408-1414`
|
||||
|
||||
```typescript
|
||||
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
|
||||
sys.skills(agent), // 当前 agent 可用的 skills 描述
|
||||
sys.environment(model), // 工作目录、日期、平台、git 状态
|
||||
instruction.system(), // 自动读取 AGENTS.md / CLAUDE.md / CONTEXT.md
|
||||
MessageV2.toModelMessagesEffect(msgs, model),
|
||||
])
|
||||
const system = [...env, ...instructions, ...(skills ? [skills] : [])]
|
||||
```
|
||||
|
||||
**关键发现**:
|
||||
- **AGENTS.md / CLAUDE.md 是 instruction 自动注入**,不是用户手动 @引用
|
||||
- `system.ts` 的 `provider()` 函数**根据 model ID 选择不同 .txt 模板**(如 `PROMPT_ANTHROPIC` / `PROMPT_GEMINI` / `PROMPT_CODEX`)
|
||||
- `environment()` 注入**运行时环境信息**(cwd、平台、日期)
|
||||
|
||||
### 3.3 Agent 配置中的 `prompt` 字段
|
||||
|
||||
源码:`packages/opencode/src/agent/agent.ts`
|
||||
|
||||
```typescript
|
||||
// build / plan 没有 prompt 字段 → 走 SystemPrompt.provider(model)
|
||||
build: { name: "build", mode: "primary", permission: ... },
|
||||
plan: { name: "plan", mode: "primary", permission: ... },
|
||||
|
||||
// explore / compaction / title / summary 有显式 prompt
|
||||
explore: { ..., prompt: PROMPT_EXPLORE, mode: "subagent" },
|
||||
compaction: { ..., prompt: PROMPT_COMPACTION, mode: "primary", hidden: true },
|
||||
title: { ..., prompt: PROMPT_TITLE, mode: "primary", hidden: true },
|
||||
summary: { ..., prompt: PROMPT_SUMMARY, mode: "primary", hidden: true },
|
||||
```
|
||||
|
||||
**结论**:agent 的 `prompt` 字段**只决定 Layer 1 的内容**。Layer 2(env/instructions/skills)和 Layer 3(user.system)始终拼上。
|
||||
|
||||
## 4. 核心:Agent 切换时的 4 个动作
|
||||
|
||||
源码:`packages/opencode/src/session/reminders.ts`(**整个文件 92 行就是答案**)
|
||||
|
||||
OpenCode 用 **`synthetic: true` 的 text part 注入到 user message**,而不是修改 system prompt。
|
||||
|
||||
| 切换方向 | 动作 | 模板文件 | 大小 |
|
||||
|---------|------|---------|------|
|
||||
| 任意 → **plan** | user message 追加 `PROMPT_PLAN` | `session/prompt/plan.txt` | 26 行 |
|
||||
| **plan → build** | user message 追加 `BUILD_SWITCH` | `session/prompt/build-switch.txt` | **5 行** |
|
||||
| build → **plan** (experimental) | user message 追加 `PLAN_MODE` | `session/prompt/plan-mode.txt` | 70 行 |
|
||||
| 任意切换 | system prompt **完全重算** | `request.ts:58-66` | — |
|
||||
|
||||
**最关键的发现——`build-switch.txt` 全文只有 5 行**:
|
||||
|
||||
```
|
||||
<system-reminder>
|
||||
Your operational mode has changed from plan to build.
|
||||
You are no longer in read-only mode.
|
||||
You are permitted to make file changes, run shell commands, and utilize your arsenal of tools as needed.
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
**机制总结**:
|
||||
1. 切换时**不动 message history**(之前所有 user/assistant/tool 消息完整保留)
|
||||
2. 通过**比较 `msg.info.agent` 字段**判断上一条 assistant 用的哪个 agent
|
||||
3. 在**当前 user message 末尾追加**一个 `synthetic: true` 的 text part
|
||||
4. 同时**重新计算 system prompt**(Layer 1 根据新 agent 的 `prompt` 字段切换)
|
||||
|
||||
## 5. 关键设计决策
|
||||
|
||||
| 决策 | 做法 | 原因推断 |
|
||||
|------|------|---------|
|
||||
| **system prompt 重算而非缓存** | 每次 LLM 调用都重新拼接 | agent / model / instructions 都可能动态变化 |
|
||||
| **历史消息不重置** | 切换 = 追加 synthetic part | 保持上下文连贯,避免"切换即失忆" |
|
||||
| **切换提醒伪装成 user 内容** | `<system-reminder>` 标签 + `synthetic: true` | 大多数 LLM 对 `<system-reminder>` 标签有特殊信任 |
|
||||
| **agent prompt 与 model prompt 二选一** | `agent.prompt ?? SystemPrompt.provider(model)` | build/plan 共享 model prompt,自定义 agent 可独立 prompt |
|
||||
| **AGENTS.md 自动注入** | instruction.service 每轮扫描 | Claude Code 兼容,提升跨工具体验 |
|
||||
|
||||
## 6. 与 AG Core Phase 4 的对应关系
|
||||
|
||||
| OpenCode 机制 | AG Core 对应 | 借鉴价值 |
|
||||
|--------------|-------------|---------|
|
||||
| 3 层 system prompt 拼接 | `AgentSession::submit_turn` 中组装 `LlmCycle.with_system_prompt()` | **高**——可拆分为「base prompt + agent prompt + env context」 |
|
||||
| agent 切换时追加 synthetic part | Phase 4 v1 **不做**(仅保留单 agent 角色) | 中——v0.2+ 才考虑 |
|
||||
| AGENTS.md 自动注入 | `prompt::PromptTemplate` + 文件加载(应用层) | 低——文件 I/O 是应用层职责 |
|
||||
| 权限矩阵三态 (allow/ask/deny) | `tools::PermissionChecker`(Phase 2 已实现) | 已有 |
|
||||
| Hidden system agent (Compaction) | `llm::compact`(Phase 0 已实现) | 已有 |
|
||||
| Tab 键循环切换 | 应用层 UI 概念 | 不在 core 库范围 |
|
||||
|
||||
## 7. 借鉴 / 不借鉴清单
|
||||
|
||||
### ✅ 值得借鉴(v0.2+ 考虑)
|
||||
|
||||
1. **`AgentSession` 应支持"切换 agent 但保留历史"**——目前 Phase 4 v1 不做,但 trait 设计上要预留空间
|
||||
2. **System prompt 拆分为多层**——`base_prompt + agent_prompt + env_context`,便于将来按 agent 类型切换
|
||||
3. **synthetic message 模式**——切换 agent 时插入"状态变更通知"而非修改历史
|
||||
|
||||
### ❌ 不借鉴
|
||||
|
||||
- Tab 键循环切换(应用层 UI 概念)
|
||||
- `.md` agent 定义文件(应用层文件加载)
|
||||
- `mode: primary/subagent` 区分(AG Core 是 lib 不做 UI 角色区分)
|
||||
- Hidden system agent 字段(AG Core 已在 L0/L1 实现等价能力)
|
||||
- AGENTS.md 自动注入(应用层职责)
|
||||
|
||||
## 8. 一句话总结
|
||||
|
||||
> **OpenCode 的 agent 切换机制 = "system prompt 完全重算" + "user message 追加 5 行 synthetic 提醒"。** Agent 切换**不**重置消息历史,**不**改写之前内容,只在末尾追加一条"状态变更通知",并按新 agent 重新组装 system prompt 的 Layer 1(agent 专属 prompt)。
|
||||
@@ -0,0 +1,344 @@
|
||||
# 笔记:opencode 子代理调度、分发与合并及工作流推进
|
||||
|
||||
> 基于 `/Users/midnite/Samples/opencode` 源码调研,2026-07-04
|
||||
|
||||
---
|
||||
|
||||
## 一、整体架构
|
||||
|
||||
```
|
||||
LLM(主 Agent)
|
||||
│
|
||||
├── 调用 Task tool(tool 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 part(synthetic: 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()` | 创建 job,fork 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. 取 tasks(subtask / 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 part(pending 状态) |
|
||||
| `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` agent(hidden, 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,243 @@
|
||||
# 方案:重构 `types.rs` 为完整的 OpenAI 兼容 API 类型系统
|
||||
|
||||
## 1. 现状分析
|
||||
|
||||
### 当前问题
|
||||
|
||||
| 问题 | 详细 |
|
||||
|------|------|
|
||||
| **无 serde** | 所有类型只有 `Debug + Clone`,无 `Serialize/Deserialize`,迫使 `OpenaiProvider` 手动构建 JSON(354 行中约 200 行是序列化代码) |
|
||||
| **请求参数不全** | `ChatRequest` 只支持 `model, messages, system_prompt, tools, max_tokens, temperature, extra_body`,缺失 streaming、response_format、tool_choice、stop、reasoning_effort 等 30+ 参数 |
|
||||
| **响应类型太薄** | `ChatResponse` 只返回 `message + usage + stop_reason`,缺失 `id, created, model, choices` 数组、`logprobs`、`system_fingerprint` 等 |
|
||||
| **无流式支持** | 无 `ChatCompletionChunk` 类型,无法处理 SSE 流式响应 |
|
||||
| **反向依赖** | `types.rs` 引用 `cycle::usage::Usage`,造成模块间反向依赖 |
|
||||
| **手动解析易出错** | `parse_response()` 从 `Value` 中逐字段解析,逻辑脆弱,不支持复杂嵌套类型 |
|
||||
|
||||
### OpenAI API 参考文档覆盖范围
|
||||
|
||||
已完整阅读文档(2177 行),涵盖了完整的请求参数(35+ 个顶层参数)和响应结构。
|
||||
|
||||
## 2. 新类型系统设计
|
||||
|
||||
### 架构
|
||||
|
||||
将 `types.rs` 重构为 Rust 新风格模块目录(符合项目已有惯例),按功能领域拆分:
|
||||
|
||||
```
|
||||
src/llm/
|
||||
├── types/
|
||||
│ ├── mod.rs # 模块根:re-exports + 基础枚举/共用类型
|
||||
│ ├── request.rs # 请求参数(ChatCompletionRequest 等)
|
||||
│ ├── response.rs # 响应类型(ChatCompletionResponse + ChatCompletionChunk)
|
||||
│ ├── message.rs # 消息类型(6 种角色消息 + content parts)
|
||||
│ ├── tool.rs # 工具定义 + 工具调用
|
||||
│ ├── usage.rs # Token 用量(从 cycle/usage.rs 移入,消除反向依赖)
|
||||
│ └── shared.rs # 共用枚举(ReasoningEffort, ServiceTier, ResponseFormat 等)
|
||||
```
|
||||
|
||||
同时,将 `cycle/usage.rs` 中的 `Usage` 和 `CostTracker` **移到** `types/usage.rs`,`cycle/usage.rs` 保留 `pub use` 兼容 re-export。
|
||||
|
||||
### 核心决策
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| **序列化方式** | 全部类型 derive `Serialize, Deserialize` | 消除手动 JSON 构建,让 provider 直接 `.json(&req)` / `.json::<Res>()` |
|
||||
| **类型风格** | 直接映射 OpenAI API JSON 形状 | 一目了然,与 API 文档 1:1 对应,调试方便 |
|
||||
| **命名策略** | 添加 `OpenAI` 前缀(如 `OpenaiChatRequest`) | 明确标注为 OpenAI 兼容类型 |
|
||||
| **字段命名** | `#[serde(rename_all = "snake_case")]` | OpenAI API 使用 snake_case |
|
||||
| **可选字段** | `#[serde(skip_serializing_if = "Option::is_none")]` | 不序列化 None 字段,保持请求体干净 |
|
||||
| **默认值** | `#[serde(default)]` | 反序列化时缺失字段用默认值 |
|
||||
| **后向兼容** | 通过类型别名保持 `ChatRequest`/`ChatResponse` 等名称可用 | LlmProvider/LlmCycle 接口不变 |
|
||||
| **泛化策略** | Anthropic 是独立体系,暂不纳入当前设计 | 保持当前类型系统专注 OpenAI,Provider 层做转换 |
|
||||
|
||||
### 关键类型设计原则
|
||||
|
||||
- **`OpenaiChatRequest`**:统一结构体(不拆分 NonStreaming/Streaming),包含 `stream: Option<bool>` 字段,所有字段均为 `Option`,build 时 `skip_serializing_if`
|
||||
- **`OpenaiChatResponse`**:直接对应 `ChatCompletion`(完整响应),保留完整 choices 数组等所有字段
|
||||
- **`OpenaiChatChunk`**:对应流式 chunk,`object = "chat.completion.chunk"`
|
||||
- **消息系统**:用单个 `OpenaiChatMessage` enum 覆盖 6 种角色消息类型(Developer/System/User/Assistant/Tool/Function),每种内部使用对应 struct
|
||||
- **Content parts**:`OpenaiContentPart` enum 覆盖 text/image_url/input_audio/file/refusal
|
||||
|
||||
## 3. 完整类型清单
|
||||
|
||||
### `types/mod.rs` — 共用类型
|
||||
```
|
||||
Role → enum { Developer, System, User, Assistant, Tool, Function }
|
||||
FinishReason → enum { Stop, Length, ToolCalls, ContentFilter, FunctionCall }
|
||||
ServiceTier → enum { Auto, Default, Flex, Scale, Priority }
|
||||
Modality → enum { Text, Audio }
|
||||
ImageDetail → enum { Auto, Low, High }
|
||||
AudioFormat → enum { Wav, Mp3, Aac, Flac, Opus, Pcm16 }
|
||||
Voice → struct { id: String } 或预定义枚举
|
||||
SearchContextSize → enum { Low, Medium, High }
|
||||
StopSequence → enum { Single(String), Multiple(Vec<String>) }
|
||||
Verbosity → enum { Low, Medium, High }
|
||||
```
|
||||
|
||||
### `types/request.rs` — 请求参数
|
||||
```
|
||||
OpenaiChatRequest → struct (35+ 字段,所有 OpenAI 参数)
|
||||
ResponseFormat → enum { Text, JsonObject { .. }, JsonSchema { .. } }
|
||||
ToolChoice → enum { None, Auto, Required, Named { .. }, AllowedTools { .. } }
|
||||
StreamOptions → struct { include_usage, include_obfuscation }
|
||||
AudioParam → struct { format, voice }
|
||||
PredictionContent → struct { type, content }
|
||||
WebSearchOptions → struct { search_context_size, user_location }
|
||||
UserLocation → struct { type, approximate: Approximate }
|
||||
Approximate → struct { city, country, region, timezone }
|
||||
FunctionCallOption → struct { name } // deprecated
|
||||
FunctionDefinition → struct { name, description, parameters, strict }
|
||||
OpenaiTool → enum { Function { .. }, Custom { .. } }
|
||||
```
|
||||
|
||||
### `types/response.rs` — 响应类型
|
||||
```
|
||||
OpenaiChatResponse → struct { id, object, created, model, choices, usage, system_fingerprint, service_tier }
|
||||
Choice → struct { index, message, finish_reason, logprobs }
|
||||
OpenaiChatMessage → struct { content, refusal, role, tool_calls, function_call, audio, annotations }
|
||||
OpenaiChatChunk → struct { id, object, created, model, choices, usage, system_fingerprint, service_tier }
|
||||
ChunkChoice → struct { index, delta, logprobs, finish_reason }
|
||||
Delta → struct { role, content, tool_calls, function_call }
|
||||
Logprobs → struct { content, refusal }
|
||||
TokenLogprob → struct { token, bytes, logprob, top_logprobs }
|
||||
TopLogprob → struct { token, bytes, logprob }
|
||||
Annotation → struct { type, url_citation }
|
||||
URLCitation → struct { end_index, start_index, title, url }
|
||||
OpenaiAudio → struct { id, data, expires_at, transcript }
|
||||
FunctionCall → struct { name, arguments }
|
||||
OpenaiToolCall → enum { Function { id, function, type }, Custom { id, custom, type } }
|
||||
```
|
||||
|
||||
### `types/message.rs` — 消息类型
|
||||
```
|
||||
OpenaiChatMessage → enum (覆盖 6 种角色消息)
|
||||
DeveloperMessage → struct { content, role, name }
|
||||
SystemMessage → struct { content, role, name }
|
||||
UserMessage → struct { content, role, name }
|
||||
AssistantMessage → struct { content, refusal, role, name, tool_calls, function_call, audio }
|
||||
ToolMessage → struct { content, role, tool_call_id }
|
||||
FunctionMessage → struct { content, role, name }
|
||||
OpenaiContentPart → enum
|
||||
OpenaiContentPartText → struct { type, text }
|
||||
OpenaiContentPartImage → struct { type, image_url: ImageURL }
|
||||
OpenaiContentPartInputAudio → struct { type, input_audio: InputAudio }
|
||||
OpenaiContentPartFile → struct { type, file: FileData }
|
||||
OpenaiContentPartRefusal → struct { type, refusal }
|
||||
ImageURL → struct { url, detail }
|
||||
InputAudio → struct { data, format }
|
||||
FileData → struct { file_data, file_id, filename }
|
||||
```
|
||||
|
||||
### `types/tool.rs` — 工具类型
|
||||
```
|
||||
OpenaiToolDefinition → struct { name, description, parameters, strict }
|
||||
(保留 ToolDefinition 别名保持后向兼容,重定义为包含所有字段)
|
||||
OpenaiToolCall (在请求中使用) → 见 response.rs 中的定义
|
||||
```
|
||||
|
||||
### `types/usage.rs` — Token 用量
|
||||
```
|
||||
Usage → struct { prompt_tokens, completion_tokens, total_tokens,
|
||||
completion_tokens_details, prompt_tokens_details }
|
||||
CompletionTokensDetails → struct { reasoning_tokens, audio_tokens,
|
||||
accepted_prediction_tokens, rejected_prediction_tokens }
|
||||
PromptTokensDetails → struct { audio_tokens, cached_tokens }
|
||||
CostTracker → 从 cycle/usage.rs 移入(累计追踪器)
|
||||
```
|
||||
|
||||
### 删除的旧类型
|
||||
- `ContentBlock` → 被 `OpenaiContentPart` 替代(更准确的 OpenAI API 命名)
|
||||
- `StopReason` → 被 `FinishReason` 替代(与 API 命名一致)
|
||||
- `Message` → 被 `OpenaiChatMessage` 替代
|
||||
|
||||
### 类型别名(后向兼容)
|
||||
```
|
||||
ChatRequest = OpenaiChatRequest
|
||||
ChatResponse = OpenaiChatResponse
|
||||
Message = OpenaiChatMessage
|
||||
ContentBlock = OpenaiContentPart
|
||||
ToolDefinition = OpenaiToolDefinition
|
||||
Role = Role(保持不变,但扩展变体)
|
||||
StopReason = FinishReason
|
||||
```
|
||||
|
||||
## 4. 对其他模块的影响
|
||||
|
||||
### `provider/openai.rs`
|
||||
- **大幅简化**:`build_request_body()` → 直接 `serde_json::to_value(&request)`
|
||||
- `parse_response()` 中 100+ 行手动解析 → 直接 `serde_json::from_value::<OpenaiChatResponse>()`
|
||||
- `serialize_messages()`, `serialize_message()`, `serialize_content_block()`, `serialize_tool()` → **全部删除**
|
||||
- 新增 `chat_stream()` 方法返回 `OpenaiChatChunk` 流
|
||||
- 需要适配新类型的字段名变更(如 `Usage` 中 `input_tokens` → `prompt_tokens`)
|
||||
|
||||
### `provider.rs` (trait)
|
||||
- 接口保持不变,继续使用 `ChatRequest`/`ChatResponse` 类型别名
|
||||
- 调整 `Usage` 类型引用路径
|
||||
|
||||
### `cycle.rs`
|
||||
- `CycleConfig` 扩展支持更多请求参数(至少增加 `tools, tool_choice, response_format, stop, reasoning_effort, seed` 等)
|
||||
- `LlmCycle::submit()` 构建 `ChatRequest` 时使用新类型
|
||||
- `response.usage` 字段类型变更(新 `Usage` 含更多字段)
|
||||
- 此时不添加流式支持
|
||||
|
||||
### `cycle/usage.rs`
|
||||
- `Usage` 结构体**被移走**到 `types/usage.rs`
|
||||
- `cycle/usage.rs` 保留 `pub use crate::llm::types::usage::{Usage, CostTracker};` 作为兼容性 re-export
|
||||
- `CostTracker` 逻辑不变
|
||||
|
||||
### `error.rs`
|
||||
- 无明显变更,错误类型和映射逻辑不变
|
||||
|
||||
## 5. 实施步骤
|
||||
|
||||
### Phase 1: 基础设施
|
||||
```
|
||||
1. [准备] 在 Cargo.toml 中确认 serde 依赖(已有 serde = "1",features = ["derive"])
|
||||
2. [创建] 新建 src/llm/types/ 目录
|
||||
```
|
||||
|
||||
### Phase 2: 类型定义(按依赖顺序)
|
||||
```
|
||||
3. [usage.rs] 从 cycle/usage.rs 迁移 Usage + CostTracker
|
||||
4. [shared.rs] 定义 Role, FinishReason, ServiceTier, Modality, ImageDetail, StopSequence, ResponseFormat
|
||||
5. [message.rs] 定义 OpenaiChatMessage(6种角色)+ OpenaiContentPart + ImageURL + InputAudio
|
||||
6. [tool.rs] 定义 OpenaiToolDefinition + OpenaiToolCall + FunctionCall
|
||||
7. [request.rs] 定义 OpenaiChatRequest(35+ 字段)+ ToolChoice + StreamOptions
|
||||
8. [response.rs] 定义 OpenaiChatResponse + OpenaiChatChunk + Choice + Delta + Logprobs
|
||||
```
|
||||
|
||||
### Phase 3: 模块组装
|
||||
```
|
||||
9. [mod.rs] 创建模块根,re-export 所有类型 + 别名(ChatRequest = OpenaiChatRequest 等)
|
||||
10. [usage.rs] 更新 cycle/usage.rs 为 pub use re-export
|
||||
11. [删除] 删除旧 src/llm/types.rs
|
||||
```
|
||||
|
||||
### Phase 4: Provider 适配
|
||||
```
|
||||
12. [provider/openai.rs] 重写为 serde 序列化(删除 ~200 行手动代码)
|
||||
13. [cycle.rs] 适配新类型字段(prompt_tokens vs input_tokens)
|
||||
```
|
||||
|
||||
### Phase 5: 验证
|
||||
```
|
||||
14. [编译] cargo check 确保编译通过
|
||||
15. [检查] cargo clippy 确保无警告
|
||||
16. [测试] cargo test 确保测试通过
|
||||
```
|
||||
|
||||
## 6. 验证方式
|
||||
|
||||
- `cargo check` — 编译通过
|
||||
- `cargo clippy` — 无警告
|
||||
- `cargo test` — 所有测试通过(如果有集成测试,可能需要调整)
|
||||
- 检查 `OpenaiProvider` 代码量减少(预期从 354 行降至 ~150 行)
|
||||
- 手动验证序列化输出是否符合 OpenAI API 格式
|
||||
|
||||
## 7. 注意事项
|
||||
|
||||
1. **Break change**: 某些类型名称变化(如 `StopReason` → `FinishReason`),项目处于早期阶段,可接受
|
||||
2. **后向兼容**: 通过类型别名保持旧名称可用,接口层无需修改
|
||||
3. **Anthropic 处理**: Anthropic 是独立体系,不在当前设计中泛化,单独实现 Provider
|
||||
4. **异步流**: `chat_stream()` 的签名需要仔细设计(`Pin<Box<dyn Stream<Item = Result<OpenaiChatChunk, LlmError>>>>` 或自定义类型)
|
||||
5. **CostTracker 不变**: 虽然 Usage 变复杂了,但 CostTracker 只累计 input/output token 数,逻辑不变
|
||||
@@ -0,0 +1,515 @@
|
||||
# LLM Provider 重构改进方案(最终确认)
|
||||
|
||||
> 本文档记录 2026-06-25 设计评审后确认的方案决策,是对 9 系文档(`9-llm-provider-unified-interface.md` 及 `9a`-`9g` 子文档)中已有设计的**精炼与修订**。
|
||||
>
|
||||
> **阅读前提**:本文档假设读者已熟悉现有 9 系文档中的背景、架构总览和类型体系概念。
|
||||
>
|
||||
> **与 9 系的关系**:
|
||||
> - 9 系文档中的 `ContentBlock`、`MessageRequest`、`MessageResponse`、`StopReason`、`ThinkingConfig`、`ToolDefinition`、`PartialUsage` 等核心类型定义**继续有效**,本文档不再重复
|
||||
> - `ProviderCapabilities`、`LlmProvider trait` 签名、`PartialMessageResponse` 汇聚算法等 design intent **继续有效**(trait 签名由 9c 定义,切换时点由本文档 §4 Phase 0 执行)
|
||||
> - 本文档仅记录**本次确认的修订内容和执行计划**
|
||||
|
||||
---
|
||||
|
||||
## 修订记录
|
||||
|
||||
| 日期 | 版本 | 修订摘要 |
|
||||
|------|------|---------|
|
||||
| 2026-06-25 | v1 | 初版,记录 5 项设计决策 |
|
||||
| 2026-06-26 | v2 | 初审修订:修正 §1 表描述(Assistant → UserImage);消除 §2.3 MessageComplete 冗余字段;明确 Phase 0 add-only 策略;补充 9e 依赖审查说明;补充 HTTP mock 策略;修正 §5 兼容性验证标准;增加 §7 开放事项 |
|
||||
| 2026-06-26 | v3 | 复审修订:Phase 0 改为"add + trait 签名切换"消除结构性缺口;补充 OpenAI Response API 范围和 OpenAI-compatible 复用策略说明;调整 Phase 2 范围(聚焦逻辑简化) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 修订摘要
|
||||
|
||||
| 设计维度 | 9 系文档 | 本次修订 | 修订原因 |
|
||||
|----------|---------|---------|---------|
|
||||
| Message 模型 | 结构化层次(`System/User/Assistant/Tool`,每项含 `content: Vec<ContentBlock>`) | **扁平大枚举**(User 拆出 `UserImage` 独立变体,Assistant 保持整体,ToolUse 仍在 content 中) | 编译器能检查约束,`UserImage` 消费方 match 可直接区分文本和图片输入,无需检查 Vec 内容 |
|
||||
| StreamEvent 终端事件 | `MessageComplete { stop_reason, thinking_signature }` | **精简为 `MessageComplete { full_response: MessageResponse }`**,移除冗余顶层字段 | 消除冗余和消费方疑惑,唯一信源 |
|
||||
| Provider 发现 | 未明确 | **Enum-based**(`ProviderType` enum + exhaustive match),不做动态注册 | 当前协议数量可控,编译期安全,无运行时查表开销 |
|
||||
| 项目阶段 | 9 系是"推演中" | **可直接执行**,无历史包袱,一步到位 | 项目尚未 release,没有 breaking change 顾虑 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 本次修订的 5 项设计决策
|
||||
|
||||
### 2.1 Decision-01:Message 采用扁平大枚举
|
||||
|
||||
#### 定义
|
||||
|
||||
```rust
|
||||
/// 跨 Provider 统一的消息类型(扁平大枚举)。
|
||||
///
|
||||
/// 设计原则:每个变体直接承载完整语义,
|
||||
/// 消费方 match 即可获得所有信息,无需在嵌套的 Vec 中搜索。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
/// 系统提示(User & Assistant 之外的引导指令)
|
||||
System {
|
||||
content: Vec<ContentBlock>,
|
||||
},
|
||||
/// 用户输入
|
||||
User {
|
||||
content: Vec<ContentBlock>,
|
||||
},
|
||||
/// 用户的图片输入(快捷构造,免去构造 ContentBlock 的 boilerplate)
|
||||
UserImage {
|
||||
data: String,
|
||||
mime_type: String,
|
||||
detail: ImageDetail,
|
||||
},
|
||||
/// Assistant 回复内容块(可能包含 text、thinking、tool_use 等多种 block 的混合)
|
||||
///
|
||||
/// 注意:Assistant 的一次回复可以同时包含文本、思考过程、工具调用。
|
||||
/// 扁平大枚举并未将 ToolUse 提升为独立变体,而是保留在 content 中,
|
||||
/// 因为在一次 Assistant turn 中 text 和 tool_use 的**顺序关系**是有意义的。
|
||||
/// (例如:先输出推理过程,再调用工具)
|
||||
Assistant {
|
||||
content: Vec<ContentBlock>,
|
||||
},
|
||||
/// 工具调用结果
|
||||
ToolResult {
|
||||
tool_call_id: String,
|
||||
content: Vec<ContentBlock>,
|
||||
is_error: bool,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### 与 9 系结构化层次的差异
|
||||
|
||||
| 维度 | 9 系(结构化层次) | 本次(扁平大枚举) |
|
||||
|------|-------------------|-------------------|
|
||||
| Assistant 消息结构 | `Assistant { content: Vec<ContentBlock> }`,ToolUse 在 content 中 | 同上,保持 ToolUse 在 content 中 |
|
||||
| "独立 Assistant 消息"的含义 | 一次 LLM 响应 = 一个 `Assistant { content: [...] }` | 同上 |
|
||||
| Thinking / ToolCall 作为独立变体 | ❌ 无独立变体 | **Thinking、ToolCall 不作为独立 Message 变体**,仍在 `Assistant.content` 中 |
|
||||
| UserImage 独立变体 | `User { content: [Image{...}] }` | `UserImage { data, mime, detail }` |
|
||||
| 为什么不把 ToolUse 提到 Message 层 | — | 因为 text ↔ tool_use 的**交错顺序**是 Assistant 响应的语义组成部分,拆散后会丢失顺序信息 |
|
||||
| 实际的参与方差异 | `User` + `UserImage` 合并为同一变体 | `User` 和 `UserImage` **拆开**,方便消费方 match(无需检查 Vec 内容来区分文字和图片) |
|
||||
|
||||
> **与 9b 文档的关系**:9b 的 `Message::System`、`Message::User`、`Message::Assistant`、`Message::Tool` 四个变体分类保留,
|
||||
> 但 `User` 的图片输入场景通过新增 `UserImage` 变体提供便捷路径,减少 boilerplate。
|
||||
> `Message::Assistant` 的 `content: Vec<ContentBlock>` 保持不变——ToolUse 仍在 content 中。
|
||||
|
||||
#### 便捷构造函数
|
||||
|
||||
```rust
|
||||
impl Message {
|
||||
pub fn user_text(text: impl Into<String>) -> Self;
|
||||
pub fn user_image(data: impl Into<String>, mime_type: impl Into<String>, detail: ImageDetail) -> Self;
|
||||
pub fn assistant(text: impl Into<String>) -> Self;
|
||||
pub fn system(text: impl Into<String>) -> Self;
|
||||
pub fn tool_result(tool_call_id: impl Into<String>, text: impl Into<String>, is_error: bool) -> Self;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Decision-02:LlmProvider 感知消息类型
|
||||
|
||||
沿用 9c 文档中的 trait 设计,无修订。
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError>;
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>;
|
||||
|
||||
fn capabilities(&self) -> ProviderCapabilities;
|
||||
}
|
||||
```
|
||||
|
||||
每个 Provider 实现内部自行处理 `MessageRequest` ↔ 原生协议格式的映射。无外部转换层。
|
||||
|
||||
### 2.3 Decision-03:StreamEvent 高精度 + 终端事件携带完整响应
|
||||
|
||||
沿用 9c 文档中定义的 `StreamEvent`,但**在终端事件中增加完整响应快照**。
|
||||
|
||||
#### 修订后的 MessageComplete 事件
|
||||
|
||||
```rust
|
||||
pub enum StreamEvent {
|
||||
// ── Meta ──
|
||||
MessageStart { id: String, model: String },
|
||||
|
||||
// ── Content Block 边界 ──
|
||||
ContentBlockStart { index: u32, block_type: ContentBlockType },
|
||||
ContentBlockEnd { index: u32 },
|
||||
|
||||
// ── 块内增量 ──
|
||||
TextDelta { text: String },
|
||||
ThinkingDelta { text: String },
|
||||
RefusalDelta { text: String },
|
||||
ToolCallArgumentsDelta { index: u32, arguments: String },
|
||||
ToolCallEnd { index: u32 },
|
||||
|
||||
// ── 汇总 ──
|
||||
CostUpdate { usage: PartialUsage },
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 修订:MessageComplete 携带完整响应快照(移除冗余的 stop_reason / thinking_signature)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
/// 消息完成 —— 唯一可靠的完整响应来源。
|
||||
///
|
||||
/// `full_response` 携带完整的 MessageResponse(含已拼接完毕的 content / usage / stop_reason),
|
||||
/// 消费方**无需自行累积 delta**,直接使用此快照继续后续流程。
|
||||
///
|
||||
/// 设计说明:
|
||||
/// - 9c 原有设计在 `MessageComplete` 中同时携带 `stop_reason` 和 `thinking_signature` 顶层字段,
|
||||
/// 但这些信息已包含在 `full_response` 中,造成冗余和消费方的疑惑(到底读顶层字段还是 full_response)。
|
||||
/// - 本次修订全部移除顶层冗余字段,`full_response` 是唯一信源。
|
||||
/// - Anthropic 的 thinking signature(message_delta 中下发,晚于 content_block_stop)由 Provider
|
||||
/// 的流处理循环直接调用 `PartialMessageResponse::set_thinking_signature()` 写入内部状态,
|
||||
/// 再通过 `finalize()` 回填到 Thinking block 中,最终出现在 `full_response` 的 content 里。
|
||||
/// 消费方不需要感知 signature 的存在。
|
||||
MessageComplete {
|
||||
/// 完整的响应快照。
|
||||
///
|
||||
/// 与 `PartialMessageResponse` 内部累积的状态**最终一致**,
|
||||
/// 提供此快照是为了让消费方(如 LlmCycle)在流结束后可以直接拿到
|
||||
/// 完整的 MessageResponse,无需自己实现汇聚算法。
|
||||
full_response: MessageResponse,
|
||||
},
|
||||
|
||||
// ── 错误 ──
|
||||
Error { message: String },
|
||||
}
|
||||
```
|
||||
|
||||
#### 设计理由
|
||||
|
||||
1. **简化消费方**:`LlmCycle::submit_stream()` 目前需要在 `while let` 循环中逐个处理 delta 并维护一个会话状态来判断"响应是否完整"。有了 `full_response`,`LlmCycle` 或 `AgentSession` 只需要监听 `MessageComplete` 事件,拿到快照后直接继续 tool 循环或返回给调用方。
|
||||
2. **与 PartialMessageResponse 保持一致**:`PartialMessageResponse::finalize()` 产生的 `MessageResponse` 就是 `full_response` 的值。Provider 内部的汇聚逻辑不变,只是在发出 `MessageComplete` 时多传一个已完成构建的最终结果。
|
||||
3. **零额外开销**:`MessageResponse` 在 Provider 内部已经构造好了(作为汇聚算法的最终产物),只是多 clone/arc 一次给事件携带。
|
||||
4. **消除冗余**:9c 原有设计同时保留了顶层 `stop_reason`、`thinking_signature` 和 `full_response` 中的相同信息,造成消费方疑惑。本次修订只保留 `full_response` 为唯一信源。
|
||||
|
||||
#### 对 PartialMessageResponse 的影响
|
||||
|
||||
```rust
|
||||
// finalize 在原有逻辑末尾增加一步:
|
||||
// 将 finalize 的结果提前缓存,由 MessageComplete 事件携带
|
||||
impl PartialMessageResponse {
|
||||
pub fn finalize(mut self) -> Result<MessageResponse, LlmError> {
|
||||
// ... 原有代码(按 index 升序遍历 blocks) ...
|
||||
let response = MessageResponse { ... };
|
||||
|
||||
// 新增:self. 中缓存 finalize 结果
|
||||
// (实际由 Provider 的流处理循环在发出 MessageComplete 前调用
|
||||
// finalize 并填充到事件中)
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Provider 的流处理循环 - `thinking_signature` 不再经过事件层,由 Provider 直接写入 `PartialMessageResponse` 内部状态:
|
||||
|
||||
```rust
|
||||
// 伪代码:Provider 流处理循环(以 Anthropic 为例)
|
||||
let mut partial = PartialMessageResponse::new();
|
||||
|
||||
while let Some(event) = anthropic_stream.next().await {
|
||||
match event {
|
||||
// Anthropic 的 message_delta 携带 thinking.signature
|
||||
// → Provider 直接写入 PartialMessageResponse 内部状态
|
||||
AnthropicEvent::MessageDelta { delta, usage } => {
|
||||
if let Some(thinking) = &delta.thinking {
|
||||
if let Some(sig) = &thinking.signature {
|
||||
partial.set_thinking_signature(sig.clone());
|
||||
}
|
||||
}
|
||||
yield StreamEvent::CostUpdate { usage: map_usage(usage) };
|
||||
}
|
||||
// 其他 Anthropic 事件 → 映射为 StreamEvent 并 apply_to
|
||||
other => {
|
||||
let ir_event = map_to_ir_event(other);
|
||||
ir_event.apply_to(&mut partial);
|
||||
}
|
||||
// message_stop → 调用 finalize 并发出完成事件
|
||||
AnthropicEvent::MessageStop => {
|
||||
let full = partial.finalize()?;
|
||||
yield StreamEvent::MessageComplete { full_response: full };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **变更追溯**:9c 的原有设计中,`MessageComplete` 事件携带顶层 `stop_reason` 和 `thinking_signature` 字段,
|
||||
> 供 `apply_to()` 设置 `PartialMessageResponse` 的内部状态。本次修订移除这些冗余字段后,
|
||||
> `thinking_signature` 改为由 Provider 直接调用 `partial.set_thinking_signature()` 写入内部状态,
|
||||
> `stop_reason` 则在 `finalize()` 中统一定于 `full_response.stop_reason`。
|
||||
|
||||
### 2.4 Decision-04:LlmCycle 简化
|
||||
|
||||
沿用 9e 文档的改造方向,核心变化是内部消息类型从 `Vec<OpenaiChatMessage>` 改为 `Vec<Message>`。
|
||||
|
||||
> **⚠️ 依赖验证**:9e 文档写于结构化层次设计阶段(`Message::System` / `User` / `Assistant` / `Tool`),
|
||||
> 其中的代码片段(如 `build_request()` 中 match System 消息的分支、插入 System prompt 的判断逻辑)
|
||||
> 基于旧 Message 定义。扁平大枚举后——
|
||||
> - `User` 拆出 `UserImage` → match 分支需增加 `UserImage` 的处理
|
||||
> - `Message::Tool` 更名为 `Message::ToolResult` → 所有引用需改名
|
||||
> - 其余 match 分支(`System`、`User`、`Assistant`)的基本逻辑不变
|
||||
>
|
||||
> **实施 Phase 2 时**:从 9e 中摘取实现思路,代码手动编写,不直接复制 9e 中的代码片段。
|
||||
> 修改 9e 文档中过时的代码片段不在本方案范围内,Phase 2 实施时自然淘汰。
|
||||
|
||||
关键变化要点(9e 已有详述):
|
||||
|
||||
| 当前 | 改进后 |
|
||||
|------|--------|
|
||||
| `messages: Vec<OpenaiChatMessage>` | `messages: Vec<Message>` |
|
||||
| `build_request()` 中手动拼接 system prompt | system prompt 通过 `Message::System` 在 messages 中表达,Provider 映射层自行处理差异 |
|
||||
| `submit_stream()` 中自建 delta 聚合逻辑 | 监听 `MessageComplete.full_response`,直接拿到完整响应 |
|
||||
| tool 循环需自行解析 `ChatResponse` 中的 tool_calls | 从 `MessageResponse.message`(Assistant 变体)的 content 中提取 ContentBlock::ToolUse |
|
||||
|
||||
### 2.5 Decision-05:Provider 发现使用 Enum
|
||||
|
||||
不使用动态注册表,保留当前 `ProviderType` enum 模式,但扩展其覆盖范围。
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderType {
|
||||
OpenaiChat,
|
||||
OpenaiResponse,
|
||||
Anthropic,
|
||||
DeepSeek,
|
||||
Qwen,
|
||||
}
|
||||
```
|
||||
|
||||
工厂函数 `create_provider()` 做 exhaustive match:
|
||||
|
||||
```rust
|
||||
pub fn create_provider(
|
||||
provider_type: ProviderType,
|
||||
config: ProviderConfig,
|
||||
) -> Result<Box<dyn LlmProvider>, LlmError> {
|
||||
match provider_type {
|
||||
ProviderType::OpenaiChat => Ok(Box::new(providers::OpenaiChatProvider::new(...))),
|
||||
ProviderType::OpenaiResponse => Ok(Box::new(providers::OpenaiResponseProvider::new(...))),
|
||||
ProviderType::Anthropic => Ok(Box::new(providers::AnthropicProvider::new(...))),
|
||||
ProviderType::DeepSeek => Ok(Box::new(providers::DeepSeekProvider::new(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
))),
|
||||
ProviderType::Qwen => Ok(Box::new(providers::QwenProvider::new(...))),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
新增 Provider 时,编译器通过 exhaustiveness check 强制要求 `match` 更新。
|
||||
|
||||
> **理由**:当前目标协议数量(4-5 种)完全可控,enum 的编译期安全检查优于运行时的 `HashMap::get()`。
|
||||
> 未来如果扩展到 15+ 种以上,再改为注册表模式。
|
||||
|
||||
---
|
||||
|
||||
## 3. 对 9 系文档的更新映射
|
||||
|
||||
| 9 系文档 | 变更类型 | 操作 |
|
||||
|---------|---------|------|
|
||||
| `9b-ir-type-system.md` §3.2 Message | 修订 | `UserImage` 变体新增;其余部分继续有效 |
|
||||
| `9c-llm-provider-trait.md` §4.1 LlmProvider trait | 切换时点修订 | trait 签名切换由"推迟到 Phase 2"改为 Phase 0 内完成。trait 定义本身不变。 |
|
||||
| `9c-llm-provider-trait.md` §4.3 StreamEvent | 修订 | `MessageComplete` 增加 `full_response: MessageResponse` 字段 |
|
||||
| `9c-llm-provider-trait.md` §4.4 PartialMessageResponse | 追加 | `finalize()` 返回结果需在 Provider 发出 `MessageComplete` 前已可用 |
|
||||
| `9d-provider-implementations.md` | 继续有效 | 实现策略不变 |
|
||||
| `9e-llm-cycle-and-upstream.md` | 需重新审查 | 方向不变,但其中的 match 分支和 System prompt 插入逻辑基于旧 Message 定义。Phase 2 实施时参考思路而非照搬代码(见 §2.4 ⚠️ 依赖验证) |
|
||||
| `9f-edge-cases.md` | 继续有效 | 边界情况处理不变 |
|
||||
| `9g-risk-and-migration.md` | 继续有效 | 风险评估不变 |
|
||||
| 本文档 `10-...` | **新增** | 记录最终决策和修订 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 实施步骤
|
||||
|
||||
### Phase 0:类型层落地 + trait 签名切换
|
||||
|
||||
**目标**:新增新的类型系统 + 切换 `LlmProvider` trait 签名,使全链路使用新类型。Phase 0 结束时 `cargo test` 全部通过。
|
||||
|
||||
**原则**:
|
||||
- 新类型定义放入**新文件**(`message.rs`、`request_v2.rs`、`response_v2.rs`),不堆积到已有类型文件
|
||||
- 已有的 `request.rs`(`OpenaiChatRequest`)、`response.rs`(`OpenaiChatResponse`)**保留原样**,后续 Provider 实现可能作为内部转换目标继续引用
|
||||
- `LlmProvider` trait 签名由 `chat(ChatRequest) → ChatResponse` 切换为 `chat(MessageRequest) → MessageResponse`,**在同一个 Phase 内完成**(见下方任务 6‒8)
|
||||
- trait 签名变更导致的编译错误(`StubProvider`、`LlmCycle` 调用点)**在 Phase 0 内全部修复**,不留到 Phase 1
|
||||
- `AgentSession` 等上游中对 `LlmCycle.submit()` 返回值的引用同步适配
|
||||
|
||||
**`StreamEvent` 命名冲突处理**:
|
||||
新 `StreamEvent`(高精度版)定义在 `src/llm/types/response_v2.rs` 中。
|
||||
旧 `StreamEvent`(`src/llm/stream.rs` 中定义)的变体(`AssistantTextDelta`、`ToolExecutionStarted`、`TurnComplete` 等)与新类型冲突。
|
||||
处理方式(任务 9 执行):
|
||||
|
||||
1. `response_v2.rs` 中的新 `StreamEvent` 是唯一的 `StreamEvent` 定义
|
||||
2. `src/llm/stream.rs` 中的旧 `StreamEvent` 枚举**替换为**重新导出语句:`pub use super::types::response_v2::StreamEvent;`
|
||||
3. 旧 `StreamEvent` 的变体(`AssistantTextDelta`、`ToolExecutionStarted`、`TurnComplete`)**暂时保留**为一个独立的枚举(命名为 `LegacyStreamEvent`)放在 `src/llm/types/old_stream.rs` 新文件中,供 `stream.rs` 中的 `parse_chunk_stream()` 内部使用
|
||||
4. 这样 `stream.rs` 的辅助函数继续编译,`LlmCycle` 和 `AgentSession` 看到的是新 `StreamEvent`
|
||||
|
||||
**涉及文件**:
|
||||
|
||||
| 类型 | 文件 | 操作 |
|
||||
|------|------|------|
|
||||
| 新增 | `src/llm/types/message.rs` | 新文件 |
|
||||
| 新增 | `src/llm/types/request_v2.rs` | 新文件 |
|
||||
| 新增 | `src/llm/types/response_v2.rs` | 新文件 |
|
||||
| 新增 | `src/llm/types/old_stream.rs` | 新文件(从 `stream.rs` 迁移旧 `StreamEvent` 变体) |
|
||||
| 追加 | `src/llm/types/mod.rs` | 追加 `pub mod` 声明 |
|
||||
| 修改 | `src/llm/provider.rs` | 改 `LlmProvider` trait 签名 |
|
||||
| 修改 | `src/agent/builder.rs` | 更新 `StubProvider` 实现 |
|
||||
| 修改 | `src/llm/stream.rs` | 将旧 `StreamEvent` 枚举替换为对 `response_v2::StreamEvent` 的重新导出 |
|
||||
| 修改 | `src/llm/provider/openai.rs` | 修改:添加临时桥接实现(`MessageRequest → ChatRequest` 转换 + `ChatResponse → MessageResponse` 转换),Phase 1 重写时移除 |
|
||||
| 修改 | `src/llm/hooks.rs` | 更新 `HookContext.request` 类型为 `&'a MessageRequest` |
|
||||
| 修改 | `src/llm/cycle.rs` | 更新调用点(`build_request`、`submit`、`submit_stream`、`submit_messages`、`submit_request` 的类型引用和返回值) |
|
||||
| 修改 | `src/llm/cycle/retry.rs` | 如有对新 `LlmError` 类型的引用,同步适配 |
|
||||
| 修改 | `src/agent/error.rs`、`src/agent/runtime.rs`、`src/agent/session.rs` 等 | 如有对 `LlmCycle` 返回值或 `ChatResponse` 的引用,同步适配(具体文件由编译错误定位) |
|
||||
|
||||
**具体任务**:
|
||||
1. 新增 `src/llm/types/message.rs`,定义 `Message` 扁平大枚举 + `ContentBlock` + `ContentBlockType`
|
||||
2. 将 9b 中的 `ContentBlock` 变体(`Text`, `Image`, `Audio`, `File`, `ToolUse`, `ToolResult`, `Thinking`, `Extension`)及其辅助类型(`ImageSource`、`AudioSource`、`FileSource`)定义到 `message.rs` 中
|
||||
3. 新增 `src/llm/types/request_v2.rs`,定义 `MessageRequest`(从 9b 移植)+ `ExtraError` + extra 访问方法(`get_extra`、`get_extra_opt`、`get_extra_as`、`set_extra`)
|
||||
4. 新增 `src/llm/types/response_v2.rs`,定义 `MessageResponse` + `StreamEvent`(高精度版,`MessageComplete` 只含 `full_response: MessageResponse`)+ `PartialUsage` + `PartialMessageResponse` + `apply_to` + `finalize`
|
||||
5. 新类型侧单元测试:构造、序列化/反序列化(JSON roundtrip)、match 穷举性验证、`PartialMessageResponse.apply_to + finalize` 汇聚一致性测试
|
||||
6. 修改 `src/llm/provider.rs`:`LlmProvider` trait 签名改为 `chat(MessageRequest) → Result<MessageResponse, LlmError>`、`chat_stream(MessageRequest) → Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>`
|
||||
7. 修改 `src/agent/builder.rs`:更新 `StubProvider` 实现以匹配新 trait 签名
|
||||
8. 修改 `src/llm/cycle.rs`:
|
||||
- `build_request()`:将已有的 `Vec<OpenaiChatMessage>` 转换为 `Vec<Message>`(通过 `chat_message → message` 映射函数),构造 `MessageRequest`
|
||||
- `submit()` / `submit_messages()`:返回 `Result<MessageResponse, LlmError>`
|
||||
- `submit_stream()`:返回 `Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError>`
|
||||
- 流处理循环:由消费 `OpenaiChatChunk` 改为消费 `StreamEvent`。流结束处的 `full_response` 暂不使用(Phase 2 才启用简化逻辑),先提取 `stop_reason` 和 `message` 构建传统返回
|
||||
9. 新增文件 `src/llm/types/old_stream.rs`(从 `src/llm/stream.rs` 迁移旧 `StreamEvent` 定义),同时将 `src/llm/stream.rs` 中的旧 `StreamEvent` 枚举替换为对 `response_v2.rs` 中新 `StreamEvent` 的重新导出(`pub use super::types::response_v2::StreamEvent;`),确保 `stream.rs` 的 `parse_chunk_stream()` 和 `ChunkToEventStream` 继续编译通过
|
||||
10. 修改 `src/llm/provider/openai.rs`:添加 `LlmProvider` trait 临时桥接实现——
|
||||
- `chat()`:`MessageRequest → ChatRequest`(利用现有 `OpenaiChatMessage` 转换)→ 调用已有 `chat_inner()` → `ChatResponse → MessageResponse`(使用 `finalize()` 算法或直接映射)
|
||||
- `chat_stream()`:`MessageRequest → ChatRequest` → 调用已有 `chat_stream_inner()` → 将 `OpenaiChatChunk` 流映射为 `StreamEvent` 流(利用已有的 `parse_chunk_stream`)
|
||||
- 桥接实现标记 `// ponytail: Phase 0 临时桥接,Phase 1 重写时移除`
|
||||
11. 测试适配(编译驱动,涉及文件不限于以下列表,由编译器报错定位):
|
||||
- `src/llm/cycle.rs` 测试模块(`MockProvider`、`assistant_text_response()`、`assistant_tool_call_response()`、各测试用例中的断言类型)
|
||||
- `src/agent/session.rs` 测试模块(`MockProvider`、响应构造 helper 等)
|
||||
- `src/agent/builder.rs` 测试模块(`StubProvider` 已单独由任务 7 处理)
|
||||
- `src/agent/session_memory.rs`、`src/agent/runtime.rs` 等
|
||||
12. 编译驱动适配:对上游(`agent/session.rs`、`agent/runtime.rs`、`agent/error.rs` 等)中引用旧类型的地方,逐一按编译错误修复
|
||||
|
||||
**验证**:`cargo test` 全部通过。`git diff` 确认新增和修改文件范围符合预期。确认 `OpenaiProvider` 的临时桥接代码带有 `// ponytail: Phase 0 临时桥接` 注释,Phase 1 移除时易于定位。
|
||||
|
||||
### Phase 1:Provider 适配
|
||||
|
||||
> **前置条件**:Phase 0 已完成,`LlmProvider` trait 签名已切换为 `chat(MessageRequest) → MessageResponse`。本 Phase 直接实现新 Provider,无需再处理 trait 兼容性。
|
||||
|
||||
**目标**:重写 `OpenaiProvider`(使用新类型),新增 `AnthropicProvider`。DeepSeek/Qwen 作为 OpenAI-compatible 协议实现一并纳入。
|
||||
|
||||
**涉及文件**:
|
||||
- `src/llm/provider.rs` — 修改 `create_provider` 工厂函数,匹配新的 `ProviderType` enum
|
||||
- `src/llm/provider/registry.rs` — 适配新 `LlmProvider` trait(改动极小,只是类型变化)
|
||||
- `src/llm/provider/openai.rs` — 重写:内部实现 `MessageRequest ↔ OpenaiChatRequest` 转换
|
||||
- `src/llm/provider/anthropic.rs` — 新文件:`MessageRequest ↔ Anthropic Messages API` 映射
|
||||
- `src/llm/provider/deepseek.rs` — 新文件(与 `OpenaiChatProvider` 共享 `/chat/completions` 协议)
|
||||
- `src/llm/provider/qwen.rs` — 新文件(同上)
|
||||
|
||||
**具体任务**:
|
||||
1. `OpenaiProvider` 内部 `chat()`:`MessageRequest` → `OpenaiChatRequest`(serde 序列化)→ HTTP POST → 解析 `OpenaiChatResponse` → `MessageResponse`(通过 `finalize()` 算法或直接映射)
|
||||
2. `OpenaiProvider` 内部 `chat_stream()`:同样的转换路径,但响应解析改为 SSE 流式 → 逐 chunk 输出 `StreamEvent`
|
||||
3. `AnthropicProvider`:实现 Anthropic Messages API 的请求/响应映射,包括:
|
||||
- Messages API 请求体构建(`system` 参数 + `messages[]` + `tools` 等)
|
||||
- SSE 流解析(`message_start`, `content_block_start`, `content_block_delta`, `content_block_stop`, `message_delta`, `message_stop`, `ping`)
|
||||
- 将 Anthropic SSE 事件映射为 IR `StreamEvent`
|
||||
4. `DeepSeekProvider` / `QwenProvider`(OpenAI-compatible):
|
||||
- 共享 `OpenaiChatProvider` 的 `/chat/completions` 协议
|
||||
- **代码复用策略实施时决定**(推荐:`OpenaiChatProvider` 参数化为 `GenericOpenaiProvider { base_url, api_key, model, provider_name }`,DeepSeek/Qwen 共用同一实现,仅配置不同;备选:trait 组合提取 HTTP 请求逻辑为可复用组件)
|
||||
- 差异化处理:`max_tokens` 字段名(部分兼容端点使用 `max_tokens` 而非 `max_completion_tokens`)、错误格式(非标准 error body 解析)
|
||||
5. `ProviderRegistry` 的 `register_with_config()` 和 `create_provider()` 适配新 enum
|
||||
6. **OpenAI Response API(`ProviderType::OpenaiResponse`)实现范围说明**:本 Phase 的 `OpenaiResponseProvider` 只覆盖核心对话能力(models response 创建、流式)、工具调用。内置工具(`web_search`、`file_search`)、`previous_response_id` 续写、`store` 等 Response API 独有特性通过 `MessageRequest.extra` 传递(参考 9b 的 extra key 约定表),内置工具的完整支持延后。如果资源有限,`OpenaiResponseProvider` 可延迟到 Phase 2 之后开发,不影响其他 Provider。
|
||||
|
||||
**验证**:
|
||||
- 每个 Provider 的 `chat()` 和 `chat_stream()` 基本路径集成测试(mock HTTP 层)
|
||||
- 消息类型双向映射测试(`Message → OpenaiChatRequest`, `OpenaiChatResponse → MessageResponse`)
|
||||
- 错误路径测试(HTTP 400/401/429/500 → `LlmError` 映射)
|
||||
|
||||
**HTTP mock 策略**:
|
||||
- 推荐使用 [`wiremock`](https://crates.io/crates/wiremock) crate(项目尚无 HTTP mock 依赖)
|
||||
- 每个 Provider 的测试模块中,用 `MockServer` 启动 mock 服务端,返回预定义请求/流式响应
|
||||
- `OpenaiProvider` 的 mock 端点为 `/chat/completions`(SSE 流或 JSON 响应)
|
||||
- `AnthropicProvider` 的 mock 端点为 `/v1/messages`(SSE 事件序列)
|
||||
- 测试不依赖真实网络,`base_url` 指向 `mock_server.uri()`
|
||||
|
||||
### Phase 2:LlmCycle 简化(逻辑重构)
|
||||
|
||||
> **说明**:Phase 0 已完成 `LlmCycle` 的"类型迁移"(trait 签名、`build_request` 转换层、返回值类型)。Phase 2 聚焦**逻辑简化**——去掉 Phase 0 遗留的临时转换层,利用新类型的表达能力重写 LlmCycle 核心逻辑。
|
||||
|
||||
**目标**:
|
||||
- 将 `LlmCycle` 内部消息存储从 `Vec<OpenaiChatMessage>` 切换为 `Vec<Message>`,**移除 Phase 0 引入的 `OpenaiChatMessage → Message` 转换层**
|
||||
- 流处理循环重构:利用 `MessageComplete.full_response` 直接拿到完整响应,去掉手动 delta 累积
|
||||
- 工具循环清洗:从 `MessageResponse.message` 的 content 中直接提取 `ContentBlock::ToolUse`
|
||||
- `compact.rs` 适配新 `Message` 类型
|
||||
|
||||
**涉及文件**:
|
||||
- `src/llm/cycle.rs` — 主要修改
|
||||
- `src/llm/cycle/usage.rs` — 保持兼容(`Usage` 类型不变)
|
||||
- `src/llm/cycle/retry.rs` — 保持兼容
|
||||
- `src/llm/compact.rs` — 适配 `Message` 类型
|
||||
|
||||
**具体任务**:
|
||||
1. `self.messages` 从 `Vec<OpenaiChatMessage>` 改为 `Vec<Message>`,移除 `build_request()` 中的类型转换步骤
|
||||
2. `build_request()` 直接构建 `MessageRequest`(`messages` 直接传入 `self.messages`),不再手动插入 system prompt(从 messages 中取 `Message::System`)
|
||||
3. `submit()` / `submit_messages()`:已返回 `MessageResponse`,无需改签名。检查调用方是否直接解构 `MessageResponse` 是正确的
|
||||
4. `submit_stream()`:流处理循环中锚定 `MessageComplete.full_response`,拿到完整的 `MessageResponse` 后直接继续 tool 循环或结束。去掉中间状态的维护
|
||||
5. tool 循环:从 `MessageResponse.message` 的 `Assistant { content }` 中提取 `ContentBlock::ToolUse` 变体
|
||||
6. `compact.rs` 适配:`microcompact()` / `should_compact()` 的操作对象从 `OpenaiChatMessage` 改为 `Message`,按 text block 长度计算 token 数
|
||||
7. 清理 Phase 0 引入的临时转换函数(`chat_message_to_message`、`message_to_chat_message` 等),确认不再被引用后删除
|
||||
|
||||
**验证**:
|
||||
- `LlmCycle` 集成测试全部通过
|
||||
- 多轮对话 + 工具调用的端到端流程正常
|
||||
- `git diff` 确认 Phase 0 引入的临时转换函数已被删除
|
||||
|
||||
---
|
||||
|
||||
## 5. 验证标准
|
||||
|
||||
| 维度 | 验证方法 | 通过条件 |
|
||||
|------|---------|---------|
|
||||
| 类型正确性 | `cargo test` | 所有测试通过 |
|
||||
| JSON 双向映射 | 单元测试 | `Message → JSON → Message` 往返不变 |
|
||||
| Provider 基本路径 | 集成测试(mock HTTP) | 每个 Provider 的 chat + chat_stream 成功 |
|
||||
| Provider 错误路径 | 集成测试(mock HTTP 4xx/5xx) | 错误映射为正确的 `LlmError` 变体 |
|
||||
| StreamEvent 完整快照 | 集成测试 | `MessageComplete.full_response` 与 PartialMessageResponse 聚合结果一致 |
|
||||
| LlmCycle 多轮对话 | 集成测试(mock Provider) | 多轮对话 + 工具循环正常 |
|
||||
| compact | 集成测试 | 超过 token 阈值后消息被正确压缩 |
|
||||
| 向后兼容(已有代码) | 编译检查 | Phase 0 修改 `LlmProvider` trait + `LlmCycle` 调用点 + `StubProvider` 后,`cargo test` 全部通过。`git diff` 只涉及预期变更的文件,无意外修改 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 回滚方案
|
||||
|
||||
由于项目尚无外部消费者,回滚策略比较简单。每个 Phase 结束时打 tag 作为 checkpoint,允许跳跃回退。
|
||||
|
||||
| 阶段 | 触发条件 | 操作 |
|
||||
|------|---------|------|
|
||||
| Phase 0(类型层) | 新类型设计发现重大缺陷 | 回退 git,保留 9 系文档作为参照,重启设计评审 |
|
||||
| Phase 0 完成时 | 类型定义通过评审和测试 | 打 tag `types-v2-prototype` |
|
||||
| Phase 1(Provider 适配) | 某个 Provider 实现不合理 | 将该 Provider 回退为 `unimplemented!()`(当前状态),不影响其他 Provider |
|
||||
| Phase 1 完成时 | Provider 测试全部通过 | 打 tag `providers-v2-prototype` |
|
||||
| Phase 2(LlmCycle 简化) | 循环逻辑或 compact 出现问题 | 保留旧 `LlmCycle` 实现(不改文件名),通过 feature flag 切换 |
|
||||
| **跨阶段回退** | Phase 2 发现 Phase 0 类型设计有误 | 回退至 Phase 0 checkpoint(`types-v2-prototype`),在不动已有文件的前提下直接原地修改新类型文件重新迭代,不需要整个回退到 Phase 0 之前 |
|
||||
|
||||
**风险储备**:
|
||||
- 如果 `OpenaiProvider` 的重写复杂度过高,可以保留旧的 `OpenaiProvider` 不变,在旁边新增一个 `OpenaiProviderV2` 并行开发
|
||||
- `ChatRequest` / `ChatResponse` / `Message` / `ContentBlock` / `ToolDefinition` / `StopReason` 等类型别名和旧类型结构体的弃用路径:
|
||||
- **Phase 0 完成时**:旧别名**保留**(作为编译桥接),新类型通过不同路径(`request_v2::MessageRequest`、`response_v2::MessageResponse`)访问,两者同时存在于类型模块中
|
||||
- **Phase 1 完成时**:Provider 实现切换到新类型,旧 `OpenaiProvider` 的临时桥接代码被 Phase 1 的真实实现替换。旧别名仍由 `src/llm/types/mod.rs` 导出,不影响其他模块
|
||||
- **Phase 2 完成时**:`LlmCycle` 内部消息存储从 `Vec<OpenaiChatMessage>` 切换到 `Vec<Message>`,所有 `ChatRequest`/`ChatResponse` 引用被替换。此时对 `ChatRequest`、`ChatResponse`、`Message`、`ContentBlock`、`ToolDefinition`、`StopReason` 等旧别名和 `ChatResponse` 结构体加 `#[deprecated]` 标记
|
||||
- **下一个版本(v0.2.0 或 v1.0.0)**:运行 `cargo check` 确认无外部引用后,删除所有 deprecated 别名和 `ChatResponse` 结构体
|
||||
|
||||
---
|
||||
|
||||
## 7. 开放事项
|
||||
|
||||
以下事项已在 9 系文档中充分讨论,本次无修订,但列出以供跟踪:
|
||||
|
||||
- [ ] `ContentBlock::Extension` 作为逃生舱的具体使用场景(OpenAI Response 内置工具、未知 block 类型)
|
||||
- [ ] Anthropic 的 `/v1/messages` 流式 SSE 解析状态机细节(9d Provider 实现文档)
|
||||
- [ ] `MessageRequest.extra` 中每个 Provider 实际需要的 key 清单(9b 已有草案,Phase 1 实现时细化和验证)
|
||||
- [ ] Thinking signature 的端到端测试(9f 已有处理策略,Phase 2 时分配合并完成)
|
||||
- [ ] cost 计算逻辑适配新类型(当前 `CostTracker` 在 `Usage` 上工作,类型不变、无需修改,但在集成测试中验证)
|
||||
- [ ] `Message::ToolResult` 命名 — 9b 中叫 `Tool`(对应 OpenAI 的 `tool` role),本设计改为 `ToolResult`。Anthropic 没有独立的 `tool` role(tool_result 是 content block),实施时需验证此命名与所有 Provider 映射的一致性
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,340 @@
|
||||
# v0.1 发布实施计划
|
||||
|
||||
> 状态:待实施
|
||||
> 关联文档:`docs/roadmap.md`、`docs/8-examples-plan.md`、`docs/10c-phase2-llm-cycle-simplify.md`
|
||||
|
||||
## 背景与目标
|
||||
|
||||
AG Core 已完成 Phase 0-4c 全部 7 个 Phase 的核心功能,以及 Provider IR 重构(新类型系统 + Anthropic/DeepSeek/Qwen Provider)+ LlmCycle 简化(IR 消息类型切换 + 桥接层移除)。
|
||||
|
||||
**当前基线**:
|
||||
- `cargo build` — ✅ 通过(5 个 warning)
|
||||
- `cargo test` — ❌ 编译失败(4 个 error:session.rs/cycle.rs 测试模块中 `ContentBlock`/`ProviderCapabilities`/`ProviderFeatures` 导入缺失,均为 Provider IR 重构后未同步的回归)
|
||||
- LlmCycle 简化合并前全量测试为 177 通过,合并后尚未跑通过过全量测试
|
||||
|
||||
v0.1 发布的阻塞项不是"功能不足",而是"已有功能不能被用户快速看见和使用"。本计划聚焦于:
|
||||
|
||||
1. **扫清技术债** — composer.rs 迁移、锁修复、clippy 清零
|
||||
2. **提供可离线运行的示例** — 让用户 5 分钟内上手
|
||||
3. **补充面向用户的文档** — README、MockProvider、错误消息友好化
|
||||
4. **完成发布前准备** — CI 验证、Roadmap 同步、版本标记
|
||||
|
||||
---
|
||||
|
||||
## 总体时间线
|
||||
|
||||
预计 8-10 个工作日(2 周),分为两条并行线:
|
||||
|
||||
```
|
||||
Week 1 ────┬── 测试修复 + 技术债扫清
|
||||
│ ├ T0 测试编译回归修复(~0.5d)← ⚠️ 任何改动前的前置步骤
|
||||
│ ├ T1.1 composer.rs IR 迁移(~1d)
|
||||
│ ├ T1.2 knowledge.rs 锁修复(~0.5d)
|
||||
│ ├ T1.3 旧类型废弃标记(~0.5d)
|
||||
│ └ T1.4 clippy 清零(~0.5d)
|
||||
│
|
||||
├── 示例 + 开发者体验
|
||||
│ ├ T2.0 MockProvider 公开化(~0.5d)
|
||||
│ ├ T2.1 4个🥇示例(~2-3d,可并行)
|
||||
│ └ T3.1 README 初稿(~1d,并行)
|
||||
│
|
||||
Week 2 ────┬── 示例继续
|
||||
│ ├ T2.2 3个🥈示例(~2d)
|
||||
│ └ T3.2 错误消息 review(~0.5d)
|
||||
│
|
||||
└── 发布准备
|
||||
├ T3.3 README 定稿(~1d)
|
||||
├ T4.1 CI 示例验证(~0.5d)
|
||||
├ T4.2 Roadmap 更新(~0.5d)
|
||||
├ T4.3 CHANGELOG 初始化(~0.5d)
|
||||
└ T4.4 v0.1 tag(~0.5d)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### Phase A — 测试修复 + 技术债扫清(Week 1 前半)
|
||||
|
||||
> **重要**:Task A0 是后续所有任务的前提——不修复测试编译,所有 Task 的验收条件(`cargo test` 全通过)都不可执行。
|
||||
|
||||
#### Task A0:修复测试编译回归(~0.5d)
|
||||
|
||||
**问题**:Provider IR 重构后,`ContentBlock`/`ProviderCapabilities`/`ProviderFeatures` 被移到新位置,但 2 个测试模块的导入未同步,导致 `cargo test` 编译失败。
|
||||
|
||||
**涉及文件**:
|
||||
- `src/agent/session.rs` — 测试模块缺少 `ContentBlock` 导入
|
||||
- `src/llm/cycle.rs` — 测试模块缺少 `ProviderCapabilities`/`ProviderFeatures` 导入
|
||||
|
||||
**修复方案**:在相应测试模块内补全 `use` 语句(每处加一行即可)。
|
||||
|
||||
**额外收益**:修复后 `convert.rs` 中 2 处 `irrefutable if let` 警告也一并修正(改为 `let`),减少 clippy 基数。
|
||||
|
||||
**前置依赖**:无
|
||||
|
||||
**验收条件**:
|
||||
- `cargo test` 全量编译通过
|
||||
- `cargo test` 全量运行通过
|
||||
|
||||
---
|
||||
|
||||
#### Task A1:`composer.rs` IR 迁移(~1d)
|
||||
|
||||
**前置依赖**:Task A0(否则无法通过 `cargo test` 验证)
|
||||
|
||||
**涉及文件**:
|
||||
- `src/prompt/composer.rs` — 主要改动
|
||||
- `src/prompt/mod.rs` — 类型重导出
|
||||
|
||||
**改动范围**:约 60 处 `OpenaiChatMessage` → `Message`,`ContentField`/`OpenaiContentPart` → `ContentBlock`
|
||||
|
||||
**具体清单**:
|
||||
1. `PromptComposer` 内部 `messages` 字段类型 `Vec<OpenaiChatMessage>` → `Vec<Message>`
|
||||
2. 所有 `.system_text()`/`.user_text()`/`.assistant_text()`/`.developer_text()`/`.tool_result()` 构造方法 — 新 `Message` 已有同名方法,直接替换调用
|
||||
3. 所有 `xx_content()`/`xx_contents()` 方法 — `ContentBlock` 替代 `ContentField`/`OpenaiContentPart`
|
||||
4. `set_message_name()` — 新 `Message` 是扁平 enum,无 `name` 字段,移除或忽略
|
||||
5. `build()` 返回类型 `Vec<OpenaiChatMessage>` → `Vec<Message>`
|
||||
6. `validate_messages()` 消息校验函数 — `OpenaiChatMessage::Tool { .. }` → `Message::ToolResult { .. }`,`tool_calls` 从 `Assistant` 变体的 `ContentBlock::ToolUse` 中提取
|
||||
|
||||
**测试**:内联测试中 `OpenaiChatMessage::Tool { .. }` pattern match 需同步更新
|
||||
|
||||
**验收条件**:
|
||||
- `cargo build` 无错误
|
||||
- `cargo test` 中 prompt 模块测试全通过
|
||||
- 无新增 clippy 警告
|
||||
|
||||
---
|
||||
|
||||
#### Task A2:`knowledge.rs` 锁修复(~0.5d)
|
||||
|
||||
**涉及文件**:`src/memory/knowledge.rs`
|
||||
|
||||
**问题**:`std::sync::Mutex` guard 在 `search()` 方法中跨 `.await` 持有,可能在高并发下阻塞 tokio 工作线程
|
||||
|
||||
**修复方案**:`std::sync::Mutex<Vec<PageIndexEntry>>` → `tokio::sync::Mutex<Vec<PageIndexEntry>>`
|
||||
|
||||
**影响范围**:5 处 `.lock().unwrap()` 调用(`rebuild_index`、`add_page`、`delete_page`、`search`、`get_index`)
|
||||
|
||||
**验收条件**:
|
||||
- `cargo build` 无错误
|
||||
- `cargo test` 中 memory 模块测试全通过
|
||||
- clippy 不再报 `await_holding_lock` 警告
|
||||
|
||||
---
|
||||
|
||||
#### Task A3:旧类型公开 API 废弃标记(~0.5d)
|
||||
|
||||
**涉及文件**:`src/llm/types/mod.rs`
|
||||
|
||||
**改动**:
|
||||
- `ChatResponse` struct 加 `#[deprecated(since = "0.1.0", note = "请改用 MessageResponse")]`
|
||||
- `ToolDefinition` 类型别名加 `#[deprecated]`(如果仍公开)
|
||||
- 保留结构体定义(OpenAI `chat_inner()` 内部转换层仍然在用),不删除
|
||||
|
||||
**验收条件**:
|
||||
- `cargo build` 产生废弃警告(期望行为)但不产生编译错误
|
||||
- 外部调用方能看到有用提示
|
||||
|
||||
---
|
||||
|
||||
#### Task A4:clippy 警告清零(~0.5d)
|
||||
|
||||
当前剩余 8 个:
|
||||
|
||||
| # | 警告类型 | 文件 | 修复方式 |
|
||||
|---|---------|------|---------|
|
||||
| 1 | irrefutable `if let` | — | 改为直接 `let` |
|
||||
| 2 | `with_plan_step_index` 未用 | `src/llm/hooks.rs` | 加 `#[allow(dead_code)]` |
|
||||
| 3-5 | 字段未读(api_key, stop_sequence, next_block_index) | `anthropic.rs`, `response_v2.rs` | 加 `#[allow(dead_code)]` |
|
||||
| 6 | 手动前缀剥离 | — | 用 `strip_prefix()` |
|
||||
| 7 | MutexGuard 跨 await | knowledge.rs | Task A2 修复后自动消失 |
|
||||
| 8 | `if` 相同分支 | retriever.rs | 合并条件 |
|
||||
|
||||
**验收条件**:`cargo clippy --lib -p agcore` 0 警告
|
||||
|
||||
---
|
||||
|
||||
### Phase B — 示例实现(Week 1 后半 ~ Week 2 前半)
|
||||
|
||||
#### Task B0:MockProvider 公开化(~0.5d)
|
||||
|
||||
**涉及文件**:
|
||||
- `src/llm/mock.rs` — 新建,公开 `MockProvider` struct
|
||||
- `src/llm/mod.rs` — 加 `pub mod mock;`
|
||||
- `src/agent/session.rs` — 测试中 `MockProvider` 改为引用 `crate::llm::mock::MockProvider`
|
||||
|
||||
**MockProvider 接口**:
|
||||
|
||||
```rust
|
||||
pub struct MockProvider { .. }
|
||||
impl MockProvider {
|
||||
pub fn new(responses: Vec<MessageResponse>) -> Self;
|
||||
pub fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError>;
|
||||
pub fn chat_stream(&self, request: MessageRequest)
|
||||
-> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>;
|
||||
}
|
||||
```
|
||||
|
||||
**决策**:同时实现 `chat_stream`,从 `MessageResponse` 拆解为 `StreamEvent::ContentBlockStart + ContentBlockDelta * N + MessageComplete` 序列,使流式示例也能不依赖 API key。
|
||||
|
||||
**验收条件**:
|
||||
- 公开 `MockProvider` 可被外部 crate 引用
|
||||
- `cargo test` 中所有 session 测试通过
|
||||
- `agent_session_demo.rs` 示例可引用 `MockProvider` 并运行
|
||||
|
||||
---
|
||||
|
||||
#### Task B1:🥇 示例 × 4(~2-3d,可并行)
|
||||
|
||||
4 个示例相互无依赖,按技术债消除进度安排:
|
||||
|
||||
| # | 示例 | 代码量 | 前置依赖 | 说明 |
|
||||
|---|------|--------|---------|------|
|
||||
| B1a | `agent_session_demo.rs` | ~100 行 | Task B0 | AgentBuilder → AgentSession → submit_turn → SessionMemory 完整链路 |
|
||||
| B1b | `custom_tool.rs` | ~80 行 | 无 | 实现模拟工具 → ToolRegistry 注册 → invoke/invoke_all → PermissionChecker |
|
||||
| B1c | `prompt_composer.rs` | ~60 行 | Task A1 | 模板变量插值 → PromptComposer 构建消息链 → 断言验证,纯离线 |
|
||||
| B1d | `task_agent_demo.rs` | ~70 行 | 无 | 构造 JSON → JsonPlanParser → Plan → Step 状态机 → Hook 事件 |
|
||||
|
||||
每个示例的详细设计见 `docs/8-examples-plan.md`,按该文档直接实现。
|
||||
|
||||
**验收条件**:
|
||||
- `cargo run --example prompt_composer` → 成功退出
|
||||
- `cargo run --example custom_tool` → 成功退出
|
||||
- `cargo run --example agent_session_demo` → 成功退出
|
||||
- `cargo run --example task_agent_demo` → 成功退出
|
||||
|
||||
---
|
||||
|
||||
#### Task B2:🥈 示例 × 3(~2d)
|
||||
|
||||
| # | 示例 | 代码量 | 前置依赖 |
|
||||
|---|------|--------|---------|
|
||||
| B2a | `conversation_memory_demo.rs` | ~70 行 | 无 |
|
||||
| B2b | `knowledge_search_demo.rs` | ~60 行 | 无 |
|
||||
| B2c | `streaming_events_demo.rs` | ~80 行 | Task B0(MockProvider 需支持 `chat_stream`) |
|
||||
|
||||
**验收条件**:额外 3 个示例均可 `cargo run` 成功退出
|
||||
|
||||
---
|
||||
|
||||
### Phase C — 开发者体验 + 文档(Week 2 后半,与 Phase B 后段并行)
|
||||
|
||||
#### Task C1:README 完整版(~1.5d)
|
||||
|
||||
**涉及文件**:`README.md`
|
||||
|
||||
**内容结构**:
|
||||
|
||||
```
|
||||
# AG Core
|
||||
|
||||
## 这是什么? ← 一句话定位
|
||||
## 快速上手 ← cargo add + MockProvider 示例
|
||||
## 核心模块 ← 7 个模块一句话说明
|
||||
## 架构关系图 ← ASCII 或 Mermaid
|
||||
## 模块依赖关系 ← 依赖关系图 + 说明
|
||||
## 环境变量 ← 当前支持的环境变量表
|
||||
## 参考项目 ← OpenClaw / Hermes / OpenHuman / OpenHarness
|
||||
## 许可证 ← MIT / Apache-2.0
|
||||
```
|
||||
|
||||
**要求**:快速上手代码段在提交前必须真实编译通过。
|
||||
|
||||
---
|
||||
|
||||
#### Task C2:错误消息友好化 review(~0.5d)
|
||||
|
||||
**涉及文件**:
|
||||
- `src/agent/error.rs` — AgentError 消息
|
||||
- `src/llm/error.rs` — LlmError 消息
|
||||
- `src/tools/error.rs` — ToolError 消息
|
||||
- `src/memory/error.rs` — MemoryError 消息
|
||||
- `src/prompt/error.rs` — PromptError 消息
|
||||
|
||||
**检查标准**:
|
||||
- 用户能理解"哪里错了"
|
||||
- 有可操作的建议("请检查 API key"、"请配置环境变量 LLM_API_KEY")
|
||||
- 无英文残留的工程师视角消息
|
||||
|
||||
---
|
||||
|
||||
### Phase D — 发布准备(Week 2 末)
|
||||
|
||||
#### Task D1:CI 示例验证集成(~0.5d)
|
||||
|
||||
- 确认 `cargo build` 无新增警告
|
||||
- 确认 `cargo test` 全部通过(退出码 0)
|
||||
- 确认 `cargo test --examples` 全部通过
|
||||
- 确认 `cargo clippy --lib -p agcore` 0 警告
|
||||
|
||||
#### Task D2:Roadmap 更新(~0.5d)
|
||||
|
||||
更新 `docs/roadmap.md`:
|
||||
- Phase 2 状态更新为 ✅ 全部交付物已完成(Provider IR 重构 + LlmCycle 简化)
|
||||
- 测试计数更新为 D1 确认的最终实际数值
|
||||
- 移除 clippy 警告计数
|
||||
- 添加 v0.1 发布里程碑
|
||||
|
||||
#### Task D3:依赖裁剪(~0.5d,非阻塞,可跳过)
|
||||
|
||||
`tokio = { features = ["full"] }` → 按需 feature(`rt`、`macros`、`sync`、`time`、`net`)
|
||||
|
||||
**理由**:减少编译时间 + 减少安全面。时间不够可延后到 v0.2。
|
||||
|
||||
#### Task D4:CHANGELOG 初始化(~0.5d)
|
||||
|
||||
**涉及文件**:`CHANGELOG.md`
|
||||
|
||||
**内容要求**:
|
||||
- 版本号:`v0.1.0`
|
||||
- 发布日期:标记当天
|
||||
- 变更摘要:Phase 0-4c(LLM 调用周期、提示词工程、工具系统、记忆系统、Agent 运行时、任务执行、会话级记忆)+ Provider IR 重构(统一类型系统 + Anthropic/DeepSeek/Qwen 适配)+ LlmCycle 简化
|
||||
- 格式参考 [Keep a Changelog](https://keepachangelog.com/) 规范
|
||||
|
||||
**验收条件**:
|
||||
- `CHANGELOG.md` 文件存在,内容与当前版本一致
|
||||
- 文件随 tag commit 一起提交
|
||||
|
||||
---
|
||||
|
||||
#### Task D5:版本标记(~0.5d)
|
||||
|
||||
```bash
|
||||
git tag v0.1.0 && git push --tags
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 并行机会
|
||||
|
||||
| 并行组 | 包含任务 | 说明 |
|
||||
|--------|---------|------|
|
||||
| 组 0 | A0 | 单独执行,后续所有 Task 的前提 |
|
||||
| 组 1 | A1 + A2 + A3 | A0 完成后可并行修 |
|
||||
| 组 2 | B1a + B1b + B1d | 三个示例无前置依赖(B1a 需等 B0,B1c 需等 A1) |
|
||||
| 组 3 | C1 + C2 + D2 | README / 错误消息 / Roadmap 更新,纯文档工作 |
|
||||
| 组 4 | D1 + D3 | CI 验证 + 依赖裁剪,可并行 |
|
||||
|
||||
---
|
||||
|
||||
## 风险与应对
|
||||
|
||||
| 风险 | 影响 | 概率 | 应对 |
|
||||
|------|------|------|------|
|
||||
| A0 修复后仍有未发现的测试回归 | 🔴 后续 Task 验收不可信 | 低 | `cargo test` 全量通过后才启动 A1 |
|
||||
| composer.rs 迁移发现未预见的 API 依赖 | 🔴 A1 延期 | 低 | 保持每次 commit 可编译;分 2 次提交(先私有不影响构建,再改返回类型) |
|
||||
| MockProvider stream 实现比预期复杂 | 🟡 B2c 延期 | 中 | 先简化实现(只支持基本 text delta),复杂 case 留给 v0.2 |
|
||||
| 示例与库 API 不同步 | 🟡 持续风险 | 中 | 所有示例纳入 `cargo test --examples` 作为 CI gate |
|
||||
| clippy `#[allow(dead_code)]` 积累过多 | 🟢 低 | 高 | v0.2 发布前专门 review 一次允许列表 |
|
||||
| tokio feature 裁剪导致编译失败 | 🟡 D3 延期 | 低 | 裁剪前确认现有 feature 覆盖所有 `tokio::` 调用点 |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. **代码质量**:`cargo build` 0 错误,`cargo clippy --lib -p agcore` 0 警告
|
||||
2. **测试覆盖**:`cargo test` 全量编译通过且全部运行通过,`cargo test --examples` 全通过
|
||||
3. **示例可用**:4 个🥇 + 3 个🥈共 7 个示例均可离线 `cargo run` 成功退出
|
||||
4. **文档完整**:README 包含快速上手 + 架构概览,错误消息全部友好化
|
||||
5. **版本标记**:`git tag v0.1.0`
|
||||
6. **Roadmap 同步**:`docs/roadmap.md` 测试计数和状态与代码一致
|
||||
@@ -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 8(MVP 出口)依赖 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.1(ProviderConfig 扩展)
|
||||
// 执行到此分支时,将同步补充 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]` 无法标注不存在的枚举,因此排除标注。Roadmap(v0.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` 的 Provider(OpenAI / 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 时保留已有默认 headers(x-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()` 中各分支注入 timeout(OpenAI-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)`,改为参数化读取
|
||||
@@ -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 Compat(DeepSeek、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+ 阶段处理 |
|
||||
@@ -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 8(MVP 出口)和 Phase 10(ContextSlot)的前置依赖。
|
||||
|
||||
**成功标准**:
|
||||
- SqliteStore 完整实现 MemoryStore trait(4 个方法: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 存储反而需要手动处理排序逻辑。非必要不引入新存储范式。
|
||||
|
||||
---
|
||||
|
||||
## 推荐方案
|
||||
|
||||
### 总体方向:方案 A(rusqlite + 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 自然完成或 panic,Mutex 通过 `.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/Redis,v0.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 B1(rusqlite + tempfile 依赖)、Task A1(store/ 目录存在) |
|
||||
| 预估工作量 | M(1-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 B2(sqlite_store.rs 文件存在) |
|
||||
| 预估工作量 | XS(< 15min) |
|
||||
| 风险等级 | 低 |
|
||||
| 验收条件 | `cargo build` 通过,`SqliteStore` 可从 `agcore::memory::SqliteStore` 路径访问 |
|
||||
|
||||
#### Task B4 — 编写测试
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 任务描述 | 在 `sqlite_store.rs` 中编写 `#[cfg(test)] mod tests`,覆盖: |
|
||||
| | (1)CRUD 基本操作(save → get → list → delete → get None) |
|
||||
| | (2)Upsert 语义(同 id 重复 save 覆盖内容,created_at 保持调用方传入值) |
|
||||
| | (3)prefix 过滤(MemoryFilter.prefix) |
|
||||
| | (4)时间范围过滤(MemoryFilter.since) |
|
||||
| | (5)offset/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(实现完成) |
|
||||
| 预估工作量 | M(1-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
|
||||
@@ -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 1(StepStatus 先标记 `#[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+2(CHANGELOG 需记录实际变更) |
|
||||
| 预估工作量 | 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 Agent(name / 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(示例编写模式已建立);SqliteStore(Phase 7 已完成) |
|
||||
| 预估工作量 | M(1-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() {
|
||||
// 使用真实 Provider(AG_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 5(Quick Start) | 零文件重叠 |
|
||||
| 2 | commit 2(StepStatus 修复) | commit 5(Quick Start) | 零文件重叠 |
|
||||
| 3 | commit 5(Quick 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.1,Phase 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(同步触发,返回流之前) │ │
|
||||
│ │ ├─ 组装 LlmCycle(system_prompt / compact_config) │ │
|
||||
│ │ ├─ 调用 submit_with_tools_stream() │ │
|
||||
│ │ ├─ turn_index += 1 │ │
|
||||
│ │ └─ 返回流 │ │
|
||||
│ │ │ │
|
||||
│ │ finalize_turn(response) │ │
|
||||
│ │ ├─ cost_so_far.add(&response.usage) │ │
|
||||
│ │ └─ OnTurnEnd hook(turn_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 自身失败(网络/认证/限流)
|
||||
// 这里不做 retry:retry 逻辑留给上层循环的 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 task;hook 在 `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` 核心
|
||||
|
||||
**工作量**:M(1-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 层)
|
||||
|
||||
**工作量**:M(1-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`;消息历史长度为 8(user + 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
|
||||
}
|
||||
```
|
||||
|
||||
## 附录 B:CycleConfig 的 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,与 ContextSlot(P1,Phase 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 + 余弦相似度。
|
||||
///
|
||||
/// **稳定性**:实验性 API(v0.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。
|
||||
|
||||
##### P0(7 个)
|
||||
|
||||
| # | 测试名 | 所属文件 | 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` 正确解析 |
|
||||
|
||||
##### P1(5 个)
|
||||
|
||||
| # | 测试名 | 所属文件 | 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.1(PM 建议)
|
||||
|
||||
**内容**: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 个测试) | +12(7 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 Compat,wiremock 测试继承 `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.3(11.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 | 总量 100,id 无重复 |
|
||||
| **11.3.2** | `concurrent_writers_max_pressure` | `sqlite_store.rs` | 无 | S | 中 | 100 task × 1 write | 总量 100,id 无重复 |
|
||||
| **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+ passed,0 failed |
|
||||
| 3 | Lint | `cargo clippy --all-targets -- -D warnings` | 0 warning |
|
||||
| 4 | 文档 | `cargo doc --no-deps` | 0 warning(VectorRetriever 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 13(v0.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.1(ToolChoice 不搬走,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-merge(child.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 消息内容、血缘关系不变)。
|
||||
|
||||
**预估工作量**:M(1-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.4(ToolChoice 已移走,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` 文件
|
||||
|
||||
**预估工作量**:M(1-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` 文件
|
||||
|
||||
**预估工作量**:M(1-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(<1h,cleanup)+ 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`)
|
||||
@@ -0,0 +1,278 @@
|
||||
# LLM 调用周期控制 — 实施方案
|
||||
|
||||
> 参考实现: [HKUDS/OpenHarness](https://github.com/HKUDS/OpenHarness)
|
||||
|
||||
## 目标
|
||||
|
||||
实现大模型基础调用周期控制,作为 agcore 的核心底层件。
|
||||
|
||||
## 范围
|
||||
|
||||
- 仅支持 OpenAI-compatible API (`POST /v1/chat/completions`)
|
||||
- 仅非流式调用(后续可扩展流式)
|
||||
- 支持传入 tool definitions 和解析 tool_use response,但**不含 tool 自动执行循环**
|
||||
- 单次请求-响应周期控制
|
||||
|
||||
## 领域模块结构
|
||||
|
||||
所有 LLM 调用周期相关代码归入 `llm` 领域目录,未来其他功能(工具、记忆、提示词等)以同样方式组织。
|
||||
|
||||
```
|
||||
src/
|
||||
lib.rs # crate 根
|
||||
llm.rs # mod llm — 领域根(声明 + 重导出)
|
||||
llm/
|
||||
types.rs # llm::types — Message, ContentBlock, ChatRequest/Response, ToolDefinition
|
||||
error.rs # llm::error — LlmError
|
||||
provider.rs # llm::provider — LlmProvider trait(仅接口)
|
||||
provider/
|
||||
openai.rs # llm::provider::openai — OpenaiProvider 实现
|
||||
cycle.rs # llm::cycle — 生命周期引擎(子模块根)
|
||||
cycle/
|
||||
retry.rs # llm::cycle::retry — 重试策略
|
||||
usage.rs # llm::cycle::usage — Token 用量
|
||||
|
||||
# 未来领域示例(占位):
|
||||
# tools.rs + tools/ # 工具调用、MCP
|
||||
# memory.rs + memory/ # 记忆系统
|
||||
# prompt.rs + prompt/ # 提示词工程
|
||||
# agent.rs + agent/ # Agent 运行时
|
||||
```
|
||||
|
||||
`llm.rs` 根模块声明:
|
||||
|
||||
```rust
|
||||
// llm.rs
|
||||
pub mod types;
|
||||
pub mod error;
|
||||
pub mod provider;
|
||||
pub mod cycle;
|
||||
```
|
||||
|
||||
## 模块设计
|
||||
|
||||
### 1. llm/types.rs — 核心数据类型
|
||||
|
||||
```rust
|
||||
pub enum Role { User, Assistant, System, Tool }
|
||||
|
||||
pub enum ContentBlock {
|
||||
Text { text: String },
|
||||
ImageUrl { url: String }, // 多模态支持
|
||||
ToolUse { id: String, name: String, input: Value }, // 预留,暂不实现 tool 自动执行循环
|
||||
ToolResult { tool_use_id: String, content: String }, // 预留,暂不实现 tool 自动执行循环
|
||||
}
|
||||
|
||||
pub struct Message {
|
||||
pub role: Role,
|
||||
pub content: Vec<ContentBlock>,
|
||||
}
|
||||
|
||||
pub struct ToolDefinition {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_schema: Value,
|
||||
}
|
||||
|
||||
pub struct ChatRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<Message>,
|
||||
pub system_prompt: Option<String>,
|
||||
pub tools: Vec<ToolDefinition>,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
pub extra_body: Option<Value>, // 用于 enable_thinking 等扩展参数(如阿里云 DashScope)
|
||||
}
|
||||
|
||||
pub struct ChatResponse {
|
||||
pub message: Message,
|
||||
pub usage: Usage,
|
||||
pub stop_reason: Option<StopReason>,
|
||||
}
|
||||
|
||||
pub enum StopReason {
|
||||
Stop,
|
||||
ToolUse, // 预留,暂不实现 tool 自动执行循环
|
||||
MaxTokens, // 达到 max_tokens 限制
|
||||
ContentFilter,
|
||||
Length, // 同 MaxTokens,兼容某些 API 的 finish_reason
|
||||
Other(String),
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:`ToolUse` / `ToolResult` / `ToolUse` variant of `StopReason` 为预留类型,暂不实现 tool 自动执行循环。
|
||||
|
||||
### 2. llm/error.rs — 错误体系
|
||||
|
||||
```rust
|
||||
#[derive(thiserror::Error)]
|
||||
pub enum LlmError {
|
||||
#[error("认证失败: {0}")]
|
||||
Authentication(String),
|
||||
|
||||
#[error("限流{retry_after:?}")]
|
||||
RateLimit { retry_after: Option<Duration> },
|
||||
|
||||
#[error("请求失败({status}): {body}")]
|
||||
Request { status: u16, body: String },
|
||||
|
||||
#[error("请求超时({duration:?})")]
|
||||
Timeout { duration: Duration },
|
||||
|
||||
#[error("流式响应错误: {0}")]
|
||||
Stream(String),
|
||||
|
||||
#[error("上下文超限(actual:{actual}, limit:{limit})")]
|
||||
ContextLength { actual: u32, limit: u32 },
|
||||
|
||||
#[error("LLM 调用失败: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
```
|
||||
|
||||
**可重试错误**:`RateLimit`、`Timeout`、状态码 `5xx`。
|
||||
**不可重试**:`Authentication`、状态码 `4xx`(除 429)、`ContextLength`。
|
||||
|
||||
### 3. llm/provider.rs — Provider 接口
|
||||
|
||||
trait 单独存放,具体实现在 `provider/` 子模块。
|
||||
|
||||
```rust
|
||||
// llm/provider.rs
|
||||
pub mod openai;
|
||||
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, LlmError>;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.1 llm/provider/openai.rs — OpenAI 兼容实现
|
||||
|
||||
```rust
|
||||
use super::LlmProvider;
|
||||
|
||||
pub struct OpenaiProvider {
|
||||
http_client: reqwest::Client,
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
}
|
||||
|
||||
impl LlmProvider for OpenaiProvider {
|
||||
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, LlmError> {
|
||||
// POST {base_url}/chat/completions
|
||||
// extra_body 会被合并到请求体中(如 enable_thinking)
|
||||
// 解析 response → ChatResponse
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:`extra_body` 中的字段需与目标 API 兼容。部分 API(如阿里云 DashScope)通过 `extra_body` 传递扩展参数(如 `enable_thinking`)。
|
||||
|
||||
后续新增实现: `provider/anthropic.rs`、`provider/azure.rs` 等。
|
||||
|
||||
### 4. llm/cycle.rs — 生命周期引擎
|
||||
|
||||
```rust
|
||||
mod retry;
|
||||
mod usage;
|
||||
|
||||
pub use retry::RetryConfig;
|
||||
pub use usage::{CostTracker, Usage};
|
||||
|
||||
pub struct CycleConfig {
|
||||
pub model: String,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
pub max_turns: Option<u32>,
|
||||
pub retry: RetryConfig,
|
||||
}
|
||||
|
||||
pub struct LlmCycle {
|
||||
provider: Box<dyn LlmProvider>,
|
||||
config: CycleConfig,
|
||||
usage: CostTracker,
|
||||
messages: Vec<Message>,
|
||||
system_prompt: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
`submit()` 完整流程:
|
||||
|
||||
```
|
||||
submit(prompt, tools)
|
||||
│
|
||||
├─ ① push Message(user, [Text(prompt)])
|
||||
├─ ② 构建 ChatRequest { messages, system, tools, max_tokens, temperature }
|
||||
├─ ③ [重试循环] provider.chat(request)
|
||||
│ ├─ Ok → 解析 ChatResponse
|
||||
│ └─ Err(可重试) → compute_delay → sleep → retry
|
||||
├─ ④ push Message(assistant, [Text(...) | ToolUse(...)])
|
||||
├─ ⑤ usage.add(response.usage)
|
||||
└─ ⑥ return ChatResponse
|
||||
```
|
||||
|
||||
#### 4.1 llm/cycle/retry.rs — 重试策略
|
||||
|
||||
```rust
|
||||
pub struct RetryConfig {
|
||||
pub max_retries: u32, // 默认 3
|
||||
pub base_delay: Duration, // 默认 1s
|
||||
pub max_delay: Duration, // 默认 30s
|
||||
pub jitter_factor: f64, // 默认 0.25
|
||||
}
|
||||
```
|
||||
|
||||
指数退避 + jitter: `delay = min(base * 2^attempt, max_delay) + random(0, delay * jitter_factor)`
|
||||
|
||||
**可重试错误**: `RateLimit`、`Timeout`、状态码 `5xx`
|
||||
**不可重试**: `Authentication`、状态码 `4xx`(除 429)、`ContextLength`
|
||||
|
||||
`should_retry(err: &LlmError) -> bool` 判断逻辑:
|
||||
- `RateLimit` → true
|
||||
- `Timeout` → true
|
||||
- `Request { status, .. }` → status >= 500 || status == 429
|
||||
- 其他 → false
|
||||
|
||||
#### 4.2 llm/cycle/usage.rs — Token 用量
|
||||
|
||||
```rust
|
||||
#[derive(Default)]
|
||||
pub struct Usage { pub input_tokens: u32, pub output_tokens: u32 }
|
||||
|
||||
pub struct CostTracker { accumulated: Usage }
|
||||
impl CostTracker {
|
||||
pub fn add(&mut self, usage: &Usage);
|
||||
pub fn total(&self) -> &Usage;
|
||||
pub fn reset(&mut self);
|
||||
}
|
||||
```
|
||||
|
||||
## 依赖
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
async-trait = "0.1"
|
||||
tracing = "0.1"
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
- Unit: types 序列化、retry 退避计算、usage 累计
|
||||
- Mock: HTTP mock server 测试 provider 请求/响应/错误处理
|
||||
- Integration (可选): Ollama 本地真实调用验证
|
||||
|
||||
## 后续扩展
|
||||
|
||||
- 流式接口 (`Stream<CycleEvent>`)
|
||||
- Tool 自动执行循环 (参考 OpenHarness `run_query()`)
|
||||
- 多 Provider 注册发现 (参考 OpenHarness `ProviderRegistry`)
|
||||
- 上下文压缩 (auto-compaction)
|
||||
- 生命周期钩子 (pre/post tool use hooks)
|
||||
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 block(Image / 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-16(Phase 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(继承父的 RuntimeBundle,Arc::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 2:EngineError + 模块骨架
|
||||
|
||||
- **文件**:
|
||||
- `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 3:SessionSnapshot + 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>`(**纯同步**,不写 store;session_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 4:Checkpointer
|
||||
|
||||
- **文件**:`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 5:SessionManager
|
||||
|
||||
- **文件**:`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_id(UUID 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 18:Agent 角色热切换与子代理调度
|
||||
|
||||
## 背景与目标
|
||||
|
||||
### 问题空间
|
||||
|
||||
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 17(SessionManager + 会话树 + 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 存到 MemoryStore;switch 后需更新 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_meta(I/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` 所需的基础设施已全部就绪,无需新增任何依赖 [高]。
|
||||
|
||||
---
|
||||
|
||||
## 可选方案
|
||||
|
||||
### 方案 A:switch_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 方法 [中]
|
||||
|
||||
**结论**:采用 A2(SessionManager 方法)。AgentSession 没有 store 引用,无法更新 SessionMeta。独立文件 `engine/switch.rs` 作为 SessionManager 的 impl 块。
|
||||
|
||||
### 方案 B:bridge_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 负责"桥接数据"。
|
||||
|
||||
### 方案 C:dispatch_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 更新 SessionMeta,AgentSession 无 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 条目数通常 < 100,O(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 session(RwLock 读锁,返回后释放)
|
||||
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 dropped:drop 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 行)
|
||||
- 父设置 SessionMemory(key: "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` 捕获 panic;panic 传播到 `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)、SessionMeta(L32)、锁契约(L4-12) |
|
||||
| `src/engine/error.rs` | EngineError 枚举(当前 6 变体,`#[non_exhaustive]`) |
|
||||
| `src/engine/mod.rs` | 模块注册 |
|
||||
| `src/engine/snapshot.rs` | SessionSnapshot(from_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 6(KnowledgeStore)[高]、Phase 15(VectorStore 模式参考)[低]
|
||||
- **优先级**:P0(v0.3.0 最后一个 Phase)
|
||||
- **预估规模**:约 600 行核心 + 200 行测试
|
||||
|
||||
---
|
||||
|
||||
## 需求分析
|
||||
|
||||
### 功能需求
|
||||
|
||||
| ID | 需求 | 优先级 |
|
||||
|----|------|--------|
|
||||
| F1 | `GraphEntity` / `GraphRelation` / `RelationDirection` 类型定义 | P0 |
|
||||
| F2 | `KnowledgeGraph` trait(10 个 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 1(Phase 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 模块依赖 llm(conversation/vector_store 使用 compact/embedding)、tokio(knowledge.rs 使用 Mutex)、time(types.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-util(Stream 组合)
|
||||
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 模块依赖 llm(conversation/vector_store 使用 compact/embedding)、tokio(knowledge.rs 使用 Mutex)、time(types.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-util(Stream 组合)
|
||||
+ 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 1:Cargo.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 2:cfg 门控(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 3:Checkpoint 全量验证
|
||||
|
||||
**操作清单**:
|
||||
|
||||
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 模块依赖 llm(conversation/vector_store)、tokio(knowledge)、time(types),imply 链必须完整传递 | `memory` feature 定义已包含 `llm`、`tokio`、`time`;验证矩阵覆盖 memory 独立编译 |
|
||||
| **跨模块引用未门控** | **高** | provider.rs 依赖 reqwest、cycle.rs 依赖 ToolRegistry、session.rs 依赖 engine,Step 1 必须添加 cfg 门控 | provider 模块 cfg 改为 provider-xxx 条件;cycle.rs 加 tools 门控;session.rs 加 engine 门控 |
|
||||
@@ -0,0 +1,424 @@
|
||||
# AG Core v0.3.2 Step 3(Phase 26–27)— 验证固化 + 文档更新实施方案
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
**背景**:agcore v0.3.2 Step 1(Phase 20–25)已交付 —— Cargo features 拆分基础设施改造全部完成,所有模块 `#[cfg]` 门控注入完毕,依赖全部 optional 化,`default = ["full"]` 保持向后兼容。当前项目处于已改造完成但未经 CI 固化、无文档指引的状态。
|
||||
|
||||
**当前状态快照**:
|
||||
- v0.3.0 → v0.3.2 Step 1:68 个源文件,23,765 行
|
||||
- 16 个 features(10 模块级 + 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 示例 | 工作 5(Phase 27) |
|
||||
| F6 | example 文件顶部标注所需 features | 用户可一键复制运行命令 | 工作 5(Phase 27) |
|
||||
| F7 | roadmap 状态同步 | 总入口 + v0.3.2 子文档 | 工作 5(Phase 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.0–v0.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 的测试无需真实 provider,mock 即可
|
||||
|
||||
推理不变的 example(simple_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"` 将警告升级为编译错误,确保 `F4(0 编译器警告)`被矩阵中所有 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-1:LlmProvider 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-2:CI 使用 nightly toolchain
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| 问题 | 项目已使用 edition 2024,是否降级到 2021 以使用 stable Rust |
|
||||
| 决策 | 测试和 clippy 使用 nightly(edition 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-3:CI 矩阵使用 `--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 行 | 工作 0(context_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 声明 | 工作 1(context_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` 全部通过 | 逐一验证 | 工作 1(example 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 个 job(6 测试 + 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 | 全矩阵 CI(6 测试 + 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.2(Phase 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 个 Phase,9 个 Step。
|
||||
|
||||
### Phase 28:Feature gate + Wire 类型 + Provider 骨架(~140 行)
|
||||
|
||||
#### Step 28.1:Cargo.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.2:Wire 类型定义
|
||||
**文件操作**:新建 `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.3:Provider 结构体 + 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.4:Factory 注册 + 模块门控
|
||||
**文件操作**:
|
||||
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.1:convert_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.2:convert_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.4:SSE 事件类型 + 状态机(~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.6:LlmProvider 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.1:wiremock 非流式测试(~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.2:wiremock 流式测试(~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.3:CI 矩阵(~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.5:Example(~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
|
||||
↑ 后者覆盖前者
|
||||
```
|
||||
|
||||
### 改动一:GenericOpenaiProvider(openai.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
|
||||
}
|
||||
```
|
||||
|
||||
### 改动二:OpenaiResponseProvider(openai_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`,零影响。
|
||||
|
||||
### 改动三:AnthropicProvider(anthropic.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,375 @@
|
||||
# Phase 0 剩余模块 — 实施方案
|
||||
|
||||
> 定稿日期:2026-06-02
|
||||
|
||||
## 背景与目标
|
||||
|
||||
AG Core Phase 0(Foundation)已完成核心数据类型、错误体系、Provider 接口、OpenAI 实现、生命周期引擎等基础设施。剩余 4 个子项尚未实现:**ProviderRegistry**、**HookExecutor**、**StreamEvents**、**Auto-compaction**。它们均被 Roadmap 标记为 P0(Must Have),是本阶段不可或缺的底层能力。
|
||||
|
||||
**目标**:完成这 4 个模块的设计与实现,使 Phase 0 全面交付。
|
||||
|
||||
---
|
||||
|
||||
## 需求分析
|
||||
|
||||
### 功能需求
|
||||
|
||||
| 模块 | 需求 | 验收条件 |
|
||||
|------|------|---------|
|
||||
| ProviderRegistry | 支持注册命名 Provider、按名称查找、设置默认 | 3 个公开方法 + 工厂辅助 |
|
||||
| HookExecutor | 4 个事件点:PreRequest / PostRequest / OnRetry / OnError | Hook trait + HookExecutor 触发 |
|
||||
| StreamEvents | 流式事件枚举 + Provider 流式方法 + Cycle 流式入口 | 6 种事件类型 + SSE 解析 |
|
||||
| Auto-compaction | Token 估算 + 微压缩 + 断路器 | 触发后释放 token 且不改变语义 |
|
||||
|
||||
### 非功能需求
|
||||
|
||||
- 所有公开 API 必须带 `///` 文档注释
|
||||
- 无新增 `unwrap()` 调用
|
||||
- 与现有 `LlmCycle` 集成时保持向后兼容(全为可选/增量添加)
|
||||
- 错误统一使用 `LlmError` 枚举
|
||||
|
||||
---
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 1. ProviderRegistry (`src/llm/provider/registry.rs`)
|
||||
|
||||
**职责**:管理多个 LLM Provider 实例,支持按名称注册、发现、切换。
|
||||
|
||||
```rust
|
||||
// src/llm/provider/registry.rs
|
||||
|
||||
use std::collections::HashMap;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::{LlmProvider, ProviderConfig, ProviderType, create_provider};
|
||||
|
||||
/// Provider 注册表——管理多个 LLM Provider 实例。
|
||||
pub struct ProviderRegistry {
|
||||
providers: HashMap<String, Box<dyn LlmProvider>>,
|
||||
default_name: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderRegistry {
|
||||
pub fn new() -> Self;
|
||||
|
||||
/// 注册一个已初始化的 Provider 实例。
|
||||
pub fn register(&mut self, name: impl Into<String>, provider: Box<dyn LlmProvider>);
|
||||
|
||||
/// 通过 ProviderType + ProviderConfig 创建并注册。
|
||||
pub fn register_with_config(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
provider_type: ProviderType,
|
||||
config: ProviderConfig,
|
||||
) -> Result<(), LlmError>;
|
||||
|
||||
/// 设置默认 Provider。
|
||||
pub fn set_default(&mut self, name: &str) -> Result<(), LlmError>;
|
||||
|
||||
/// 按名称查找 Provider。
|
||||
pub fn get(&self, name: &str) -> Option<&dyn LlmProvider>;
|
||||
|
||||
/// 获取默认 Provider。
|
||||
pub fn get_default(&self) -> Option<&dyn LlmProvider>;
|
||||
}
|
||||
```
|
||||
|
||||
**无新增依赖**。
|
||||
|
||||
---
|
||||
|
||||
### 2. HookExecutor (`src/llm/hooks.rs`)
|
||||
|
||||
**职责**:在 LLM 调用生命周期的关键节点插入自定义逻辑。
|
||||
|
||||
```rust
|
||||
// src/llm/hooks.rs
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::types::ChatRequest;
|
||||
|
||||
/// 生命周期钩子事件点。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HookEvent {
|
||||
/// LLM 请求发起之前(可阻断)。
|
||||
PreRequest,
|
||||
/// 成功响应之后。
|
||||
PostRequest,
|
||||
/// 重试之前(仅可重试错误时触发)。
|
||||
OnRetry,
|
||||
/// 不可恢复错误返回之前。
|
||||
OnError,
|
||||
}
|
||||
|
||||
/// 此次钩子调用的上下文。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HookContext<'a> {
|
||||
pub event: HookEvent,
|
||||
pub request: Option<&'a ChatRequest>,
|
||||
pub error: Option<&'a LlmError>,
|
||||
pub attempt: u32,
|
||||
}
|
||||
|
||||
/// 钩子执行结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HookResult {
|
||||
/// 是否阻断后续操作(仅 PreRequest 有效)。
|
||||
pub should_block: bool,
|
||||
/// 阻断/备注原因。
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
/// 生命周期钩子 trait。
|
||||
#[async_trait]
|
||||
pub trait Hook: Send + Sync {
|
||||
async fn execute(&self, ctx: &HookContext<'_>) -> HookResult;
|
||||
}
|
||||
|
||||
/// 钩子执行器——管理注册与触发。
|
||||
pub struct HookExecutor {
|
||||
hooks: Vec<(HookEvent, Box<dyn Hook>)>,
|
||||
}
|
||||
|
||||
impl HookExecutor {
|
||||
pub fn new() -> Self;
|
||||
pub fn register(&mut self, event: HookEvent, hook: Box<dyn Hook>);
|
||||
pub async fn execute(&self, event: HookEvent, ctx: &HookContext<'_>) -> Vec<HookResult>;
|
||||
}
|
||||
```
|
||||
|
||||
**与 LlmCycle 集成**:
|
||||
- `LlmCycle` 新增字段 `hook_executor: Option<HookExecutor>`
|
||||
- 新增 builder 方法 `with_hook_executor()`
|
||||
- `submit()` 中在 4 个点触发(PreRequest 若阻断则提前返回)
|
||||
|
||||
**无新增依赖**(async-trait 已存在)。
|
||||
|
||||
---
|
||||
|
||||
### 3. StreamEvents (`src/llm/stream.rs`)
|
||||
|
||||
**职责**:提供流式 LLM 调用的事件抽象,将原始 SSE chunk 解析为语义化事件。
|
||||
|
||||
```rust
|
||||
// src/llm/stream.rs
|
||||
|
||||
use std::pin::Pin;
|
||||
use tokio_stream::Stream;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::types::{FinishReason, Usage, OpenaiChatChunk, ToolDefinition};
|
||||
use serde_json::Value;
|
||||
|
||||
/// 流式事件——LLM 调用全生命周期的语义化事件。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StreamEvent {
|
||||
/// 助手回复文本增量。
|
||||
AssistantTextDelta { text: String },
|
||||
/// 工具调用开始。
|
||||
ToolExecutionStarted { tool_name: String, input: Value },
|
||||
/// 工具调用完成。
|
||||
ToolExecutionCompleted { tool_name: String, output: Value, is_error: bool },
|
||||
/// Token 用量更新。
|
||||
CostUpdate { usage: Usage },
|
||||
/// 一轮会话完成。
|
||||
TurnComplete { reason: FinishReason },
|
||||
/// 可恢复的错误事件。
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
/// 将原始 OpenaiChatChunk 流解析为 StreamEvent 流。
|
||||
pub fn parse_chunk_stream(
|
||||
chunks: Pin<Box<dyn Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
|
||||
) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>>;
|
||||
```
|
||||
|
||||
**Provider 层扩展**(`src/llm/provider.rs`):
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, LlmError>;
|
||||
|
||||
/// 流式聊天请求——返回原始 SSE chunk 流。
|
||||
/// 默认实现回退到非流式调用。
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: ChatRequest,
|
||||
) -> Result<
|
||||
Pin<Box<dyn Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
|
||||
LlmError,
|
||||
> {
|
||||
let response = self.chat(request).await?;
|
||||
let chunk = OpenaiChatChunk::from(response);
|
||||
Ok(Box::pin(tokio_stream::once(Ok(chunk))))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**LlmCycle 扩展**:
|
||||
```rust
|
||||
impl LlmCycle {
|
||||
/// 提交请求并返回语义事件流。
|
||||
pub async fn submit_stream(
|
||||
&mut self,
|
||||
prompt: String,
|
||||
tools: Vec<ToolDefinition>,
|
||||
) -> Result<
|
||||
Pin<Box<dyn Stream<Item = StreamEvent> + Send>>,
|
||||
LlmError,
|
||||
>;
|
||||
}
|
||||
```
|
||||
|
||||
**新增依赖**:
|
||||
```toml
|
||||
tokio-stream = "0.1"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Auto-compaction (`src/llm/compact.rs`)
|
||||
|
||||
**职责**:在上下文过长时自动压缩历史消息,避免 ContextLength 错误。
|
||||
|
||||
```rust
|
||||
// src/llm/compact.rs
|
||||
|
||||
use crate::llm::types::{ContentField, Message, OpenaiChatMessage, OpenaiContentPart};
|
||||
|
||||
// === 常量 ===
|
||||
const AUTOCOMPACT_BUFFER_TOKENS: u32 = 13_000;
|
||||
const RESERVED_OUTPUT_TOKENS: u32 = 20_000;
|
||||
const MAX_CONSECUTIVE_FAILURES: u32 = 3;
|
||||
const KEEP_RECENT: usize = 6;
|
||||
|
||||
/// 上下文压缩配置。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompactConfig {
|
||||
/// 模型上下文窗口大小(token 数)。
|
||||
pub context_window: u32,
|
||||
/// 为输出预留的 token 数。
|
||||
pub reserved_tokens: u32,
|
||||
/// 微压缩保留的最近消息数。
|
||||
pub keep_recent: usize,
|
||||
}
|
||||
|
||||
impl Default for CompactConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
context_window: 128_000,
|
||||
reserved_tokens: RESERVED_OUTPUT_TOKENS,
|
||||
keep_recent: KEEP_RECENT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CompactConfig {
|
||||
pub fn threshold(&self) -> u32 {
|
||||
self.context_window
|
||||
.saturating_sub(self.reserved_tokens)
|
||||
.saturating_sub(AUTOCOMPACT_BUFFER_TOKENS)
|
||||
}
|
||||
}
|
||||
|
||||
/// 压缩状态——跟踪连续失败次数(断路器模式)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompactState {
|
||||
consecutive_failures: u32,
|
||||
}
|
||||
|
||||
impl CompactState {
|
||||
pub fn new() -> Self;
|
||||
pub fn record_success(&mut self);
|
||||
/// 记录失败,返回 true 表示已达断路器上限。
|
||||
pub fn record_failure(&mut self) -> bool;
|
||||
}
|
||||
|
||||
/// 粗略估计消息列表的 token 数(基于字符数,4 字符 ≈ 1 token)。
|
||||
pub fn estimate_message_tokens(messages: &[Message]) -> u32;
|
||||
|
||||
/// 判断是否需要触发自动压缩。
|
||||
pub fn should_compact(messages: &[Message], config: &CompactConfig, state: &CompactState) -> bool;
|
||||
|
||||
/// 执行微压缩——用占位符替换旧的 tool result 内容。
|
||||
/// 返回释放的 token 数。
|
||||
pub fn microcompact(messages: &mut Vec<Message>, keep_recent: usize) -> u32;
|
||||
```
|
||||
|
||||
**与 LlmCycle 集成**:
|
||||
- `LlmCycle` 新增字段 `compact_config: Option<CompactConfig>`, `compact_state: CompactState`
|
||||
- 新增 builder 方法 `with_compact_config()`
|
||||
- `submit()` 开始时调用 `should_compact()` → `microcompact()`
|
||||
|
||||
**完整 LLM 摘要压缩**留占位,Phase 2 实现(需要循环内调用 LLM 的能力)。
|
||||
|
||||
**无新增依赖**。
|
||||
|
||||
---
|
||||
|
||||
## 实现计划
|
||||
|
||||
### Step 1: 先写方案文档
|
||||
|
||||
创建 `docs/3-phase0-remaining.md`(即本文档)。
|
||||
|
||||
### Step 2: ProviderRegistry
|
||||
|
||||
- 创建 `src/llm/provider/registry.rs`
|
||||
- `provider.rs` 添加 `pub mod registry;`
|
||||
- `cargo check` 验证
|
||||
|
||||
### Step 3: HookExecutor
|
||||
|
||||
- 创建 `src/llm/hooks.rs`
|
||||
- `llm.rs` 添加 `pub mod hooks;`
|
||||
- `LlmCycle` 新增字段和方法
|
||||
- `submit()` 中插入钩子触发点
|
||||
- `cargo check` 验证
|
||||
|
||||
### Step 4: StreamEvents
|
||||
|
||||
- `Cargo.toml` 添加 `tokio-stream`
|
||||
- 创建 `src/llm/stream.rs`
|
||||
- `llm.rs` 添加 `pub mod stream;`
|
||||
- `LlmProvider` 添加 `chat_stream()`(默认回退)
|
||||
- `OpenaiProvider` 实现 SSE 解析
|
||||
- `LlmCycle` 添加 `submit_stream()`
|
||||
- `cargo check` 验证
|
||||
|
||||
### Step 5: Auto-compaction
|
||||
|
||||
- 创建 `src/llm/compact.rs`
|
||||
- `llm.rs` 添加 `pub mod compact;`
|
||||
- `LlmCycle` 新增字段和方法
|
||||
- `submit()` 开头插入压缩检查
|
||||
- `cargo check` 验证
|
||||
|
||||
### Step 6: 收尾
|
||||
|
||||
- `cargo clippy` — 无警告
|
||||
- `cargo build --release` — 完整构建
|
||||
- 检查所有新公开 API 有 `///` 注释
|
||||
|
||||
---
|
||||
|
||||
## 风险评估
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| SSE 解析边界情况 | 中 | 中 | 参考 `reqwest` 的 chunked 响应处理;先用简单的 `lines()` 方式逐行读取 |
|
||||
| token 估算不准 | 中 | 低 | 仅用于触发微压缩的阈值判断,保守估算即可;后续可接入 tiktoken-rs |
|
||||
| 钩子阻断语义复杂 | 低 | 中 | PreRequest 阻断后返回明确错误消息;其他事件点只读不阻断 |
|
||||
| 与后续 Phase 冲突 | 低 | 高 | 保持接口向后兼容,全用可选集成(Option) |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. `cargo check` 编译通过
|
||||
2. `cargo clippy` 无警告
|
||||
3. 4 个模块文件存在且路径正确
|
||||
4. `ProviderRegistry` 支持注册/查找/默认 Provider
|
||||
5. `HookExecutor` 支持 4 个事件点注册与触发
|
||||
6. `StreamEvents` 支持从 SSE chunk 解析为语义事件
|
||||
7. `Auto-compaction` 支持 token 估算与微压缩
|
||||
8. `LlmCycle` 向后兼容(新增字段全为 Option,不影响现有代码)
|
||||
@@ -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,611 @@
|
||||
# Phase 1: Prompt Engineering — 方案设计
|
||||
|
||||
> 定稿日期:2026-06-02
|
||||
|
||||
## 背景与目标
|
||||
|
||||
AG Core Phase 0(Foundation)已完成 LLM 调用周期的全部基础设施。Phase 1 的目标是补齐**提示词工程**能力,提供提示词的组合、模板化与优化能力,使其能直接服务于 Phase 2(工具系统)和 Phase 4(Agent 运行时)。
|
||||
|
||||
**目标**:实现 `PromptTemplate`(模板引擎)和 `PromptComposer`(提示词组合器)两个核心组件,覆盖变量插值、条件渲染、多消息序列拼接等场景。
|
||||
|
||||
---
|
||||
|
||||
## 需求分析
|
||||
|
||||
### 功能需求
|
||||
|
||||
| 模块 | 需求 | 验收条件 |
|
||||
|------|------|---------|
|
||||
| `PromptTemplate` | 支持变量插值 `{{ var }}` | 渲染后正确替换所有变量 |
|
||||
| `PromptTemplate` | 支持条件渲染 `{{#if var}}...{{/if}}` | 变量非空/非 false 时渲染块 |
|
||||
| `PromptTemplate` | 支持列表循环 `{{#each list}}...{{/each}}` | 遍历渲染集合元素 |
|
||||
| `PromptTemplate` | 支持嵌套模板引用 `{{> template_name }}` | 引用已注册的模板片段 |
|
||||
| `PromptTemplate` | 支持部分转义(原样输出 `{{ literal }}`) | 提供 raw block 语法 |
|
||||
| `PromptComposer` | 按角色拼接 system/user/assistant 消息序列 | 输出 `Vec<OpenaiChatMessage>` |
|
||||
| `PromptComposer` | 支持插入预编译的 PromptTemplate | 组合器中混合使用静态文本和模板 |
|
||||
| `PromptComposer` | 支持从已有消息列表扩展 | 接收 `Vec<OpenaiChatMessage>` 作为初始状态 |
|
||||
| `PromptComposer` | 支持多模态 ContentPart 构建(图片/音频/文件) | `user_content()`/`system_content()` 等接受 `OpenaiContentPart` |
|
||||
| `PromptComposer` | 支持 Developer 角色(o1 系列模型) | `developer()` / `developer_template()` / `developer_content()` |
|
||||
| `PromptComposer` | 支持 Tool 角色(工具执行结果回传) | `tool()` / `tool_content()` 接受 `tool_call_id` |
|
||||
| `PromptComposer` | 支持 `name` 字段设置(多角色区分) | `with_name()` 链式方法为上一消息设置名称 |
|
||||
| `PromptComposer` | 支持消息序列合法性验证 | `validate_messages()` 检查顺序约束与角色交替 |
|
||||
| `PromptTemplateRegistry` | 模板注册表:按名称注册、查找、文件加载 | 从字符串/文件注册模板,按名称渲染 |
|
||||
| `PromptTemplateRegistry` | 支持延迟编译模式 | `register_lazy()` 存储原始字符串,首次渲染时编译 |
|
||||
| `PromptTemplate` | 实现 `Display` trait | 输出原始模板字符串(用于日志和调试) |
|
||||
| `TemplateContext` | 支持从 JSON 构造 | `from_json()` 递归转换 `serde_json::Value` 为 `TemplateValue` |
|
||||
|
||||
### 非功能需求
|
||||
|
||||
- 所有公开 API 必须带 `///` 文档注释
|
||||
- 无新增 `unwrap()` 调用
|
||||
- **零运行时依赖**(不使用 tera、askama 等模板引擎 crate)
|
||||
- 模板引擎失败时返回结构化错误(`PromptError`)
|
||||
- 与现有 `OpenaiChatMessage` / `ChatRequest` 类型自然集成
|
||||
|
||||
---
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 模块结构
|
||||
|
||||
```
|
||||
src/
|
||||
prompt.rs # prompt 模块根:声明子模块 + 重导出公共 API
|
||||
prompt/
|
||||
template.rs # PromptTemplate — 模板引擎
|
||||
template/
|
||||
registry.rs # PromptTemplateRegistry — 模板注册表
|
||||
composer.rs # PromptComposer — 提示词组合器
|
||||
error.rs # PromptError — 错误类型
|
||||
```
|
||||
|
||||
`prompt.rs` 根模块声明:
|
||||
|
||||
```rust
|
||||
// prompt.rs
|
||||
pub mod composer;
|
||||
pub mod error;
|
||||
pub mod template;
|
||||
|
||||
pub use composer::PromptComposer;
|
||||
pub use error::PromptError;
|
||||
pub use template::{PromptTemplate, PromptTemplateRegistry};
|
||||
```
|
||||
|
||||
`lib.rs` 添加:
|
||||
|
||||
```diff
|
||||
pub mod llm;
|
||||
+pub mod prompt;
|
||||
```
|
||||
|
||||
### 1. 模板引擎选择:自建轻量
|
||||
|
||||
**决策**:不使用 `tera` / `askama` / `maud` / `minijinja` 等第三方模板 crate。
|
||||
|
||||
**理由**:
|
||||
- Phase 1 模板需求极其简单(变量插值 + 条件 + 列表循环),不需要 Jinja2/Handlebars 全能力
|
||||
- 无依赖 = 编译更快、无安全面、版本冲突为 0
|
||||
- 自建 50-80 行核心逻辑即可覆盖所有需求
|
||||
- Roadmap 估算 400 行总代码,60 行模板引擎足够
|
||||
|
||||
**语法设计**(参考 Handlebars 子集):
|
||||
|
||||
```
|
||||
{{ variable_name }} → 变量插值
|
||||
{{#if var}}...{{/if}} → 条件渲染(var 存在且非空)
|
||||
{{#if var}}...{{else}}...{{/if}} → 条件 + 否则
|
||||
{{#each list}} {{item}} {{/each}} → 列表循环
|
||||
{{#raw}} {{literal}} {{/raw}} → 原始块(不解析内部模板语法)
|
||||
{{> template_name}} → 引用已注册的嵌套模板
|
||||
```
|
||||
|
||||
### 2. PromptTemplate — 模板引擎
|
||||
|
||||
```rust
|
||||
// prompt/template.rs
|
||||
|
||||
use std::collections::HashMap;
|
||||
use crate::prompt::error::PromptError;
|
||||
|
||||
/// 渲染上下文中使用的值类型。
|
||||
pub enum TemplateValue {
|
||||
String(String),
|
||||
Bool(bool),
|
||||
Array(Vec<TemplateValue>),
|
||||
Object(HashMap<String, TemplateValue>),
|
||||
}
|
||||
|
||||
/// `TemplateValue` 自动转换(提升 `ctx.insert("name", "Alice")` 的易用性)。
|
||||
impl From<String> for TemplateValue { ... }
|
||||
impl From<&str> for TemplateValue { ... }
|
||||
impl From<bool> for TemplateValue { ... }
|
||||
|
||||
/// 模板变量上下文。
|
||||
pub struct TemplateContext {
|
||||
vars: HashMap<String, TemplateValue>,
|
||||
}
|
||||
|
||||
impl TemplateContext {
|
||||
pub fn new() -> Self;
|
||||
|
||||
/// 插入变量(支持 `&str` / `String` / `bool` 自动转换)。
|
||||
pub fn insert(&mut self, key: impl Into<String>, value: impl Into<TemplateValue>);
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&TemplateValue>;
|
||||
|
||||
/// 从 `serde_json::Value` 递归构造(支持嵌套 Object/Array)。
|
||||
pub fn from_json(value: &serde_json::Value) -> Result<Self, PromptError>;
|
||||
|
||||
/// 从 `HashMap` 构造(适用于配置加载场景)。
|
||||
pub fn from_map(map: HashMap<String, TemplateValue>) -> Self;
|
||||
}
|
||||
|
||||
/// 预编译的模板。
|
||||
pub struct PromptTemplate {
|
||||
/// 原始模板字符串(用于 debug)。
|
||||
raw: String,
|
||||
/// 编译后的 AST 片段。
|
||||
fragments: Vec<Fragment>,
|
||||
}
|
||||
|
||||
/// 编译后的 AST 节点(内部枚举)。
|
||||
enum Fragment {
|
||||
Literal(String),
|
||||
Variable { name: String },
|
||||
If { condition: String, body: Vec<Fragment>, else_body: Vec<Fragment> },
|
||||
Each { variable: String, body: Vec<Fragment> },
|
||||
Raw(String),
|
||||
Include(String),
|
||||
}
|
||||
|
||||
impl PromptTemplate {
|
||||
/// 从模板字符串编译。
|
||||
pub fn compile(template: &str) -> Result<Self, PromptError>;
|
||||
|
||||
/// 使用上下文渲染。
|
||||
pub fn render(&self, ctx: &TemplateContext) -> Result<String, PromptError>;
|
||||
|
||||
/// 注册可引用的子模板。
|
||||
pub fn register_partial(&mut self, name: &str, template: PromptTemplate);
|
||||
}
|
||||
|
||||
/// `Display` 输出原始模板字符串,便于日志和调试。
|
||||
impl fmt::Display for PromptTemplate {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.raw)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**编译流程**:
|
||||
1. 逐字符扫描模板字符串
|
||||
2. 遇到 `{{` 时解析指令类型(variable/if/each/raw/include)
|
||||
3. 生成 `Fragment` AST 列表
|
||||
4. 返回 `PromptTemplate { raw, fragments }`
|
||||
|
||||
**渲染流程**:
|
||||
1. 遍历 `fragments`
|
||||
2. `Literal` → 直接追加
|
||||
3. `Variable { name }` → `ctx.get(name)` → 追加字符串值
|
||||
4. `If { condition, body, else_body }` → 判断 `ctx.get(condition)` 是否存在且真 → 递归渲染 body/else_body
|
||||
5. `Each { variable, body }` → `ctx.get(variable)` 转为数组 → 为每个元素设置 `item` 变量 → 递归渲染 body
|
||||
6. `Raw(text)` → 原样追加(不解析 `{{ }}`)
|
||||
7. `Include(name)` → 查找已注册的 partials → 递归渲染
|
||||
|
||||
### 3. PromptTemplateRegistry — 模板注册表
|
||||
|
||||
提供轻量的模板管理能力,按名称注册、查找、从文件加载,适用于管理多个 Agent 的提示词模板。
|
||||
|
||||
```rust
|
||||
// prompt/template/registry.rs
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use crate::prompt::error::PromptError;
|
||||
use crate::prompt::template::{PromptTemplate, TemplateContext};
|
||||
|
||||
/// 内部存储的模板(支持延迟编译)。
|
||||
enum StoredTemplate {
|
||||
Compiled(PromptTemplate),
|
||||
Raw(String),
|
||||
}
|
||||
|
||||
/// 模板注册表——管理多模板实例。
|
||||
pub struct PromptTemplateRegistry {
|
||||
templates: HashMap<String, StoredTemplate>,
|
||||
}
|
||||
|
||||
impl PromptTemplateRegistry {
|
||||
pub fn new() -> Self;
|
||||
|
||||
/// 从模板字符串编译并注册(立即编译)。
|
||||
pub fn register(&mut self, name: &str, template: &str) -> Result<(), PromptError>;
|
||||
|
||||
/// 延迟编译注册:只存储原始字符串,首次渲染时编译。
|
||||
/// 适合模板数量多但并非全部立即使用的场景。
|
||||
pub fn register_lazy(&mut self, name: &str, template: &str);
|
||||
|
||||
/// 从文件读取并编译注册。
|
||||
pub fn register_file(&mut self, name: &str, path: &Path) -> Result<(), PromptError>;
|
||||
|
||||
/// 获取已注册的模板(延迟编译的模板在此首次编译)。
|
||||
pub fn get(&mut self, name: &str) -> Result<&PromptTemplate, PromptError>;
|
||||
|
||||
/// 按名称渲染(`{{> name }}` 引用时自动查找)。
|
||||
pub fn render(&mut self, name: &str, ctx: &TemplateContext) -> Result<String, PromptError>;
|
||||
}
|
||||
```
|
||||
|
||||
**设计约束**:
|
||||
- 不设全局单例,用户自行创建和持有
|
||||
- 不引入文件系统监听、热加载、序列化等复杂能力
|
||||
- 注册表内部模板可用于解析 `{{> partial_name }}` 子模板引用
|
||||
- 用户仍可单独持有 `PromptTemplate` 实例,不强制使用注册表
|
||||
|
||||
### 4. PromptComposer — 提示词组合器
|
||||
|
||||
```rust
|
||||
// prompt/composer.rs
|
||||
|
||||
use crate::llm::types::message::{OpenaiChatMessage, OpenaiContentPart, ContentField};
|
||||
use crate::prompt::error::PromptError;
|
||||
use crate::prompt::template::{PromptTemplate, TemplateContext};
|
||||
|
||||
/// 提示词组合器——构建多角色消息序列。
|
||||
pub struct PromptComposer {
|
||||
messages: Vec<OpenaiChatMessage>,
|
||||
pending_name: Option<String>,
|
||||
}
|
||||
|
||||
impl PromptComposer {
|
||||
/// 创建一个空的组合器。
|
||||
pub fn new() -> Self;
|
||||
|
||||
/// 从已有的消息列表初始化。
|
||||
pub fn from_messages(messages: Vec<OpenaiChatMessage>) -> Self;
|
||||
|
||||
// ===== 纯文本消息 =====
|
||||
|
||||
/// 添加一条纯文本 system 消息。
|
||||
pub fn system(mut self, text: impl Into<String>) -> Self;
|
||||
|
||||
/// 添加一条纯文本 user 消息。
|
||||
pub fn user(mut self, text: impl Into<String>) -> Self;
|
||||
|
||||
/// 添加一条纯文本 assistant 消息。
|
||||
pub fn assistant(mut self, text: impl Into<String>) -> Self;
|
||||
|
||||
/// 添加一条纯文本 developer 消息(o1 系列模型使用)。
|
||||
pub fn developer(mut self, text: impl Into<String>) -> Self;
|
||||
|
||||
/// 添加一条 Tool 消息(工具执行结果回传)。
|
||||
pub fn tool(mut self, tool_call_id: impl Into<String>, content: impl Into<String>) -> Self;
|
||||
|
||||
// ===== 模板消息 =====
|
||||
|
||||
/// 使用模板和上下文渲染后添加为 user 消息。
|
||||
pub fn user_template(
|
||||
mut self,
|
||||
template: &PromptTemplate,
|
||||
ctx: &TemplateContext,
|
||||
) -> Result<Self, PromptError>;
|
||||
|
||||
/// 使用模板和上下文渲染后添加为 system 消息。
|
||||
pub fn system_template(
|
||||
mut self,
|
||||
template: &PromptTemplate,
|
||||
ctx: &TemplateContext,
|
||||
) -> Result<Self, PromptError>;
|
||||
|
||||
/// 使用模板和上下文渲染后添加为 assistant 消息。
|
||||
pub fn assistant_template(
|
||||
mut self,
|
||||
template: &PromptTemplate,
|
||||
ctx: &TemplateContext,
|
||||
) -> Result<Self, PromptError>;
|
||||
|
||||
/// 使用模板和上下文渲染后添加为 developer 消息。
|
||||
pub fn developer_template(
|
||||
mut self,
|
||||
template: &PromptTemplate,
|
||||
ctx: &TemplateContext,
|
||||
) -> Result<Self, PromptError>;
|
||||
|
||||
// ===== 多模态 ContentPart =====
|
||||
|
||||
/// 添加一条含指定 ContentPart 的 system 消息。
|
||||
pub fn system_content(mut self, part: OpenaiContentPart) -> Self;
|
||||
|
||||
/// 添加一条含指定 ContentPart 的 user 消息。
|
||||
pub fn user_content(mut self, part: OpenaiContentPart) -> Self;
|
||||
|
||||
/// 添加一条含指定 ContentPart 的 assistant 消息。
|
||||
pub fn assistant_content(mut self, part: OpenaiContentPart) -> Self;
|
||||
|
||||
/// 添加一条含指定 ContentPart 的 developer 消息。
|
||||
pub fn developer_content(mut self, part: OpenaiContentPart) -> Self;
|
||||
|
||||
/// 添加一条含指定 ContentPart 的 Tool 消息。
|
||||
pub fn tool_content(mut self, tool_call_id: impl Into<String>, part: OpenaiContentPart) -> Self;
|
||||
|
||||
/// 批量添加 ContentPart 作为 user 消息。
|
||||
pub fn user_contents(mut self, parts: Vec<OpenaiContentPart>) -> Self;
|
||||
|
||||
/// 批量添加 ContentPart 作为 system 消息。
|
||||
pub fn system_contents(mut self, parts: Vec<OpenaiContentPart>) -> Self;
|
||||
|
||||
/// 批量添加 ContentPart 作为 assistant 消息。
|
||||
pub fn assistant_contents(mut self, parts: Vec<OpenaiContentPart>) -> Self;
|
||||
|
||||
/// 批量添加 ContentPart 作为 developer 消息。
|
||||
pub fn developer_contents(mut self, parts: Vec<OpenaiContentPart>) -> Self;
|
||||
|
||||
// ===== 角色标识 =====
|
||||
|
||||
/// 为上一条添加的消息设置 `name` 字段(多 agent 系统中区分同角色实体)。
|
||||
pub fn with_name(mut self, name: impl Into<String>) -> Self;
|
||||
|
||||
// ===== 构建 =====
|
||||
|
||||
/// 构建最终的消息列表。
|
||||
pub fn build(self) -> Vec<OpenaiChatMessage>;
|
||||
|
||||
/// 构建并直接创建 ChatRequest(需搭配 model 参数)。
|
||||
/// 返回的 `OpenaiChatRequest` 中 `tools`、`temperature`、`max_tokens` 等字段均为 `None`,
|
||||
/// 可通过结构体更新语法补全:`OpenaiChatRequest { tools: Some(...), ..req }`。
|
||||
pub fn build_request(
|
||||
self,
|
||||
model: impl Into<String>,
|
||||
) -> crate::llm::types::request::OpenaiChatRequest;
|
||||
}
|
||||
|
||||
/// 验证消息序列是否符合 OpenAI API 要求。
|
||||
/// 检查项:Tool 消息必须紧跟在匹配的 Assistant 消息后、角色交替规则等。
|
||||
pub fn validate_messages(messages: &[OpenaiChatMessage]) -> Result<(), PromptError>;
|
||||
```
|
||||
|
||||
**Builder 模式设计**:
|
||||
- `PromptComposer` 采用链式调用(builder pattern),与 Rust 生态的主流风格一致
|
||||
- 每个 `system()` / `user()` / `assistant()` / `developer()` / `tool()` 方法返回 `Self`,支持连续调用
|
||||
- `with_name()` 作用于上一条消息,内部通过 `pending_name: Option<String>` 暂存,push 消息时消费
|
||||
- `build()` 返回 `Vec<OpenaiChatMessage>`,`build_request()` 创建完整的 `OpenaiChatRequest`
|
||||
- **`ContentField` 类型约定**:所有纯文本消息(`system()` / `user()` / `assistant()` / `developer()`)统一使用 `ContentField::Array(vec![OpenaiContentPart::Text{...}])`,与 OpenAI API 非流式响应的标准格式一致
|
||||
|
||||
**`validate_messages()` 校验规则**:
|
||||
1. `Tool` 角色的消息必须跟在 `Assistant` 角色且含 `tool_calls` 的消息之后
|
||||
2. 禁止连续出现两条同角色的非 Tool 消息(system 除外)
|
||||
3. 消息列表不能为空
|
||||
|
||||
### 5. PromptError — 错误类型
|
||||
|
||||
```rust
|
||||
// prompt/error.rs
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum PromptError {
|
||||
#[error("模板解析错误: {0}")]
|
||||
Parse(String),
|
||||
|
||||
#[error("渲染错误: 变量 '{0}' 未找到")]
|
||||
VariableNotFound(String),
|
||||
|
||||
#[error("渲染错误: 引用的子模板 '{0}' 未注册")]
|
||||
PartialNotFound(String),
|
||||
|
||||
#[error("渲染错误: '{0}' 不是数组,无法遍历")]
|
||||
NotAnArray(String),
|
||||
|
||||
#[error("渲染递归超过最大深度限制 ({0})")]
|
||||
MaxDepthReached(u8),
|
||||
|
||||
#[error("渲染错误: {0}")]
|
||||
Render(String),
|
||||
|
||||
#[error("消息序列校验失败: {0}")]
|
||||
InvalidSequence(String),
|
||||
|
||||
#[error("文件读取错误: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 使用示例
|
||||
|
||||
```rust
|
||||
use agcore::prompt::{PromptComposer, PromptTemplate, TemplateContext};
|
||||
|
||||
// 编译模板
|
||||
let tpl = PromptTemplate::compile(
|
||||
"你是{{role}}。请回答以下问题:\n{{#if context}}参考背景:{{context}}\n{{/if}}提问:{{question}}"
|
||||
)?;
|
||||
|
||||
// 构建上下文
|
||||
let mut ctx = TemplateContext::new();
|
||||
ctx.insert("role", "资深工程师");
|
||||
ctx.insert("question", "Rust 的所有权规则是什么?");
|
||||
ctx.insert("context", "用户有 Java 背景");
|
||||
|
||||
// 使用组合器构建消息序列
|
||||
let messages = PromptComposer::new()
|
||||
.system("你是一个专业的 Rust 助手")
|
||||
.user_template(&tpl, &ctx)?
|
||||
.build();
|
||||
|
||||
// 可选:直接构建 ChatRequest
|
||||
let request = PromptComposer::new()
|
||||
.system("你是一个翻译助手")
|
||||
.user("Hello, world!")
|
||||
.build_request("gpt-4o");
|
||||
```
|
||||
|
||||
### 7. 与 LlmCycle 的集成
|
||||
|
||||
`PromptComposer::build()` 输出 `Vec<OpenaiChatMessage>`,但 **`LlmCycle.messages` 是私有字段**,无法直接赋值。因此**需要对 `LlmCycle` 进行扩展**,提供消息注入入口,使 Composer 能和 `LlmCycle` 的多轮对话循环协同工作。
|
||||
|
||||
**方案**:在 `LlmCycle` 上新增 3 个方法:
|
||||
|
||||
```rust
|
||||
// llm/cycle.rs
|
||||
|
||||
impl LlmCycle {
|
||||
/// 直接设置消息历史(覆盖已有消息),支持 Builder 链式调用。
|
||||
pub fn with_messages(mut self, messages: Vec<Message>) -> Self;
|
||||
|
||||
/// 追加消息到历史尾部。
|
||||
pub fn extend_messages(&mut self, messages: Vec<Message>);
|
||||
|
||||
/// 使用预构建消息提交(跳过自动 push user prompt)。
|
||||
/// 与 submit() 不同,不自动添加 user_text(prompt)。
|
||||
pub async fn submit_messages(
|
||||
&mut self,
|
||||
messages: Vec<Message>,
|
||||
tools: Vec<ToolDefinition>,
|
||||
) -> Result<ChatResponse, LlmError>;
|
||||
}
|
||||
```
|
||||
|
||||
**`submit_messages()` 与 `submit()` 的区别**:
|
||||
|
||||
| 维度 | `submit()` | `submit_messages()` |
|
||||
|------|-----------|-------------------|
|
||||
| 输入 | `prompt: String` + `tools` | `messages: Vec<Message>` + `tools` |
|
||||
| 内部操作 | 自动 push `user_text(prompt)` | 不自动添加任何消息 |
|
||||
| 适用场景 | 简单单轮对话 | 多轮/预构建消息序列 |
|
||||
| system_prompt 处理 | 自动插入(如果无 System 消息) | 完全由调用方控制 |
|
||||
|
||||
**`system_prompt` 冲突处理**:`submit_messages()` 关闭 `LlmCycle` 的自动 system prompt 插入逻辑,避免与 `PromptComposer` 已构建的 System/Developer 消息重复。由调用方全权控制消息序列内容。
|
||||
|
||||
**使用示例**:
|
||||
```rust
|
||||
let messages = PromptComposer::new()
|
||||
.system("你是一个专业的 Rust 助手")
|
||||
.user_template(&query_tpl, &ctx)?
|
||||
.build();
|
||||
|
||||
let mut cycle = LlmCycle::new(provider, config)
|
||||
.with_messages(messages);
|
||||
|
||||
let resp = cycle.submit_messages(vec![], tools).await?;
|
||||
```
|
||||
|
||||
`PromptComposer::build_request()` 直接创建 `OpenaiChatRequest`,可用于绕过 `LlmCycle` 直接调用 `LlmProvider` 的场景。
|
||||
|
||||
**注意**:`PromptComposer` 模块 **不** 直接依赖 `LlmCycle`(避免 `prompt → cycle` 的强耦合)。集成方法全部在 `LlmCycle` 侧实现,保持单一职责。
|
||||
|
||||
---
|
||||
|
||||
## 实现计划
|
||||
|
||||
### Step 1: 创建方案文档
|
||||
|
||||
创建 `docs/4-prompt-engineering.md`(即本文档)。
|
||||
|
||||
### Step 2: PromptError
|
||||
|
||||
- 创建 `src/prompt/error.rs`
|
||||
- 定义 `PromptError` 枚举(Parse / Render / VariableNotFound / PartialNotFound)
|
||||
|
||||
### Step 3: PromptTemplate
|
||||
|
||||
- 创建 `src/prompt.rs`(模块根)
|
||||
- 创建 `src/prompt/template.rs`
|
||||
- 实现编译(`compile()`):逐字符扫描 → 生成 `Vec<Fragment>`
|
||||
- 注意处理:嵌套 `#if` 栈匹配、`{{` 字面量转义、未闭合标签检测
|
||||
- 实现渲染(`render()`):递归遍历 Fragment → 拼接字符串
|
||||
- 定义非字符串值渲染格式:`Bool`→`"true"`/`"false"`、`Array`→JSON、`Object`→JSON
|
||||
- 递归加深度限制(16 层)防止循环引用
|
||||
- 支持功能:变量插值、条件渲染、列表循环、原始块、子模板引用
|
||||
- 实现 `Display for PromptTemplate`(输出原始模板字符串)
|
||||
- 编写 20+ 边界测试覆盖:嵌套 if/each、未闭合标签、空变量、空数组 each、递归深度超限
|
||||
- 运行 `cargo test + cargo check` 验证
|
||||
|
||||
### Step 4: PromptTemplateRegistry
|
||||
|
||||
- 推荐选项:`template` 保持单文件,`PromptTemplateRegistry` 合并同文件(~40 行不值得单独目录)
|
||||
- 内部存储使用 `StoredTemplate` 枚举(支持 `Compiled` 和 `Raw` 两种状态)
|
||||
- 实现:`register()` 立即编译、`register_lazy()` 延迟编译、`register_file()` 文件加载
|
||||
- 实现 `get()` / `render()`(延迟编译的模板首次渲染时编译)
|
||||
- 运行 `cargo check` 验证
|
||||
|
||||
### Step 5: PromptComposer
|
||||
|
||||
- 创建 `src/prompt/composer.rs`
|
||||
- 实现 Builder 链式 API,内部维护 `messages: Vec<OpenaiChatMessage>` + `pending_name: Option<String>`
|
||||
- 角色方法:`system()` / `user()` / `assistant()` / `developer()` / `tool()`
|
||||
- 纯文本消息统一使用 `ContentField::Array([Text])`
|
||||
- `tool()` 需传入 `tool_call_id` 和 `content`
|
||||
- 模板方法:`system_template()` / `user_template()` / `assistant_template()` / `developer_template()`
|
||||
- 多模态方法:`*_content()`(单个 ContentPart)和 `*_contents()`(批量)
|
||||
- `with_name()`:作用于上一条消息的 `name` 字段
|
||||
- `build()` / `build_request()`
|
||||
- `validate_messages()`:独立的纯函数,校验 Tool→Assistant 顺序、角色交替、非空
|
||||
- 运行 `cargo check` 验证
|
||||
|
||||
### Step 6: LlmCycle 扩展
|
||||
|
||||
- `cycle.rs` 新增 3 个方法:
|
||||
- `with_messages(self, messages: Vec<Message>) -> Self` — 链式设置消息历史
|
||||
- `extend_messages(&mut self, messages: Vec<Message>)` — 追加消息
|
||||
- `submit_messages(&mut self, messages: Vec<Message>, tools: Vec<ToolDefinition>) -> Result<...>` — 预构建消息提交
|
||||
- `submit_messages()` 关闭自动 system_prompt 插入(避免与 Composer 的 System/Developer 消息重复)
|
||||
- 运行 `cargo check` 验证
|
||||
|
||||
### Step 7: lib.rs 注册
|
||||
|
||||
- `lib.rs` 添加 `pub mod prompt;`
|
||||
- 运行 `cargo check` 验证
|
||||
|
||||
### Step 8: 收尾
|
||||
|
||||
- `cargo clippy` — 无警告
|
||||
- `cargo build` — 完整构建
|
||||
- 检查所有新公开 API 有 `///` 文档注释
|
||||
|
||||
---
|
||||
|
||||
## 术语表
|
||||
|
||||
| 术语 | 说明 |
|
||||
|------|------|
|
||||
| `TemplateValue` | 模板渲染上下文中使用的值类型枚举 |
|
||||
| `TemplateContext` | 模板变量上下文,持有所有变量 |
|
||||
| `PromptTemplate` | 预编译的模板,持有 AST 片段列表 |
|
||||
| `Fragment` | 编译后的 AST 节点(内部枚举) |
|
||||
| `PromptComposer` | 提示词组合器,构建多角色消息序列 |
|
||||
| `PromptError` | 提示词工程专属错误类型 |
|
||||
|
||||
---
|
||||
|
||||
## 风险评估
|
||||
|
||||
| 风险 | 概率 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| 模板语法不支持复杂场景(如嵌套 each) | 低 | 当前需求不涉及;后续可引入 tera 替换 |
|
||||
| 自定义模板引擎解析有 bug | 中 | 编写单元测试覆盖所有语法分支 |
|
||||
| 与未来 Prompt Optimizer 冲突 | 低 | PromptOptimizer 只修改模板/上下文,不改模板引擎接口 |
|
||||
| 条件语义不明确(什么是"假"值) | 低 | 明确定义:None / false / 空字符串 / 空数组 均为假 |
|
||||
| 子模板循环引用导致栈溢出 | 低 | 渲染时加递归深度限制(16 层)或已注册集合去重检测 |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. `cargo check` 编译通过
|
||||
2. `cargo clippy` 无警告
|
||||
3. 模块文件路径正确:`src/prompt.rs` + `src/prompt/{template,composer,error}.rs`
|
||||
4. `PromptTemplate::compile()` 能解析含变量/条件/循环的模板
|
||||
5. `PromptTemplate::render()` 正确渲染所有语法
|
||||
6. `PromptTemplate` 实现 `Display` trait,输出原始模板字符串
|
||||
7. `TemplateContext` 提供 `from_json()` / `from_map()` 构造方式,支持 `From<&str>` 自动转换
|
||||
8. `PromptTemplateRegistry` 支持立即编译(`register()`)、延迟编译(`register_lazy()`)、文件加载(`register_file()`)
|
||||
9. `PromptComposer` 支持链式调用,覆盖 System / User / Assistant / Developer / Tool 五种角色
|
||||
10. `PromptComposer` 支持 `user_content()` / `system_content()` / `assistant_content()` / `developer_content()` / `tool_content()` 多模态方法
|
||||
11. `PromptComposer` 支持 `with_name()` 设置消息角色标识
|
||||
12. `PromptComposer::build_request()` 能创建 `OpenaiChatRequest`
|
||||
13. `validate_messages()` 能校验消息序列合法性
|
||||
14. `LlmCycle` 新增 `with_messages()` / `extend_messages()` / `submit_messages()` 支持 Composer 集成
|
||||
15. `lib.rs` 包含 `pub mod prompt;`
|
||||
16. 所有新公开 API 有文档注释
|
||||
@@ -0,0 +1,996 @@
|
||||
# Phase 2: Tool System — 方案设计
|
||||
|
||||
> 定稿日期:2026-06-03
|
||||
|
||||
## 背景与目标
|
||||
|
||||
AG Core Phase 0(Foundation)已完成 LLM 调用周期基础设施,Phase 1(Prompt Engineering)已完成提示词组合与模板化。Phase 2 的目标是补齐**工具系统**能力,实现 LLM 驱动的工具定义、注册、调用、权限控制,以及 MCP 协议集成。
|
||||
|
||||
**核心目标**:让 LLM 能通过 `FinishReason::ToolCalls` 触发工具自动执行,并将结果回传至对话上下文,形成完整的"思考 → 调用 → 反馈"闭环。
|
||||
|
||||
---
|
||||
|
||||
## 需求分析
|
||||
|
||||
### 功能需求
|
||||
|
||||
| 模块 | 需求 | 验收条件 |
|
||||
|------|------|---------|
|
||||
| `BaseTool` trait | 工具抽象接口:名称、描述、参数、执行、权限声明 | 实现 trait 后可注册到 Registry |
|
||||
| `ToolRegistry` | 工具注册、发现、按名称调用 | 注册 3 个工具后能按名称查找到并执行 |
|
||||
| `McpClient` | MCP 协议客户端(stdio transport) | 能启动 MCP 服务器子进程、列出工具、调用工具 |
|
||||
| `PermissionChecker` | 工具执行前权限校验 | 禁止无权限的工具执行,返回结构化错误 |
|
||||
| 自动 Tool 循环 | LlmCycle 收到 ToolCalls 后自动执行工具并回传 | 一个包含工具调用的对话能完整执行 2+ 轮 |
|
||||
| 流式 Tool 事件 | 流式模式下发射 `ToolExecutionCompleted` 事件 | 流式调用中工具执行完成后触发对应事件 |
|
||||
| 工具调用历史持久化 | 自动工具循环产生的 Tool/Assistant 消息正确追加到 `messages` | 查看 `cycle.messages()` 能获取完整工具交互轨迹 |
|
||||
|
||||
### 非功能需求
|
||||
|
||||
- 所有公开 API 必须带 `///` 文档注释
|
||||
- 无新增 `unwrap()` 调用
|
||||
- `BaseTool` 的 `execute()` 必须为 `async`
|
||||
- 工具执行错误必须结构化为 `ToolError`,不允许 panic
|
||||
- MCP 客户端超时默认 30 秒,可配置
|
||||
- 自定义工具与 MCP 工具通过同一 `ToolRegistry` 管理,对 LlmCycle 透明
|
||||
- 权限检查在工具执行之前,阻断后返回错误而非静默跳过
|
||||
- `BaseTool::execute()` 签名必须预留扩展点(`ToolContext` 注入),确保未来 Skill/Agent 层可在不修改 trait 签名的情况下注入 session_id、cancellation_token 等上下文信息
|
||||
- 自动 tool 循环应考虑 token 消耗——工具定义随每轮请求重复发送,工具结果直接追加到对话历史,需提供结果大小限制和截断策略
|
||||
|
||||
---
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 模块结构
|
||||
|
||||
```
|
||||
src/
|
||||
tools.rs # tools 模块根:声明子模块 + 重导出公共 API
|
||||
tools/
|
||||
base.rs # BaseTool trait — 工具抽象接口
|
||||
registry.rs # ToolRegistry — 工具注册表
|
||||
permission.rs # PermissionChecker — 权限校验器
|
||||
mcp.rs # McpClient — MCP 协议客户端
|
||||
error.rs # ToolError — 工具系统错误类型
|
||||
```
|
||||
|
||||
`tools.rs` 根模块声明:
|
||||
|
||||
```rust
|
||||
// tools.rs
|
||||
pub mod base;
|
||||
pub mod error;
|
||||
pub mod mcp;
|
||||
pub mod permission;
|
||||
pub mod registry;
|
||||
|
||||
pub use base::{BaseTool, ToolContext};
|
||||
pub use error::ToolError;
|
||||
pub use mcp::McpClient;
|
||||
pub use permission::{Permission, PermissionChecker, PermissionConfig};
|
||||
pub use registry::{ToolEntry, ToolInvocation, ToolRegistry};
|
||||
```
|
||||
|
||||
`lib.rs` 添加:
|
||||
|
||||
```diff
|
||||
pub mod llm;
|
||||
pub mod prompt;
|
||||
+pub mod tools;
|
||||
```
|
||||
|
||||
### 1. BaseTool trait — 工具抽象接口
|
||||
|
||||
```rust
|
||||
// tools/base.rs
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::tools::error::ToolError;
|
||||
use crate::tools::permission::Permission;
|
||||
|
||||
/// 工具执行上下文 —— 携带每次执行的运行时信息。
|
||||
/// 新增字段时提供默认值,不破坏已有工具实现。
|
||||
pub struct ToolContext<'a> {
|
||||
/// 当前对话/会话 ID,用于关联性追踪。
|
||||
pub session_id: &'a str,
|
||||
/// 链路追踪 ID,用于跨工具调用的耗时分布。
|
||||
pub trace_id: &'a str,
|
||||
/// 取消令牌,用于优雅取消正在执行的工具。
|
||||
pub cancellation_token: CancellationToken,
|
||||
}
|
||||
|
||||
/// 工具抽象接口 —— 所有工具(自定义或 MCP)最终都实现此 trait。
|
||||
#[async_trait]
|
||||
pub trait BaseTool: Send + Sync {
|
||||
/// 工具名称(唯一标识,用于 LLM 的 tool_calls.name 匹配)。
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// 工具描述(LLM 据此决定是否调用此工具)。
|
||||
fn description(&self) -> &str;
|
||||
|
||||
/// 工具参数定义(JSON Schema 格式,传递给 LLM 的 tool.parameters)。
|
||||
fn parameters(&self) -> Value;
|
||||
|
||||
/// 声明工具所需的权限列表。
|
||||
fn required_permissions(&self) -> Vec<Permission> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// 执行工具调用。
|
||||
/// `ctx` 携带执行上下文(session_id、trace_id 等),Phase 3/4 可扩展字段而不破坏 trait 签名。
|
||||
async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<Value, ToolError>;
|
||||
}
|
||||
```
|
||||
|
||||
**设计说明**:
|
||||
- `name()` 返回 `&str` 而非 `String`,避免每次调用克隆
|
||||
- `parameters()` 返回 `serde_json::Value`,与现有 `OpenaiToolDefinition.parameters` 类型一致
|
||||
- `required_permissions()` 提供默认空实现,简化无敏感操作的工具定义
|
||||
- `execute()` 接收 `Value`(JSON 对象)+ `ToolContext` 作为参数,返回 `Value` 作为结果,与 OpenAI API 的 arguments/output 格式一致
|
||||
- `ToolContext` 从 Phase 2 即注入 `execute()` 签名,防止后续 breaking change;新增字段用 `Option` 包裹或提供默认值
|
||||
|
||||
### 2. ToolRegistry — 工具注册表
|
||||
|
||||
```rust
|
||||
// tools/registry.rs
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm::types::{OpenaiToolDefinition, ToolDefinition};
|
||||
use crate::tools::base::BaseTool;
|
||||
use crate::tools::error::ToolError;
|
||||
use crate::tools::permission::{Permission, PermissionChecker};
|
||||
|
||||
/// 工具调用记录 —— 用于追踪和调试。
|
||||
pub struct ToolInvocation {
|
||||
pub tool_name: String,
|
||||
pub input: Value,
|
||||
pub output: Result<Value, ToolError>,
|
||||
}
|
||||
|
||||
/// 工具注册表 —— 管理工具注册、发现、调用。
|
||||
pub struct ToolRegistry {
|
||||
tools: HashMap<String, Arc<dyn BaseTool>>,
|
||||
permission_checker: Option<Arc<PermissionChecker>>,
|
||||
}
|
||||
|
||||
impl ToolRegistry {
|
||||
pub fn new() -> Self;
|
||||
|
||||
/// 设置权限检查器(可选,不设置则不检查权限)。
|
||||
pub fn with_permission_checker(mut self, checker: PermissionChecker) -> Self;
|
||||
|
||||
/// 注册一个工具。
|
||||
pub fn register(&mut self, tool: Arc<dyn BaseTool>) -> Result<(), ToolError>;
|
||||
|
||||
/// 批量注册工具。
|
||||
pub fn register_all(&mut self, tools: Vec<Arc<dyn BaseTool>>) -> Result<(), ToolError>;
|
||||
|
||||
/// 注销一个工具。
|
||||
pub fn unregister(&mut self, name: &str) -> Option<Arc<dyn BaseTool>>;
|
||||
|
||||
/// 按名称查找工具。
|
||||
pub fn get(&self, name: &str) -> Option<Arc<dyn BaseTool>>;
|
||||
|
||||
/// 获取所有已注册工具的名称列表。
|
||||
pub fn list_tools(&self) -> Vec<String>;
|
||||
|
||||
/// 获取所有工具的 ToolDefinition 列表(用于传递给 LLM)。
|
||||
pub fn definitions(&self) -> Vec<ToolDefinition>;
|
||||
|
||||
/// 调用一个工具(含权限检查)。
|
||||
pub async fn invoke(&self, name: &str, args: Value) -> Result<ToolInvocation, ToolError>;
|
||||
|
||||
/// 批量执行工具调用(并行执行互不依赖的工具)。
|
||||
pub async fn invoke_all(
|
||||
&self,
|
||||
calls: Vec<(String, Value)>,
|
||||
) -> Vec<ToolInvocation>;
|
||||
}
|
||||
```
|
||||
|
||||
**核心逻辑**:
|
||||
- `invoke()`:查找工具 → 权限检查 → 执行 → 返回 `ToolInvocation`
|
||||
- `invoke_all()`:对多个工具调用并行执行(使用 `tokio::join!` 或 `futures::join_all`),适用于 LLM 同时发出多个 tool_calls 的场景
|
||||
- `invoke_all()` 应对每个工具执行添加超时控制(通过 `tokio::time::timeout`),超时时间由 `CycleConfig::tool_timeout_secs` 配置,默认 60 秒,防止单个工具长时间阻塞整个循环
|
||||
- `definitions()`:将注册的工具批量转换为 `Vec<ToolDefinition>`,供 LlmCycle 传递 LLM
|
||||
- `ToolRegistry` 不持有 `PermissionChecker` 的生命周期(使用 `Arc`),允许多个 Registry 共享同一个 Checker
|
||||
|
||||
**使用示例**:
|
||||
```rust
|
||||
let mut registry = ToolRegistry::new()
|
||||
.with_permission_checker(checker);
|
||||
|
||||
registry.register(Arc::new(WeatherTool))?;
|
||||
registry.register(Arc::new(FileReadTool))?;
|
||||
|
||||
// 获取 ToolDefinitions 传递给 LLM
|
||||
let tools = registry.definitions();
|
||||
|
||||
// 收到 LLM 的 tool_calls 后执行
|
||||
let calls = vec![
|
||||
("get_weather".into(), json!({"city": "Beijing"})),
|
||||
("read_file".into(), json!({"path": "/tmp/data.txt"})),
|
||||
];
|
||||
let results = registry.invoke_all(calls).await;
|
||||
```
|
||||
|
||||
### 3. PermissionChecker — 权限校验器
|
||||
|
||||
```rust
|
||||
// tools/permission.rs
|
||||
|
||||
/// 权限级别枚举。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum Permission {
|
||||
/// 只读(读取文件、查询数据库等)。
|
||||
Read,
|
||||
/// 写入(创建/修改文件、插入数据等)。
|
||||
Write,
|
||||
/// 删除(删除文件、记录等)。
|
||||
Delete,
|
||||
/// 网络访问(HTTP 请求等)。
|
||||
Network,
|
||||
/// Shell 命令执行。
|
||||
Shell,
|
||||
/// 文件系统操作(除读/写/删之外的 FS 操作)。
|
||||
FileSystem,
|
||||
/// 自定义权限(可通过 namespaced 字符串扩展)。
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
/// 权限配置。
|
||||
pub struct PermissionConfig {
|
||||
/// 允许的权限列表(空 = 全部允许)。
|
||||
pub allowed: Vec<Permission>,
|
||||
/// 拒绝的权限列表(优先级高于 allowed)。
|
||||
pub denied: Vec<Permission>,
|
||||
/// 是否允许未声明权限的工具执行(默认为 true)。
|
||||
pub allow_unspecified: bool,
|
||||
}
|
||||
|
||||
impl Default for PermissionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allowed: vec![Permission::Read, Permission::Network],
|
||||
denied: vec![Permission::Delete, Permission::Shell],
|
||||
allow_unspecified: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 权限检查器。
|
||||
pub struct PermissionChecker {
|
||||
config: PermissionConfig,
|
||||
}
|
||||
|
||||
impl PermissionChecker {
|
||||
pub fn new(config: PermissionConfig) -> Self;
|
||||
|
||||
/// 检查指定权限是否允许执行。
|
||||
pub fn check(&self, tool_name: &str, permissions: &[Permission]) -> Result<(), ToolError>;
|
||||
}
|
||||
```
|
||||
|
||||
**权限判定规则**:
|
||||
1. 如果权限在 `denied` 中 → 拒绝
|
||||
2. 如果权限在 `allowed` 中 → 允许
|
||||
3. 如果 `allowed` 非空且权限不在其中 → 拒绝(白名单模式)
|
||||
4. 如果 `allowed` 为空 → 按 `allow_unspecified` 判定
|
||||
|
||||
### 4. McpClient — MCP 协议客户端
|
||||
|
||||
MCP(Model Context Protocol)是一种基于 JSON-RPC 的协议,用于 LLM 与外部工具系统通信。Phase 2 实现其 **最小可行子集**,优先实现 stdio transport。
|
||||
|
||||
> **传输方式说明**:MCP 协议版本 2025-03-26 定义了两种标准传输——`stdio` 和 `Streamable HTTP`。原有的 `HTTP+SSE` 传输(2024-11-05)已被官方废弃,新实现不应采用。`Streamable HTTP` 通过单一 HTTP 端点同时支持 JSON 响应和 SSE 流式升级,是 HTTP 场景的推荐方案。
|
||||
|
||||
```rust
|
||||
// tools/mcp.rs
|
||||
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use serde_json::Value;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{ChildStdin, ChildStdout, Command as TokioCommand};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::tools::base::BaseTool;
|
||||
use crate::tools::error::ToolError;
|
||||
|
||||
/// MCP 协议版本。
|
||||
const MCP_VERSION: &str = "2025-03-26";
|
||||
|
||||
/// MCP 传输方式。
|
||||
pub enum McpTransport {
|
||||
/// 通过子进程 stdin/stdout 通信。
|
||||
Stdio {
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
},
|
||||
/// Streamable HTTP 传输(MCP 2025-03-26 引入,替代已废弃的 HTTP+SSE)。
|
||||
/// 客户端通过单一 HTTP 端点与 MCP Server 通信,支持 JSON 和 SSE 流式响应。
|
||||
StreamableHttp {
|
||||
url: String,
|
||||
headers: Option<Vec<(String, String)>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// MCP 子进程运行时状态(connect() 后创建)。
|
||||
struct ChildProcessState {
|
||||
child: tokio::process::Child,
|
||||
stdin: tokio::io::BufWriter<tokio::process::ChildStdin>,
|
||||
/// 等待响应的请求映射(id → oneshot sender)。
|
||||
pending: HashMap<u64, tokio::sync::oneshot::Sender<Result<Value, ToolError>>>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
/// MCP 客户端 —— 与 MCP 服务器通信。
|
||||
pub struct McpClient {
|
||||
transport: McpTransport,
|
||||
server_name: String,
|
||||
/// 已初始化的工具列表(缓存)。
|
||||
tools: Vec<McpTool>,
|
||||
/// 是否已初始化。
|
||||
initialized: AtomicBool,
|
||||
/// 超时时间(秒)。
|
||||
timeout_secs: u64,
|
||||
/// 子进程运行时状态(connect() 后创建,close() 后取回)。
|
||||
process: Option<tokio::sync::Mutex<ChildProcessState>>,
|
||||
}
|
||||
|
||||
/// MCP 服务器暴露的工具(缓存结构)。
|
||||
struct McpTool {
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
input_schema: Value,
|
||||
}
|
||||
|
||||
impl McpClient {
|
||||
/// 创建一个 MCP 客户端。
|
||||
pub fn new(server_name: impl Into<String>, transport: McpTransport) -> Self;
|
||||
|
||||
/// 设置超时时间。
|
||||
pub fn with_timeout(mut self, secs: u64) -> Self;
|
||||
|
||||
/// 连接并初始化(发送 initialize 请求,获取服务器能力声明)。
|
||||
/// 启动子进程,创建 ChildProcessState(含 reader task)。
|
||||
pub async fn connect(&mut self) -> Result<(), ToolError>;
|
||||
|
||||
/// 列出服务器支持的工具(调用 tools/list)。
|
||||
pub async fn list_tools(&mut self) -> Result<Vec<ToolDefinition>, ToolError>;
|
||||
|
||||
/// 调用一个工具(调用 tools/call)。
|
||||
/// 通过 Mutex 获取 stdin 写入权限,发送 JSON-RPC 请求,通过 id 匹配响应。
|
||||
/// reader task 持续读取 stdout,解析 JSON-RPC 响应,通过 oneshot 通知调用方。
|
||||
pub async fn call_tool(&self, name: &str, args: Value) -> Result<Value, ToolError>;
|
||||
|
||||
/// 关闭连接(终止子进程)。
|
||||
/// 发送 shutdown → 等待 5s 优雅退出 → 超时则 child.kill()。
|
||||
pub async fn close(&mut self) -> Result<(), ToolError>;
|
||||
|
||||
/// 将 MCP 客户端转换为 BaseTool 适配器列表(用于注册到 ToolRegistry)。
|
||||
pub fn into_tools(self) -> Vec<Arc<dyn BaseTool>>;
|
||||
}
|
||||
```
|
||||
|
||||
**MCP 协议交互流程**:
|
||||
|
||||
```
|
||||
客户端 MCP 服务器
|
||||
│ │
|
||||
├── initialize request ──────► │
|
||||
│ { protocolVersion, capabilities }
|
||||
│◄──── initialize response ── │
|
||||
│ { protocolVersion, serverInfo, capabilities }
|
||||
│ │
|
||||
├── initialized notification ──► │
|
||||
│ │
|
||||
├── tools/list ──────────────► │
|
||||
│◄── tools/list result ──────── │
|
||||
│ { tools: [{ name, description, inputSchema }] }
|
||||
│ │
|
||||
├── tools/call ─────────────► │
|
||||
│ { name, arguments }
|
||||
│◄── tools/call result ──────── │
|
||||
│ { content: [{ type, text }] }
|
||||
│ │
|
||||
├── shutdown ───────────────► │
|
||||
│◄── shutdown ───────────── │
|
||||
```
|
||||
|
||||
**关于 stdio transport 实现**:
|
||||
- 使用 `tokio::process::Command` 启动子进程
|
||||
- stdin 写入 JSON-RPC 请求(每行一个 JSON 对象)
|
||||
- stdout 读取 JSON-RPC 响应(使用 `BufReader` 逐行读取)
|
||||
- 每个请求关联一个 `id`(递增整数),通过 `id` 匹配请求和响应
|
||||
- 进程退出时自动关闭
|
||||
|
||||
**关于 MCP Server 的管理**:
|
||||
- Phase 2 **不** 实现 MCP Server 框架,只实现 Client
|
||||
- MCP Server 由外部提供(如 `npx @anthropic/mcp-server-filesystem`)
|
||||
- 用户需要提供 MCP Server 的启动命令和参数
|
||||
|
||||
**工具缓存说明**:
|
||||
- `McpClient` 在 `list_tools()` 时缓存工具列表,避免每次调用都重新请求
|
||||
- 缓存假设:MCP Server 的工具列表在运行时不会频繁变更(如插件式加载场景除外)
|
||||
- 如需刷新,可通过新增 `refresh_tools()` 方法或基于 TTL(如 60 秒)自动失效
|
||||
|
||||
### 5. ToolError — 错误类型
|
||||
|
||||
```rust
|
||||
// tools/error.rs
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ToolError {
|
||||
#[error("工具 '{0}' 未注册")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("工具 '{0}' 执行失败: {1}")]
|
||||
ExecutionFailed(String, String),
|
||||
|
||||
#[error("工具 '{0}' 参数无效: {1}")]
|
||||
InvalidArguments(String, String),
|
||||
|
||||
#[error("权限被拒绝: 工具 '{0}' 需要 {1:?} 权限")]
|
||||
PermissionDenied(String, String),
|
||||
|
||||
#[error("MCP 协议错误: {0}")]
|
||||
McpError(String),
|
||||
|
||||
#[error("MCP 未初始化: {0}")]
|
||||
McpNotInitialized(String),
|
||||
|
||||
#[error("MCP 超时: {0}")]
|
||||
McpTimeout(String),
|
||||
|
||||
#[error("IO 错误: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("其他错误: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
```
|
||||
|
||||
### 6. LlmCycle 扩展 — 自动 Tool 循环
|
||||
|
||||
**核心设计**:在 `LlmCycle` 中新增 `submit_with_tools()` 方法,自动处理 tool 执行循环。
|
||||
|
||||
```rust
|
||||
// llm/cycle.rs 新增
|
||||
|
||||
use crate::tools::registry::ToolRegistry;
|
||||
use crate::tools::error::ToolError;
|
||||
|
||||
impl LlmCycle {
|
||||
/// 提交消息并自动处理工具调用循环。
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. 发送请求(含工具定义)
|
||||
/// 2. 检查响应中的 finish_reason
|
||||
/// 3. 如果是 ToolCalls → 先 push Assistant 消息 → 执行工具 → 回传结果 → 重复 1
|
||||
/// 4. 如果是 Stop/Length → push Assistant 消息 → 返回最终响应
|
||||
///
|
||||
/// 注意:OpenAI API 要求 tool 消息必须紧跟在对应的 Assistant(tool_calls)消息之后。
|
||||
/// 因此 push 工具结果前必须先 push Assistant 响应,否则 API 拒绝请求。
|
||||
pub async fn submit_with_tools(
|
||||
&mut self,
|
||||
prompt: String,
|
||||
registry: &ToolRegistry,
|
||||
) -> Result<ChatResponse, LlmError> {
|
||||
let tools = registry.definitions();
|
||||
let max_turns = self.config.max_tool_turns.unwrap_or(10);
|
||||
let mut turn = 0;
|
||||
|
||||
self.messages.push(OpenaiChatMessage::user_text(prompt));
|
||||
self.maybe_compact();
|
||||
|
||||
loop {
|
||||
turn += 1;
|
||||
if turn > max_turns {
|
||||
return Err(LlmError::Other(format!(
|
||||
"达到最大工具循环轮次 ({})",
|
||||
max_turns
|
||||
)));
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
let response = self.submit_request(&tools).await?;
|
||||
|
||||
// 检查是否需要执行工具
|
||||
let should_execute = matches!(
|
||||
response.stop_reason,
|
||||
Some(FinishReason::ToolCalls)
|
||||
) && has_tool_calls(&response.message);
|
||||
|
||||
// 将 Assistant 响应(含 tool_calls 或最终文本)追加到消息历史
|
||||
self.messages.push(response.message.clone());
|
||||
|
||||
if !should_execute {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
// 解析 tool_calls 并执行
|
||||
let tool_calls = extract_tool_calls(&response.message);
|
||||
let results = registry.invoke_all(tool_calls).await;
|
||||
|
||||
// 回传工具结果
|
||||
for result in results {
|
||||
let content = match &result.output {
|
||||
Ok(value) => serde_json::to_string(value)
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("工具结果序列化失败: {}", e);
|
||||
"{}".to_string()
|
||||
}),
|
||||
Err(e) => format!("错误: {}", e),
|
||||
};
|
||||
|
||||
self.messages.push(
|
||||
OpenaiChatMessage::tool_result(result.tool_name.clone(), content)
|
||||
);
|
||||
}
|
||||
|
||||
// 每轮工具执行后触发 compaction,防止 token 快速膨胀
|
||||
self.maybe_compact();
|
||||
}
|
||||
}
|
||||
|
||||
/// 在接近上下文窗口时压缩历史消息。
|
||||
fn maybe_compact(&mut self) {
|
||||
if let Some(ref config) = self.compact_config
|
||||
&& should_compact(&self.messages, config, &self.compact_state)
|
||||
{
|
||||
let freed = microcompact(&mut self.messages, config.keep_recent);
|
||||
if freed > 0 {
|
||||
self.compact_state.record_success();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 内部请求方法(与 submit 共享重试逻辑,但不 push user message)。
|
||||
async fn submit_request(
|
||||
&mut self,
|
||||
tools: &[ToolDefinition],
|
||||
) -> Result<ChatResponse, LlmError> {
|
||||
// ... 提取 submit() 中的 request → response 逻辑(不含 user prompt push)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**关键设计决策**:
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| 循环方式 | 同步循环(单线程串行) | 工具执行依赖前一轮结果,串行更安全 |
|
||||
| 最大轮次 | `CycleConfig.max_tool_turns`,独立于 `max_turns`,默认 `Some(10)` | 防止无限循环(LLM 反复调用工具)。使用独立字段避免影响现有 `submit()`/`submit_messages()` 的 `max_turns` 语义 |
|
||||
| 工具并行 | `invoke_all()` 互不依赖的工具并行 | LLM 可能一次发出多个 tool_calls(parallel_tool_calls) |
|
||||
| 工具超时 | `CycleConfig::tool_timeout_secs`,默认 60 | 防止单个工具长时间阻塞循环。`invoke_all()` 使用 `tokio::time::timeout` 包装 |
|
||||
| 错误处理 | 工具执行错误以文本回传 LLM,而非终止循环 | LLM 可自行从错误中恢复 |
|
||||
| 消息追踪 | 所有工具交互通过 `self.messages` 持久化 | 调用方能通过 `cycle.messages()` 查看完整轨迹 |
|
||||
|
||||
**Token 消耗分析**:
|
||||
|
||||
自动 tool 循环的 token 消耗主要来自三个来源:
|
||||
|
||||
| 来源 | 说明 | 影响程度 |
|
||||
|------|------|---------|
|
||||
| 工具定义重复发送 | `definitions()` 在每轮请求中携带全部工具的 JSON Schema | 注册工具数 × 平均定义大小 × 轮数。20 个工具 × 500B × 5 轮 ≈ 50KB 输入 token |
|
||||
| 工具结果追加历史 | 每次工具执行结果完整追加到 `messages`,后续请求重发全部历史 | 最显著的 token 泄漏源。大结果(如向量搜索 Top-50)单次可能 ~15KB,多轮累加 |
|
||||
| Value→String 序列化 | 工具结果 `serde_json::to_string()` 后 JSON 字符串膨胀 ~20-30% | 线性的常量损耗 |
|
||||
|
||||
**影响估算**:
|
||||
|
||||
| 场景 | 工具相关 token 占比 | 说明 |
|
||||
|------|-------------------|------|
|
||||
| 单次简单查询 | <5% | 可忽略 |
|
||||
| 文件读取+分析(3-4 轮) | ~30% | 工具结果逐步累积 |
|
||||
| 网页搜索+总结(3-5 轮) | ~40% | 工具结果包含页面内容 |
|
||||
| 多工具数据 pipeline(5-10 轮) | ~60%+ | 需关注压缩和限制策略 |
|
||||
|
||||
**缓解方向**(Phase 2 不强制实现,但设计需可扩展):
|
||||
- **结果大小限制**:工具执行结果超过阈值时自动截断(如 `CycleConfig::max_tool_result_bytes`)
|
||||
- **自动压缩**:现有的 Auto-compaction 需感知工具消息,避免压缩掉 LLM 后续依赖的数据
|
||||
- **工具定义缓存**:基础工具定义变化极少,未来可考虑客户端侧缓存(需等 provider 支持)
|
||||
|
||||
**错误分类与处理策略**:
|
||||
|
||||
工具执行错误需要区分"可恢复"和"不可恢复"两类,不可恢复的错误应终止循环而非回传 LLM:
|
||||
|
||||
| 错误类型 | 处理策略 | 理由 |
|
||||
|---------|---------|------|
|
||||
| `ToolError::ExecutionFailed` | 回传 LLM(文本) | LLM 可能下次换参数或换方式重试 |
|
||||
| `ToolError::InvalidArguments` | 回传 LLM(文本) | LLM 可自动修正参数 |
|
||||
| `ToolError::NotFound` | 终止循环,返回 `LlmError` | LLM 无法注册工具,重试无意义 |
|
||||
| `ToolError::PermissionDenied` | 终止循环,返回 `LlmError` | 安全敏感,不应允许重试 |
|
||||
| `ToolError::McpError` | 终止循环,返回 `LlmError` | MCP 链路故障,重试大概率失败 |
|
||||
| `ToolError::McpTimeout` | 终止循环,返回 `LlmError` | 或可考虑重试 1 次后终止 |
|
||||
| `ToolError::Io` | 终止循环,返回 `LlmError` | IO 错误通常是环境问题 |
|
||||
| `ToolError::Other` | 回传 LLM(文本) | 兜底,保守回传 |
|
||||
|
||||
实现上可在 `ToolError` 上添加 `is_recoverable()` 方法,或在 `submit_with_tools()` 中通过 `match` 分支判断。
|
||||
|
||||
**submit_request() 重构说明**:
|
||||
|
||||
提取 `submit_request()` 作为 `submit_with_tools()` 的内部方法时,需确保不影响现有方法的行为。重构后的方法职责矩阵:
|
||||
|
||||
| 方法 | Push user msg | Compaction | Retry | Call provider | Handle response |
|
||||
|------|:---:|:---:|:---:|:---:|:---:|
|
||||
| `submit()` | ✅ | ✅ | ✅ | → `submit_request()` | ✅ |
|
||||
| `submit_messages()` | ❌ | ✅ | ✅ | → `submit_request()` | ✅ |
|
||||
| `submit_with_tools()` | ✅ | ✅ | ✅ | → `submit_request()` | ✅* |
|
||||
| `submit_request()` | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
|
||||
*`submit_with_tools()` 在 `submit_request()` 返回后额外检查 `ToolCalls`,执行工具后递归调用自身。
|
||||
|
||||
**流式模式支持**:
|
||||
|
||||
`submit_stream()` 的增强方案:新增 `submit_stream_with_tools()`,在流式事件层面支持自动 tool 循环。
|
||||
|
||||
> **实现复杂度提示**:流式 tool 循环需要自定义 `Stream` 实现 + 内部状态机(`Streaming` → `ExecutingTools` → `Finished`)。每一轮需要:消费当前流 → 收集事件 → 检测 `TurnComplete(ToolCalls)` → 执行工具 → 发射 `ToolExecutionCompleted` → 发起新流 → 继续 yield。不能用简单的 `stream!` 宏实现。
|
||||
>
|
||||
> 建议 Phaes 3 再实现 `submit_stream_with_tools()`,Phase 2 只实现非流式的 `submit_with_tools()`。如果 Phase 2 需要可先返回 "not yet implemented" 错误。
|
||||
|
||||
```rust
|
||||
impl LlmCycle {
|
||||
pub async fn submit_stream_with_tools(
|
||||
&mut self,
|
||||
prompt: String,
|
||||
registry: &ToolRegistry,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError> {
|
||||
// 1. 使用 submit_stream() 获取初始事件流
|
||||
// 2. 监听 TurnComplete { reason: ToolCalls }
|
||||
// 3. 触发时:通过 ToolRegistry 执行工具
|
||||
// 4. 发射 ToolExecutionCompleted 事件(由 submit_stream_with_tools 负责,非底层 stream parser)
|
||||
// 5. 将工具结果注入 messages
|
||||
// 6. 自动发起下一轮请求(递归)
|
||||
// 7. 直到 finish_reason 为 Stop
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**事件发射时序**:
|
||||
```
|
||||
submit_stream_with_tools("查天气")
|
||||
│
|
||||
├─ AssistantTextDelta "我来查一下北京的天气..." ← 底层 stream parser 发射
|
||||
├─ ToolExecutionStarted { tool_name, input, id } ← submit_stream_with_tools 发射
|
||||
├─ TurnComplete { reason: ToolCalls } ← 底层 stream parser 发射
|
||||
│
|
||||
├── [自动] 执行工具 get_weather({city:"北京"})
|
||||
│
|
||||
├─ ToolExecutionCompleted { tool_name, output, ... } ← submit_stream_with_tools 发射
|
||||
│
|
||||
├─ AssistantTextDelta "北京今天 22°C" ← 底层 stream parser 发射
|
||||
├─ TurnComplete { reason: Stop } ← 底层 stream parser 发射
|
||||
│
|
||||
└─ (流结束)
|
||||
|
||||
**事件发射职责划分**:底层 `parse_chunk_stream()` 负责 LLM 原生事件(`AssistantTextDelta`、`TurnComplete`);`submit_stream_with_tools()` 负责工具层事件(`ToolExecutionStarted`、`ToolExecutionCompleted`),在工具执行前/后手动 `yield` 事件。
|
||||
```
|
||||
|
||||
### 7. 自定义工具示例
|
||||
|
||||
```rust
|
||||
use agcore::tools::{BaseTool, ToolError};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
struct WeatherTool;
|
||||
|
||||
#[async_trait]
|
||||
impl BaseTool for WeatherTool {
|
||||
fn name(&self) -> &str { "get_weather" }
|
||||
fn description(&self) -> &str { "获取指定城市的当前天气" }
|
||||
fn parameters(&self) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": { "type": "string", "description": "城市名称" }
|
||||
},
|
||||
"required": ["city"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> Result<Value, ToolError> {
|
||||
let city = args["city"].as_str()
|
||||
.ok_or_else(|| ToolError::InvalidArguments(
|
||||
"get_weather".into(), "缺少 city 参数".into()
|
||||
))?;
|
||||
// 模拟天气查询
|
||||
Ok(serde_json::json!({ "city": city, "temperature": 22, "unit": "°C" }))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. 模块依赖关系
|
||||
|
||||
```
|
||||
tools/ 模块内部依赖:
|
||||
base.rs → 无内部依赖(Permission 枚举 + ToolError)
|
||||
permission → 无内部依赖
|
||||
registry → base, error, permission
|
||||
mcp → base, error(需通过 registry 注册)
|
||||
error → 无内部依赖
|
||||
|
||||
跨模块依赖:
|
||||
tools/ → llm/types (ToolDefinition 类型)
|
||||
llm/cycle → tools/registry (自动 tool 循环)
|
||||
llm/cycle → tools/error (ToolError 转换)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. 未来工具化路线扩展性分析
|
||||
|
||||
> 本节回答"当前设计是否足以支撑未来常规工具、MCP、Skill、记忆等统一走工具调用路线"。
|
||||
|
||||
#### 设计目标
|
||||
|
||||
未来所有 Agent 可调用的能力(常规工具、MCP 工具、Skill、记忆操作)都应通过 `BaseTool` trait 统一暴露给 LLM,`ToolRegistry` 作为唯一的工具发现和调用入口,对 `LlmCycle` 透明。
|
||||
|
||||
#### 各场景支持度评估
|
||||
|
||||
| 场景 | 当前支持度 | 关键瓶颈 |
|
||||
|------|-----------|---------|
|
||||
| 常规工具(天气/计算器) | ✅ 直接可行 | 无 |
|
||||
| MCP 工具(McpClient→BaseTool 适配器) | ✅ 可行 | 适配器模式优雅,MCP 流式/进度能力被 `Value→Value` 约束 |
|
||||
| Memory CRUD(store/recall/forget/update) | ⚠️ 基本可行 | 检索分页、大量结果返回需额外处理 |
|
||||
| 长时运行工具(数据集查询、文件上传) | ❌ 不可行 | 无进度汇报、无 cancellation 机制 |
|
||||
| 多轮确认工具("是否冻结账户?"审批流程) | ❌ 不可行 | 单次调用→单次返回,无法表达"反问→确认"模式 |
|
||||
| Skill 编排(多步骤组合、嵌套执行) | ❌ 不可行 | 无上下文传播(跨步骤传递中间结果)、无工具组合原语 |
|
||||
| Agent 按场景筛选工具子集 | ⚠️ 部分可行 | 无 tag/category 筛选机制 |
|
||||
|
||||
#### 关键扩展点
|
||||
|
||||
**A. `BaseTool::execute()` 签名——预留 `ToolContext` 注入**
|
||||
|
||||
`BaseTool` 是公开 trait,一旦用户实现并发布 crate,后续 breaking change 成本极高。当前签名:
|
||||
|
||||
```rust
|
||||
async fn execute(&self, args: Value) -> Result<Value, ToolError>;
|
||||
```
|
||||
|
||||
未来扩展路径——新增 `ToolContext` 参数,携带执行上下文:
|
||||
|
||||
```rust
|
||||
async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<Value, ToolError>;
|
||||
```
|
||||
|
||||
`ToolContext` 初始应包含的字段(Phase 2 实现时不必全部实现,但签名需预留参数位置):
|
||||
|
||||
| 字段 | 用途 | 引入阶段 |
|
||||
|------|------|---------|
|
||||
| `session_id: &str` | 追踪一次对话中所有工具调用的关联性 | Phase 2 |
|
||||
| `trace_id: &str` | 链路追踪,跨工具调用的耗时分布 | Phase 2 |
|
||||
| `cancellation_token: CancellationToken` | 优雅取消正在执行的工具 | Phase 2 |
|
||||
| `progress: Option<UnboundedSender<ProgressEvent>>` | 进度汇报(数据处理到 50%) | Phase 3 |
|
||||
| `shared_state: Option<&HashMap<String, Value>>` | Skill 跨步骤传递中间结果 | Phase 4 |
|
||||
|
||||
这样 Skill/Agent 层在 Phase 4 引入时,`execute` 签名不必改,只需在 `ToolContext` 中增加字段。
|
||||
|
||||
**B. `ToolRegistry` 内部结构——引入 `ToolEntry` 元数据**
|
||||
|
||||
当前内部是 `HashMap<String, Arc<dyn BaseTool>>`,未来扩展为:
|
||||
|
||||
```rust
|
||||
pub struct ToolEntry {
|
||||
pub tool: Arc<dyn BaseTool>,
|
||||
pub tags: Vec<String>,
|
||||
pub category: String, // "memory", "data", "communication" 等
|
||||
pub version: Option<String>,
|
||||
pub stats: ToolStats, // 调用次数、平均耗时
|
||||
}
|
||||
```
|
||||
|
||||
对应的筛选 API:
|
||||
|
||||
```rust
|
||||
pub fn find_by_tag(&self, tag: &str) -> Vec<&ToolEntry>;
|
||||
pub fn find_by_category(&self, category: &str) -> Vec<&ToolEntry>;
|
||||
pub fn groups(&self) -> HashMap<&str, Vec<&ToolEntry>>;
|
||||
```
|
||||
|
||||
**C. 工具返回模式——从单一 `Value` 到 `ToolOutput` 枚举**
|
||||
|
||||
当前返回类型 `Result<Value, ToolError>` 只能表达"一次性完整返回"。未来根据需要引入多模式输出:
|
||||
|
||||
```rust
|
||||
pub enum ToolOutput {
|
||||
/// 一次性返回完整结果
|
||||
Final(Value),
|
||||
/// 通过 channel 逐步流式输出结果
|
||||
Streamed { initial: Value, rx: Receiver<Value> },
|
||||
/// 需要 LLM 进一步确认后再继续
|
||||
AwaitingInput { context: Value, prompt: String },
|
||||
}
|
||||
```
|
||||
|
||||
| 返回模式 | 场景示例 |
|
||||
|---------|---------|
|
||||
| `Final(Value)` | 天气查询、文件读取 |
|
||||
| `Streamed { initial, rx }` | 向量搜索 Top-100 逐批返回 |
|
||||
| `AwaitingInput { context, prompt }` | "检测到可疑交易,是否冻结?" |
|
||||
|
||||
#### 各能力的引入时序
|
||||
|
||||
```
|
||||
Phase 2(当前实现)
|
||||
├─ BaseTool trait (Value→Value, 但签名预留 Context 参数位)
|
||||
├─ ToolRegistry (HashMap<String, ToolEntry> + tag/category 筛选)
|
||||
├─ PermissionChecker / McpClient / ToolError
|
||||
├─ submit_with_tools() / submit_stream_with_tools()
|
||||
└─ ToolContext { session_id, trace_id, cancellation_token }
|
||||
|
||||
Phase 3(Memory 工具化)
|
||||
├─ MemoryStore trait(扩展 BaseTool)
|
||||
├─ memory_store / memory_recall / memory_search 等作为工具注册
|
||||
└─ ToolContext.progress 支持(分批返回检索结果)
|
||||
|
||||
Phase 4(Agent + Skill + 编排)
|
||||
├─ ToolContext.shared_state 支持(跨步骤传递中间结果)
|
||||
├─ ToolOutput 枚举支持(如需要流式/确认模式)
|
||||
├─ ToolChain / ToolSelector 工具组合原语
|
||||
└─ Skill 机制(多步骤编排 + 内部状态)
|
||||
```
|
||||
|
||||
#### 已识别但推迟的设计决策
|
||||
|
||||
| 决策 | 推迟原因 | 何时需要 |
|
||||
|------|---------|---------|
|
||||
| `ToolOutput` 枚举 | Phase 2 的所有场景(常规工具/MCP)用 `Value` 足够 | Phase 4 Agent 编排或长时工具 |
|
||||
| 工具 DAG 调度 | Agent 场景后才需要复杂编排 | Phase 4 |
|
||||
| Skill 机制 | 需要先有 Agent 使用工具的实践经验 | Phase 4 |
|
||||
| 工具调用审计持久化 | 可先通过 Hook 点实现简单日志 | Phase 4 |
|
||||
| 用户授权(运行时弹窗确认) | `PermissionChecker` 只做静态策略判定,不处理运行时交互。用户授权属于交互流程,应作为 `ToolOutput::AwaitingInput` 由上层 UI/Agent 层实现 | Phase 4 |
|
||||
|
||||
---
|
||||
|
||||
## 实现计划
|
||||
|
||||
### Step 1: 创建方案文档
|
||||
|
||||
创建 `docs/5-tool-system.md`(即本文档)。
|
||||
|
||||
### Step 2: ToolError
|
||||
|
||||
- 创建 `src/tools/error.rs`
|
||||
- 定义 `ToolError` 枚举(NotFound / ExecutionFailed / InvalidArguments / PermissionDenied / McpError / McpTimeout / Io / Other)
|
||||
- 运行 `cargo check` 验证
|
||||
|
||||
### Step 3: Permission
|
||||
|
||||
- 创建 `src/tools/permission.rs`
|
||||
- 定义 `Permission` 枚举 + `PermissionConfig` + `PermissionChecker`
|
||||
- 编写权限判定逻辑(白名单/黑名单/未指定策略)
|
||||
- 编写 5+ 边界测试覆盖:白名单模式、黑名单模式、空列表、自定义权限冲突
|
||||
- 运行 `cargo test` 验证
|
||||
|
||||
### Step 4: BaseTool trait
|
||||
|
||||
- 创建 `src/tools/base.rs`
|
||||
- 定义 `BaseTool` trait(name / description / parameters / required_permissions / execute)
|
||||
- 定义 `ToolContext` 结构体(session_id / trace_id / cancellation_token),注入 `execute()` 作为第二个参数
|
||||
- 创建 `src/tools.rs` 模块根,声明子模块,重导出公共 API
|
||||
- `lib.rs` 添加 `pub mod tools;`
|
||||
- 编写 1 个 MockTool 测试工具并验证 trait 实现
|
||||
- 运行 `cargo check` 验证
|
||||
|
||||
### Step 5: ToolRegistry
|
||||
|
||||
- 创建 `src/tools/registry.rs`
|
||||
- 定义 `ToolInvocation` 结构体 + `ToolEntry` 元数据包装(tool + tags + category + stats)+ `ToolRegistry`
|
||||
- 实现核心方法:register / get / list / definitions / invoke / invoke_all / find_by_tag / find_by_category
|
||||
- `invoke_all()` 使用 `futures::future::join_all` + `tokio::time::timeout` 并行执行互不依赖的工具(每工具独立超时)
|
||||
- `definitions()` 将 `HashMap` 中的工具转换为 `Vec<ToolDefinition>`
|
||||
- `ToolRegistry` 不支持运行时并发注册(setup 阶段一次性构建),如需热注册由调用方通过 `Arc<RwLock<ToolRegistry>>` 包装
|
||||
- 编写 8+ 测试覆盖:注册冲突、空注册表查找、单次调用、批量并行调用、工具执行失败
|
||||
- 运行 `cargo test` 验证
|
||||
|
||||
### Step 6: LlmCycle 扩展(自动 Tool 循环)
|
||||
|
||||
- 新增 `cycle_submit.rs` 子模块(或直接在 `cycle.rs` 中扩增,取决于代码量)
|
||||
- 提取 `submit_request()` 内部方法(将 submit() 中的 request→response 逻辑独立),同时重构 `submit_messages()` 以复用同一路径
|
||||
- 实现 `submit_with_tools()` 方法:
|
||||
- 循环:submit_request → push Assistant 消息 → 检查 finish_reason → 调用 registry.invoke_all → push tool_results → 重复
|
||||
- 在 push tool_results **之前**先 push Assistant(tool_calls)消息(OpenAI API 要求)
|
||||
- `max_tool_turns` 控制(独立于 `max_turns`),达到上限返回错误
|
||||
- 不可恢复的错误(NotFound、PermissionDenied、McpError)终止循环
|
||||
- 可恢复的错误(ExecutionFailed、InvalidArguments)以文本回传 LLM
|
||||
- 每轮执行后触发 `maybe_compact()` 防止 token 膨胀
|
||||
- `submit_stream_with_tools()` 方法:
|
||||
- Phase 2 标记为未实现(返回 `LlmError::Other("流式 tool 循环将在后续版本中支持")`)
|
||||
- 实际实现推迟到 Phase 3(需要自定义 `ToolStream` 状态机)
|
||||
- 更新 `CycleConfig`:
|
||||
- 新增 `max_tool_turns: Option<u32>`,默认 `Some(10)`(不影响 `max_turns` 语义)
|
||||
- 新增 `tool_timeout_secs: u64`,默认值 60
|
||||
- 新增 `max_tool_result_bytes: Option<usize>`,默认 `Some(65536)`(限制单次工具结果大小)
|
||||
- 编写 3+ 集成测试:单轮 tool 调用、多轮 tool 调用、达到 max_tool_turns 终止
|
||||
- 运行 `cargo test` 验证
|
||||
|
||||
### Step 7: McpClient(MCP 协议客户端)
|
||||
|
||||
- 创建 `src/tools/mcp.rs`
|
||||
- 实现 JSON-RPC 消息结构(Request / Response / Error / Notification)
|
||||
- 定义 `ChildProcessState` 结构体,包含运行时字段:`child`/`stdin`/`pending: HashMap<u64, oneshot::Sender>`/`next_id: u64`
|
||||
- reader task 使用 `tokio::select!` 同时监听 stdout 和 cancellation token
|
||||
- `call_tool()` 通过 Mutex 获取 stdin 写入权限,通过 id 匹配响应
|
||||
- 子进程意外退出时通知所有 pending 请求
|
||||
- 实现 stdio transport:
|
||||
- `connect()`:启动子进程,创建 ChildProcessState,发送 initialize 请求
|
||||
- `list_tools()`:调用 tools/list,缓存结果
|
||||
- `call_tool()`:调用 tools/call,解析响应
|
||||
- `close()`:发送 shutdown → 等待 5s 优雅退出 → 超时则 child.kill()
|
||||
- `StreamableHttp` transport 预留枚举变体,当前返回 "not implemented" 错误,不在 Phase 2 实现
|
||||
- 实现 `into_tools()`:将 MCP 工具转换为 `Vec<Arc<dyn BaseTool>>` 适配器
|
||||
- 设置 30 秒默认超时
|
||||
- 编写 MCP 协议消息序列化/反序列化测试 + 模拟子进程集成测试
|
||||
- 运行 `cargo test` 验证
|
||||
|
||||
### Step 8: 收尾
|
||||
|
||||
- 更新 `docs/roadmap.md` 标记 Phase 2 完成
|
||||
- `cargo clippy` — 无警告
|
||||
- `cargo build` — 完整构建
|
||||
- 检查所有新公开 API 有 `///` 文档注释
|
||||
- `cargo test` — 所有测试通过
|
||||
|
||||
---
|
||||
|
||||
## 术语表
|
||||
|
||||
| 术语 | 说明 |
|
||||
|------|------|
|
||||
| `BaseTool` | 工具抽象接口,所有工具需实现此 trait |
|
||||
| `ToolRegistry` | 工具注册表,管理工具注册、发现、调用 |
|
||||
| `ToolInvocation` | 工具调用记录,包含输入、输出和执行结果 |
|
||||
| `Permission` | 权限级别枚举(Read/Write/Delete/Network/Shell 等) |
|
||||
| `PermissionChecker` | 权限校验器,执行前判定是否允许 |
|
||||
| `McpClient` | MCP 协议客户端,通过 stdio 与 MCP Server 通信 |
|
||||
| `ToolDefinition` | 传递给 LLM 的工具定义(同 `OpenaiToolDefinition`) |
|
||||
| 自动 Tool 循环 | LlmCycle 自动执行 LLM 请求的工具调用并回传结果 |
|
||||
|
||||
---
|
||||
|
||||
## 风险评估
|
||||
|
||||
| 风险 | 概率 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| MCP 协议规范变化 | 中 | 只实现最小子集(initialize/list_tools/call_tool),封装在 `mcp.rs` 中便于适配 |
|
||||
| MCP 子进程异常退出 | 中 | 实现超时机制 + 错误恢复;进程退出时自动标记为不可用 |
|
||||
| 工具执行死循环(LLM 反复调用工具) | 中 | `max_turns` 硬限制,达到上限后终止循环 |
|
||||
| JSON-RPC 消息竞争(stdio 双工) | 中 | 请求和响应通过 `id` 字段匹配,使用 `Mutex` 保护写操作 + `HashMap<u64, OneshotSender>` 等待响应,实现复杂度高于接口示意 |
|
||||
| 权限配置过于复杂 | 低 | PermissionConfig 提供合理默认值(允许 Read/Network,拒绝 Delete/Shell),简单场景无需自定义 |
|
||||
| 工具调用参数类型不匹配 | 低 | `execute()` 接收 `Value`,由实现方自行校验;通过 `ToolError::InvalidArguments` 返回结构化错误 |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. `cargo check` 编译通过
|
||||
2. `cargo clippy` 无警告
|
||||
3. 模块文件路径正确:`src/tools.rs` + `src/tools/{base,registry,permission,mcp,error}.rs`
|
||||
4. `BaseTool` trait 可被自定义工具实现,`name()` / `description()` / `parameters()` / `execute()` 四个方法正常工作
|
||||
5. `ToolRegistry` 支持注册、查找、列出、注销操作
|
||||
6. `ToolRegistry::definitions()` 返回正确的 `Vec<ToolDefinition>`
|
||||
7. `ToolRegistry::invoke()` 执行工具前进行权限检查
|
||||
8. `ToolRegistry::invoke_all()` 并行执行多个工具调用
|
||||
9. `PermissionChecker` 根据配置正确判定权限(白名单/黑名单/默认策略)
|
||||
10. `LlmCycle::submit_with_tools()` 收到 `FinishReason::ToolCalls` 后自动执行工具并回传结果
|
||||
11. `LlmCycle::submit_with_tools()` 达到 `max_turns` 上限时终止并返回错误
|
||||
12. `LlmCycle::submit_stream_with_tools()` 在流式模式下发射 `ToolExecutionCompleted` 事件
|
||||
13. 自动 tool 循环产生的 Tool 消息正确追加到 `cycle.messages()`
|
||||
14. `McpClient::connect()` 能完成 MCP 协议握手(initialize)
|
||||
15. `McpClient::list_tools()` 能获取 MCP Server 暴露的工具列表
|
||||
16. `McpClient::call_tool()` 能调用 MCP Server 的工具
|
||||
17. `McpClient::into_tools()` 能生成可供 `ToolRegistry` 注册的适配器
|
||||
18. 所有新公开 API 有文档注释
|
||||
19. 测试覆盖率:`cargo test` 全部通过
|
||||
20. `BaseTool::execute()` 签名通过 `ToolContext` 参数预留了扩展点(session_id、cancellation_token),未来 Skill/Agent 层可在不修改 trait 签名的情况下注入上下文
|
||||
@@ -0,0 +1,655 @@
|
||||
# 记忆系统设计方案
|
||||
|
||||
> 设计日期:2026-06-07
|
||||
> 状态:待实现
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
AG Core 已完成 Phase 0(LLM 调用周期)、Phase 1(提示词工程)、Phase 2(工具系统)。Phase 3 的目标是构建记忆系统,为 Phase 4(Agent 运行时)提供记忆存储、管理与检索能力。
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
提供一套可插拔的记忆抽象层,支持以下记忆形态:
|
||||
|
||||
- **对话记忆(ConversationMemory)** — 管理多轮对话消息历史,支持 sliding window / 全量策略
|
||||
- **知识库(KnowledgeStore)** — 基于 LLM Wiki 模式的结构化知识管理,Agent 可自主编译和维护知识页面
|
||||
- **检索器(MemoryRetriever)** — 单通道关键词检索,提供统一的记忆查找入口
|
||||
|
||||
### 1.3 设计原则
|
||||
|
||||
- **不引入 embedding 依赖** — 采用 Karpathy's LLM Wiki 模式的 index + keyword 检索,替代传统向量检索
|
||||
- **trait + 轻量默认实现** — 存储抽象接口提供纯内存默认实现(InMemoryStore),满足原型和测试需求
|
||||
- **模块间松耦合** — 记忆系统与 LlmCycle 的集成推迟到 Phase 4 Agent Runtime,Phase 3 只定义接口和数据操作
|
||||
|
||||
---
|
||||
|
||||
## 2. 需求分析
|
||||
|
||||
### 2.1 功能需求
|
||||
|
||||
| ID | 需求 | 优先级 | 说明 |
|
||||
|----|------|--------|------|
|
||||
| F1 | MemoryStore 通用键值存储 | P0 | save/get/delete/list |
|
||||
| F2 | 对话消息管理 | P0 | 按 session 管理,支持 sliding window / full |
|
||||
| F3 | 知识页面 CRUD | P1 | 创建/更新/删除/检索知识页面 |
|
||||
| F4 | 知识页面关键词检索 | P1 | 基于标题/摘要/标签的关键词匹配 |
|
||||
| F5 | 知识页面索引维护 | P1 | 维护可遍历的内容目录(index) |
|
||||
| F6 | 可插拔后端 | P0 | MemoryStore 通过 trait 抽象,下游可实现自定义后端 |
|
||||
| F7 | 记忆淘汰 | P1 | 支持 TTL 过期淘汰、容量上限淘汰 |
|
||||
| F8 | 消息条目级淘汰 | P1 | ConversationMemory 达到上限后删除最旧消息 |
|
||||
|
||||
### 2.2 非功能需求
|
||||
|
||||
| ID | 需求 | 说明 |
|
||||
|----|------|------|
|
||||
| NF1 | 零 embedding 依赖 | 核心库不引入任何向量数据库或 embedding 模型依赖 |
|
||||
| NF2 | 错误体系完善 | MemoryError 枚举,支持 is_recoverable() 分类 |
|
||||
| NF3 | 线程安全 | 所有存储实现满足 Send + Sync |
|
||||
| NF4 | 异步 API | 所有 IO 操作为 async |
|
||||
| NF5 | 模块化 | 各组件独立可替换 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 方案设计
|
||||
|
||||
### 3.1 总体架构
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Retrieval["检索层"]
|
||||
MR["MemoryRetriever"]
|
||||
end
|
||||
subgraph Logic["逻辑层"]
|
||||
CM["ConversationMemory"]
|
||||
KS["KnowledgeStore"]
|
||||
end
|
||||
subgraph Storage["存储层"]
|
||||
MS["MemoryStore (trait)"]
|
||||
IMS["InMemoryStore (默认)"]
|
||||
end
|
||||
|
||||
MR --> KS
|
||||
CM --> MS
|
||||
KS --> MS
|
||||
MS --> IMS
|
||||
```
|
||||
|
||||
### 3.2 模块结构
|
||||
|
||||
```
|
||||
src/
|
||||
memory.rs # 模块根:pub mod + pub use 重导出
|
||||
memory/
|
||||
store.rs # MemoryStore trait + InMemoryStore
|
||||
conversation.rs # ConversationMemory(对话管理)
|
||||
knowledge.rs # KnowledgeStore(具体 struct)
|
||||
retriever.rs # MemoryRetriever(单通道检索)
|
||||
error.rs # MemoryError
|
||||
types.rs # 核心数据类型
|
||||
```
|
||||
|
||||
### 3.3 接口定义
|
||||
|
||||
#### MemoryStore — 底层存储抽象
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait MemoryStore: Send + Sync {
|
||||
/// 保存/覆盖一个 MemoryItem(upsert 语义)。
|
||||
/// - 如果 id 不存在,则插入新条目
|
||||
/// - 如果 id 已存在,则覆盖旧条目
|
||||
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>;
|
||||
}
|
||||
```
|
||||
|
||||
#### InMemoryStore — 默认实现
|
||||
|
||||
```rust
|
||||
pub struct InMemoryStore {
|
||||
items: Mutex<HashMap<String, MemoryItem>>,
|
||||
}
|
||||
```
|
||||
|
||||
基于 `HashMap<String, MemoryItem>` + `Mutex`,纯内存,线程安全。
|
||||
|
||||
#### ConversationMemory — 对话记忆
|
||||
|
||||
```rust
|
||||
pub struct ConversationMemory {
|
||||
store: Arc<dyn MemoryStore>,
|
||||
session_id: String,
|
||||
config: ConversationMemoryConfig,
|
||||
messages: Vec<OpenaiChatMessage>, // 热缓存,供 compact 直接操作
|
||||
compact_state: CompactState, // 断路器状态
|
||||
}
|
||||
|
||||
pub struct ConversationMemoryConfig {
|
||||
pub strategy: MemoryStrategy, // sliding_window | full
|
||||
pub max_turns: usize, // sliding window 的最大轮数
|
||||
pub compact_config: Option<CompactConfig>, // 复用现有压缩配置
|
||||
}
|
||||
```
|
||||
|
||||
- `add_message(msg)` 写入热缓存 `self.messages`,同时通过 `store.save()` 持久化到后端
|
||||
- `get_history()` 优先从热缓存返回,缓存未命中时从 store 恢复
|
||||
- `compact` 直接在 `self.messages` 上调用 `should_compact()` 和 `microcompact()`
|
||||
- 压缩后同步回 `store`
|
||||
- 使用了 `llm::types::OpenaiChatMessage` 作为内部消息类型
|
||||
- 复用现有 `CompactConfig`(context_window, reserved_tokens, keep_recent)和 `CompactState`
|
||||
|
||||
#### KnowledgeStore — 具体 struct
|
||||
|
||||
```rust
|
||||
pub struct KnowledgeStore {
|
||||
store: Arc<dyn MemoryStore>,
|
||||
index: Mutex<Vec<PageIndexEntry>>,
|
||||
}
|
||||
|
||||
impl KnowledgeStore {
|
||||
pub fn new(store: Arc<dyn MemoryStore>) -> Self { ... }
|
||||
pub async fn add_page(&self, page: KnowledgePage) -> Result<(), MemoryError> { ... }
|
||||
pub async fn get_page(&self, id: &str) -> Result<Option<KnowledgePage>, MemoryError> { ... }
|
||||
pub async fn update_page(&self, page: KnowledgePage) -> Result<(), MemoryError> { ... }
|
||||
pub async fn delete_page(&self, id: &str) -> Result<(), MemoryError> { ... }
|
||||
pub async fn search(&self, query: &str) -> Result<Vec<KnowledgePage>, MemoryError> { ... }
|
||||
pub async fn get_index(&self) -> Result<Vec<PageIndexEntry>, MemoryError> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
- `search()` 优先搜索 index(标题/摘要/标签),全文 content 搜索走 MemoryStore
|
||||
- index 在 add/update/delete 时自动维护,也支持通过 `rebuild_index()` 手动重建
|
||||
- `KnowledgeStore` 以 `"knowledge_{page_id}"` 格式作为 `MemoryItem.id` 前缀,前缀字符串提取为常量 `const KNOWLEDGE_PREFIX: &str = "knowledge_"`
|
||||
|
||||
提供 `rebuild_index()` 方法修复 index 与 store 的不同步问题:
|
||||
|
||||
```rust
|
||||
impl KnowledgeStore {
|
||||
/// 从 MemoryStore 重建 index(修复 index 与 store 的不同步问题)
|
||||
pub async fn rebuild_index(&self) -> Result<(), MemoryError> {
|
||||
let items = self.store.list(&MemoryFilter {
|
||||
prefix: Some("knowledge_".into()),
|
||||
since: None,
|
||||
offset: None,
|
||||
limit: None,
|
||||
}).await?;
|
||||
let mut index = self.index.lock();
|
||||
index.clear();
|
||||
for item in items {
|
||||
let page: KnowledgePage = serde_json::from_str(&item.content)
|
||||
.map_err(|e| MemoryError::Serialization(e.to_string()))?;
|
||||
index.push(PageIndexEntry {
|
||||
id: page.id.clone(),
|
||||
title: page.title,
|
||||
summary: page.summary,
|
||||
tags: page.tags,
|
||||
updated_at: page.updated_at,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### MemoryRetriever — 简化版检索器
|
||||
|
||||
```rust
|
||||
pub struct MemoryRetriever {
|
||||
knowledge_store: KnowledgeStore,
|
||||
config: RetrieverConfig,
|
||||
}
|
||||
|
||||
pub struct RetrieverConfig {
|
||||
pub max_results: usize, // 默认 20
|
||||
pub min_score: f32, // 默认 0.1
|
||||
}
|
||||
|
||||
pub struct RetrievalResult {
|
||||
pub items: Vec<ScoredItem>,
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
pub struct ScoredItem {
|
||||
pub page: KnowledgePage,
|
||||
pub score: f32, // TextOverlap 评分 [0.0, 1.0]
|
||||
}
|
||||
```
|
||||
|
||||
检索流程:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["输入: query"]
|
||||
B["1. 关键词提取(split + 过滤停用词)"]
|
||||
C["2. KnowledgeStore.search(keywords)"]
|
||||
D["3. TextOverlap 评分"]
|
||||
E["4. 过滤 score < min_score"]
|
||||
F["5. 降序排序 → 截取 top-N"]
|
||||
G["6. 返回 RetrievalResult"]
|
||||
|
||||
A --> B
|
||||
B --> C
|
||||
C --> D
|
||||
D --> E
|
||||
E --> F
|
||||
F --> G
|
||||
```
|
||||
|
||||
关键词提取在 MemoryRetriever 内部简单实现:按空格/标点分割 → 过滤单字符和停用词 → 返回关键词列表。TextOverlap 计算 query 与页面标题/摘要/内容的 n-gram 重叠度(基于 Dice 系数)。
|
||||
|
||||
TextOverlap 评分基于 Dice 系数(字符 bigram):
|
||||
|
||||
dice(query, text) = 2 × |bigrams(query) ∩ bigrams(text)| / (|bigrams(query)| + |bigrams(text)|)
|
||||
|
||||
多字段加权:
|
||||
score = title_dice × 0.5 + summary_dice × 0.3 + content_dice × 0.2
|
||||
|
||||
中文场景退化:当前版本按字符级 bigram 处理中文,不依赖分词器。
|
||||
|
||||
> **已知限制**:关键词提取基于空格/标点分割,对中文不做语义分词。
|
||||
> 中文场景按字符 bigram 参与 TextOverlap 计算,精度低于专业分词方案。
|
||||
> 如有更高精度需求,可替换 MemoryRetriever 的关键词提取逻辑。
|
||||
|
||||
### 3.4 核心数据类型
|
||||
|
||||
```rust
|
||||
pub struct MemoryItem {
|
||||
pub id: String,
|
||||
pub content: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub created_at: time::OffsetDateTime,
|
||||
}
|
||||
|
||||
pub struct MemoryFilter {
|
||||
pub prefix: Option<String>,
|
||||
pub since: Option<time::OffsetDateTime>,
|
||||
pub offset: Option<usize>, // 跳过前 N 条
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
pub struct KnowledgePage {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
pub content: String,
|
||||
pub tags: Vec<String>,
|
||||
pub references: Vec<String>,
|
||||
pub created_at: time::OffsetDateTime,
|
||||
pub updated_at: time::OffsetDateTime,
|
||||
}
|
||||
|
||||
pub struct PageIndexEntry {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
pub tags: Vec<String>,
|
||||
pub updated_at: time::OffsetDateTime,
|
||||
}
|
||||
|
||||
pub enum MemoryStrategy {
|
||||
SlidingWindow,
|
||||
Full,
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 错误类型
|
||||
|
||||
```rust
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MemoryError {
|
||||
#[error("Item not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Storage error: {0}")]
|
||||
Storage(String),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(String),
|
||||
|
||||
#[error("Invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
|
||||
#[error("Retrieval error: {0}")]
|
||||
RetrievalError(String),
|
||||
}
|
||||
|
||||
impl MemoryError {
|
||||
pub fn is_recoverable(&self) -> bool {
|
||||
matches!(self, Self::NotFound(_) | Self::RetrievalError(_))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.6 ConversationMemory 与 compact 模块的集成
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph CM["ConversationMemory"]
|
||||
A["add_message(msg)"]
|
||||
A1["self.messages.push(msg) ← 热缓存"]
|
||||
A2["store.save(to_item(msg)) ← 冷持久化"]
|
||||
B{"len(messages) > max_turns?"}
|
||||
C["should_compact(&messages, &config, &state) ← 直接在热缓存上操作"]
|
||||
D["microcompact(&mut messages, keep_recent) ← 复用 microcompact()"]
|
||||
E["sync_to_store() ← 压缩后同步回 store"]
|
||||
F["return"]
|
||||
G["get_history()"]
|
||||
H["从 self.messages 返回"]
|
||||
I["从 store 恢复 → 重建热缓存"]
|
||||
|
||||
A --> A1
|
||||
A1 --> A2
|
||||
A2 --> B
|
||||
B -->|是| C
|
||||
C --> D
|
||||
D --> E
|
||||
E --> F
|
||||
B -->|否| F
|
||||
G --> H
|
||||
H -->|缓存未命中| I
|
||||
end
|
||||
J["依赖: llm::compact::{CompactConfig, CompactState, should_compact, microcompact}"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 物理存储策略
|
||||
|
||||
### 4.1 存储层次
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph RetrievalLayer["检索层"]
|
||||
MR["MemoryRetriever (检索 + 评分,无状态)"]
|
||||
end
|
||||
subgraph AbstractionLayer["存储抽象层"]
|
||||
ABS["MemoryStore\n(存储抽象接口,不感知存储介质)"]
|
||||
end
|
||||
subgraph ImplementationLayer["实现层"]
|
||||
IMS["InMemoryStore (HashMap)\n进程内 volatile\n测试/原型适用"]
|
||||
CUSTOM["下游自定义实现\nFileStore / SqliteStore / RedisStore / ...\n生产环境适用"]
|
||||
end
|
||||
|
||||
MR --> ABS
|
||||
ABS --> IMS
|
||||
ABS --> CUSTOM
|
||||
```
|
||||
|
||||
### 4.2 InMemoryStore 的物理存储
|
||||
|
||||
| 组件 | 数据结构 | 存储位置 | 持久化 | 生命周期 |
|
||||
|------|---------|---------|--------|---------|
|
||||
| `InMemoryStore` | `HashMap<String, MemoryItem>` | 进程堆内存 | ❌ | 随进程销毁 |
|
||||
| `KnowledgeStore` | 基于 `InMemoryStore` + `Vec<PageIndexEntry>` | 进程堆内存 | ❌ | 随进程销毁 |
|
||||
|
||||
**适用场景:**
|
||||
- 单元测试和集成测试
|
||||
- 本地快速原型开发
|
||||
- 单次会话的临时 Agent
|
||||
|
||||
**不适合场景:**
|
||||
- 生产部署
|
||||
- 需要跨进程/跨会话共享记忆
|
||||
- 需要数据持久化和恢复
|
||||
|
||||
### 4.3 持久化存储方案(下游实现)
|
||||
|
||||
agcore 核心库**不内置**持久化实现,用户通过实现 `MemoryStore` trait 对接所需后端:
|
||||
|
||||
| 后端 | 实现建议 | 适用场景 | 复杂度 |
|
||||
|------|---------|---------|--------|
|
||||
| **JSON 文件** | MemoryStore trait → 序列化为单文件 JSON | 单机、轻量持久化 | 低 |
|
||||
| **SQLite** | MemoryStore → 关系表 | 单机、中小规模 | 中 |
|
||||
| **PostgreSQL** | MemoryStore → 关系表 | 多进程共享、中等规模 | 中 |
|
||||
| **Redis** | MemoryStore → Hash/JSON 类型 | 高速缓存、会话共享 | 低 |
|
||||
|
||||
### 4.4 对下游实现的约束
|
||||
|
||||
`MemoryStore` trait 对持久化实现无特殊约束:
|
||||
- 方法签名不涉及文件路径、连接字符串等存储细节
|
||||
- 所有方法均为 `async`,持久化实现可自由选择同步(`spawn_blocking`)或异步 driver
|
||||
- 初始化参数在具体实现的构造函数中注入
|
||||
|
||||
### 4.5 序列化
|
||||
|
||||
核心类型均实现 `Serialize` / `Deserialize`(通过 `#[derive(serde)]`),便于持久化实现直接复用:
|
||||
|
||||
```rust
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct MemoryItem { ... }
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct KnowledgePage { ... }
|
||||
// ...
|
||||
```
|
||||
|
||||
所有类型基于 `serde_json::Value` 作为 metadata 类型,不引入 protobuf/msgpack 等序列化框架。
|
||||
|
||||
---
|
||||
|
||||
## 5. 淘汰策略
|
||||
|
||||
### 5.1 问题
|
||||
|
||||
所有存储组件如果不设上限,会随运行时间无限增长。ConversationMemory 当前的 sliding window 只做 tool result 压缩,不删除消息条目。
|
||||
|
||||
### 5.2 淘汰策略
|
||||
|
||||
在 `MemoryStore` trait 层提供可选的淘汰配置,上层组件按需设置:
|
||||
|
||||
```rust
|
||||
pub struct EvictionConfig {
|
||||
pub policy: EvictionPolicy,
|
||||
pub check_interval: usize, // 每写入 N 条后检查一次淘汰条件
|
||||
}
|
||||
|
||||
pub enum EvictionPolicy {
|
||||
None, // 不淘汰(默认)
|
||||
Ttl { ttl_secs: u64 }, // 超过存活时间淘汰
|
||||
Capacity { max_items: usize },// 超过容量淘汰最旧
|
||||
}
|
||||
```
|
||||
|
||||
`InMemoryStore` 在 `save()` 后检查淘汰条件:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["save(item)"]
|
||||
B["items.insert(id, item)"]
|
||||
C{"writes_since_last_check >= check_interval?"}
|
||||
D{"policy 类型"}
|
||||
E["Ttl → items.retain(created_at > cutoff)"]
|
||||
F["Capacity → 按 created_at 升序排列,截断到 max_items"]
|
||||
G["None → 不淘汰"]
|
||||
|
||||
A --> B
|
||||
B --> C
|
||||
C -->|是| D
|
||||
C -->|否| G
|
||||
D -->|Ttl| E
|
||||
D -->|Capacity| F
|
||||
D -->|None| G
|
||||
```
|
||||
|
||||
### 5.3 各组件淘汰策略
|
||||
|
||||
| 组件 | 推荐策略 | 理由 |
|
||||
|------|---------|------|
|
||||
| **ConversationMemory** | `Capacity { max_items }` | 对话是流式的,旧消息价值递减,达到上限后淘汰最旧的消息条目 |
|
||||
| **MemoryStore**(通用) | `Ttl { ttl_secs }` | 通用存储由调用方按场景决定 |
|
||||
| **KnowledgeStore** | `None`(默认不淘汰) | 知识是累积的,新增不淘汰旧 |
|
||||
|
||||
### 5.4 ConversationMemory 淘汰行为
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["add_message(msg)"]
|
||||
B["store.save(msg)"]
|
||||
C{"eviction.policy == Capacity?"}
|
||||
D["history = store.list(session)"]
|
||||
E{"while history.len() > max_items"}
|
||||
F["oldest = history.remove(0) ← 删除最旧消息"]
|
||||
G["store.delete(oldest.id)"]
|
||||
H["maybe_compact() ← 复用 microcompact() 做内容压缩"]
|
||||
I["return"]
|
||||
|
||||
A --> B
|
||||
B --> C
|
||||
C -->|是| D
|
||||
D --> E
|
||||
E -->|是| F
|
||||
F --> G
|
||||
G --> E
|
||||
E -->|否| H
|
||||
C -->|否| H
|
||||
H --> I
|
||||
```
|
||||
|
||||
两种机制分层:
|
||||
- **淘汰(eviction)**:删除整条消息,控制条目总数上限
|
||||
- **压缩(compaction)**:压缩剩余消息的 tool result 内容,节省 token
|
||||
|
||||
### 5.5 InMemoryStore 的淘汰实现
|
||||
|
||||
```rust
|
||||
impl InMemoryStore {
|
||||
pub fn with_eviction(config: EvictionConfig) -> Self { ... }
|
||||
}
|
||||
|
||||
// 在 save() 内部:
|
||||
async fn save(&self, item: MemoryItem) -> Result<(), MemoryError> {
|
||||
self.items.lock().insert(item.id.clone(), item);
|
||||
self.maybe_evict().await;
|
||||
}
|
||||
|
||||
async fn maybe_evict(&self) {
|
||||
match &self.eviction.policy {
|
||||
EvictionPolicy::Ttl { ttl_secs } => {
|
||||
let cutoff = Utc::now() - Duration::seconds(*ttl_secs as i64);
|
||||
self.items.lock().retain(|_, v| v.created_at > cutoff);
|
||||
}
|
||||
EvictionPolicy::Capacity { max_items } => {
|
||||
let mut items = self.items.lock();
|
||||
if items.len() > *max_items {
|
||||
let mut vec: Vec<_> = items.drain().collect();
|
||||
vec.select_nth_unstable_by(
|
||||
*max_items,
|
||||
|a, b| b.1.created_at.cmp(&a.1.created_at),
|
||||
);
|
||||
vec.truncate(*max_items);
|
||||
*items = vec.into_iter().collect();
|
||||
}
|
||||
}
|
||||
EvictionPolicy::None => {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 实现计划
|
||||
|
||||
### Step 1:基础类型 + MemoryStore(含淘汰机制)
|
||||
|
||||
**文件**:`src/memory.rs`、`src/memory/types.rs`、`src/memory/error.rs`、`src/memory/store.rs`
|
||||
|
||||
- 创建 `memory.rs` + `memory/` 目录
|
||||
- 定义 `MemoryItem`、`MemoryFilter`、`MemoryStrategy` 类型
|
||||
- 定义 `MemoryError` 枚举
|
||||
- 定义 `MemoryStore` trait 与 `InMemoryStore` 实现
|
||||
- 定义 `EvictionConfig`、`EvictionPolicy`(None / Ttl / Capacity)
|
||||
- `InMemoryStore.save()` 内部实现淘汰检查
|
||||
- 单元测试:TTL 过期淘汰、容量上限淘汰、不淘汰(None)
|
||||
- 验收:`cargo build` + `cargo test` 通过
|
||||
|
||||
**依赖**:time(日期时间,启用 `serde` feature)、serde(序列化)
|
||||
|
||||
### Step 2:ConversationMemory(含消息淘汰)
|
||||
|
||||
**文件**:`src/memory/conversation.rs`
|
||||
|
||||
- 定义 `ConversationMemoryConfig`、`MemoryStrategy`
|
||||
- 实现 `ConversationMemory`
|
||||
- `add_message()` → 写入 MemoryStore → 触发容量淘汰(删除最旧消息)
|
||||
- 复用 `llm::compact` 的 `CompactConfig` 和 `microcompact()` 做内容压缩
|
||||
- 单元测试:sliding window 消息淘汰、tool result 内容压缩、full 模式、空 session
|
||||
- 验收:`cargo build` + `cargo test` 通过
|
||||
|
||||
**依赖**:MemoryStore(含 EvictionConfig)+ llm::compact
|
||||
|
||||
### Step 3:KnowledgeStore
|
||||
|
||||
**文件**:`src/memory/knowledge.rs`
|
||||
|
||||
- 定义 `KnowledgePage`、`PageIndexEntry` 类型
|
||||
- 实现 `KnowledgeStore` 具体 struct(非 trait)
|
||||
- 内部使用 `Arc<dyn MemoryStore>` 存储数据
|
||||
- index 自动维护(add/update/delete 时同步)
|
||||
- search 基于标题/摘要/标签的关键词匹配
|
||||
- 单元测试:页面 CRUD、index 一致性、搜索
|
||||
- 验收:`cargo build` + `cargo test` 通过
|
||||
|
||||
### Step 4:MemoryRetriever + 模块整合
|
||||
|
||||
**文件**:`src/memory/retriever.rs`、`src/memory.rs`
|
||||
|
||||
- 实现内存检索器 `MemoryRetriever`
|
||||
- 内部关键词提取:split + 过滤停用词
|
||||
- TextOverlap 评分:基于 Dice 系数计算 query 与页面的文本重叠度
|
||||
- 阈值过滤 → 排序 → 截取 top-N
|
||||
- 在 `memory.rs` 中用 `pub use` 分层重导出:
|
||||
- 高频类型(大多数下游需要):`MemoryStore`、`InMemoryStore`、`ConversationMemory`、`KnowledgeStore`、`MemoryRetriever`、`MemoryError`
|
||||
- 低频类型(配置/高级使用):`MemoryItem`、`MemoryFilter`、`MemoryStrategy`、`KnowledgePage`、`PageIndexEntry`、`EvictionConfig`、`EvictionPolicy`、`ConversationMemoryConfig`、`RetrieverConfig`、`RetrievalResult`、`ScoredItem`
|
||||
- 在 `src/lib.rs` 中声明 `pub mod memory`
|
||||
- 单元测试:关键词提取、TextOverlap 评分正确性、阈值过滤、排序正确性
|
||||
- 集成测试:端到端检索流程
|
||||
- 验收:`cargo build` + `cargo test` 通过
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险评估
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| KnowledgeStore 的 keyword 检索在大规模下效率低 | 中 | 中 | MemoryStore 实现可替换——下游可使用 SQLite FTS 等更高效的后端 |
|
||||
| ConversationMemory 与 compact 耦合引入循环依赖 | 低 | 高 | 仅引用 `CompactConfig`(纯数据结构)和 `microcompact()`(纯函数),不引用 cycle.rs |
|
||||
| time 增加依赖体积 | 低 | 低 | time 是 Rust 官方维护的时间库,体积小于 chrono |
|
||||
| Phase 4 集成时发现 Memory 设计不合理 | 低 | 高 | 按最小可行接口设计,预留扩展空间 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
- [ ] `MemoryStore` trait + `InMemoryStore` 通过单元测试
|
||||
- [ ] `EvictionConfig` 支持 None / Ttl / Capacity 三种策略
|
||||
- [ ] `InMemoryStore` 在 save() 后正确执行 TTL 淘汰和容量淘汰
|
||||
- [ ] `ConversationMemory` 支持 sliding window 和 full 两种策略
|
||||
- [ ] `ConversationMemory` sliding window 模式下达到上限后删除最旧消息条目
|
||||
- [ ] `ConversationMemory` 正确复用 `llm::compact` 的压缩逻辑
|
||||
- [ ] `KnowledgeStore` 支持页面 CRUD 和 index 维护
|
||||
- [ ] `MemoryRetriever` 支持基于 TextOverlap 的知识检索
|
||||
- [ ] 无 embedding 相关依赖
|
||||
- [ ] 模块结构:`memory.rs` + `memory/` 目录 + `pub use` 重导出
|
||||
- [ ] `MemoryError` 枚举完善,支持 `is_recoverable()`
|
||||
- [ ] 所有公开 API 有文档注释(`///`)
|
||||
- [ ] `cargo build` 和 `cargo test` 通过
|
||||
- [ ] 单个文件不超过 300 行
|
||||
|
||||
---
|
||||
|
||||
## 附录:与 Karpathy's LLM Wiki 的关系
|
||||
|
||||
本方案受 Karpathy's LLM Wiki 模式启发,但做了一些调整以适应 Agent 核心库的定位:
|
||||
|
||||
| Karpathy LLM Wiki | AG Core Memory System | 差异原因 |
|
||||
|-------------------|----------------------|---------|
|
||||
| 三层:Raw → Wiki → Schema | 三组件:MemoryStore → ConversationMemory + KnowledgeStore + MemoryRetriever | Agent 场景需要区分对话记忆和知识记忆 |
|
||||
| index.md + log.md | PageIndexEntry(同 index.md)+ 无 log(Phase 4 Agent 负责) | 日志是工作流层职责,非存储层 |
|
||||
| LLM Agent 全权维护 | KnowledgeStore 提供数据接口,Phase 4 Agent 编排工作流 | core 只提供存储能力,不编排 |
|
||||
| 文件系统为后端 | MemoryStore trait 抽象后端 | 可插拔设计需要 trait 抽象 |
|
||||
| 基于文件系统搜索 | index + keyword 检索 | 文件系统搜索不适合所有后端 |
|
||||
@@ -0,0 +1,884 @@
|
||||
# Agent Runtime 方案设计
|
||||
|
||||
> 设计日期:2026-06-09
|
||||
> 状态:待实施
|
||||
> 关联文档:
|
||||
> - `docs/note-agent-runtime-design.md` — 设计决策记录(接口签名、文件清单、决策依据)
|
||||
> - `docs/note-agent-harness-references.md` — 参考项目调研(OpenClaw / Hermes / OpenHuman / OpenHarness)
|
||||
> - `docs/6-memory-system.md` — Phase 3 方案
|
||||
> - `docs/5-tool-system.md` — Phase 2 方案
|
||||
> - `docs/roadmap.md` — 项目总 Roadmap
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
AG Core 已完成 Phase 0(LLM 调用周期)、Phase 1(提示词工程)、Phase 2(工具系统)、Phase 3(记忆系统)共 4 个 phase 的交付。`LlmCycle::submit_with_tools()` 已在 Phase 2 末实现"LLM 决策 → 工具执行 → 回传结果"的单次循环;`ConversationMemory` / `KnowledgeStore` / `MemoryRetriever` 在 Phase 3 提供了完整的记忆抽象。
|
||||
|
||||
当前缺一个**整合层**:把 Phase 0-3 的能力"装配"起来,对上层应用暴露"智能体"的概念。
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
Phase 4 整体目标是提供一个**薄胶水层 + 一组 trait 抽象**,让上层应用可以基于 AG Core 构建多轮对话、任务规划等智能体行为。为控制 scope、降低交付风险,拆分为三个子阶段实施:
|
||||
|
||||
| 子阶段 | 定位 | 交付物 |
|
||||
|--------|------|--------|
|
||||
| **Phase 4a(核心胶水层)** | 最小可用 Agent Runtime | `Agent` + `AgentSession` + `submit_turn` + `RuntimeBundle` / `AgentBuilder` / `AgentError` + `Plan`/`Step` 纯数据 + hooks 扩展 |
|
||||
| **Phase 4b(任务执行)** | 自主任务规划与执行 | `TaskAgent` + `PlanParser` trait + `JsonPlanParser` + `OnPlanStepComplete` hook |
|
||||
| **Phase 4c(会话级记忆)** | 跨 context 信息桥接 | `SessionMemory`(基于 `MemoryStore`)+ AgentSession 接入 + builder 支持 |
|
||||
|
||||
**每个子阶段独立交付**,Phase 4a 完成后上层即可接入;Phase 4b/4c 无相互依赖,可并行或按需延后。
|
||||
|
||||
Phase 4a 具体包括:
|
||||
|
||||
- **`Agent` trait** — 智能体的"角色"抽象(不绑定 session)
|
||||
- **`AgentSession` struct** — 智能体的"会话"实例(绑定 session_id + 状态)
|
||||
- **`RuntimeBundle`** — 显式依赖注入容器,集中管理 provider/registry/hook 等依赖
|
||||
- **`AgentBuilder`** — 链式构造入口
|
||||
- **`AgentError`** — 统一错误类型,聚合 LlmError / ToolError / MemoryError
|
||||
- **`Plan` / `Step` / `StepStatus`** — 任务规划纯数据结构(不做解析逻辑)
|
||||
|
||||
Phase 4b 追加:
|
||||
|
||||
- **`TaskAgent` trait** — 任务型智能体的"规划/执行"抽象
|
||||
- **`PlanParser` trait + `JsonPlanParser`** — Plan 解析接口与参考实现
|
||||
|
||||
Phase 4c 追加:
|
||||
|
||||
- **`SessionMemory`** — 会话级记忆,用于 context 间的信息桥接(基于 `MemoryStore` 后端)
|
||||
|
||||
### 1.3 设计原则
|
||||
|
||||
Phase 4 严格遵循以下原则,所有范围决策都基于这些原则推导:
|
||||
|
||||
| 原则 | 含义 | 推导 |
|
||||
|------|------|------|
|
||||
| **最小范围** | AG Core 是 lib crate,不是产品;不实现业务循环 | 只暴露 trait + 最小 reference impl |
|
||||
| **薄胶水层** | 不在 L1 重写已经做好的能力 | 复用 `LlmCycle::submit_with_tools` 等已有 API |
|
||||
| **依赖注入** | 所有运行时依赖显式打包传递 | 采用 OpenHarness `RuntimeBundle` 模式 |
|
||||
| **实体/会话分离** | 同一角色可被多 session 复用 | `Agent` + `AgentSession` 两层模型 |
|
||||
| **记忆弱引用** | 记忆是"被动能力",不内嵌循环 | `memory_store: Option<Arc<dyn MemoryStore>>` 弱引用 |
|
||||
| **业务可注入** | Plan 拆解是业务能力,不在 core 库实现 | 暴露 `PlanParser` trait,上层注入 |
|
||||
| **会话级记忆** | session 内共享、context 间桥接,不是持久层也不是对话历史 | `SessionMemory` 基于 `MemoryStore`,按 session_id 命名空间隔离 |
|
||||
| **借鉴不照搬** | 4 个参考项目均非 Rust 实现 | 只取架构模式,不抄实现细节 |
|
||||
|
||||
### 1.4 与已完成的 Phase 关系
|
||||
|
||||
```
|
||||
Phase 0 (L0/L1) ── LlmProvider / LlmCycle / Hook / Stream / Compact
|
||||
Phase 1 (L2) ── PromptTemplate / PromptComposer
|
||||
Phase 2 (L1) ── ToolRegistry / BaseTool / PermissionChecker / McpClient
|
||||
Phase 3 (L2) ── MemoryStore / ConversationMemory / KnowledgeStore / MemoryRetriever
|
||||
↑
|
||||
│ 复用
|
||||
│
|
||||
Phase 4a (L1→L2) ── Agent trait + AgentSession + submit_turn + RuntimeBundle + Plan/Step 纯数据(胶水层)
|
||||
Phase 4b (L2) ── TaskAgent + PlanParser + JsonPlanParser(任务执行)
|
||||
Phase 4c (L2) ── SessionMemory(会话级记忆)
|
||||
↓
|
||||
应用层 (L4) ── 上层 crate / 二进制 / Gateway(不在 Phase 4 范围)
|
||||
```
|
||||
|
||||
详细架构对照见 `docs/note-agent-harness-references.md` §3-5。
|
||||
|
||||
## 2. 需求分析
|
||||
|
||||
### 2.1 功能需求
|
||||
|
||||
| ID | 需求 | 优先级 | 归属 | 说明 |
|
||||
|----|------|--------|------|------|
|
||||
| F1 | `Agent` trait 抽象 | P0 | 4a | 角色定义:name / system_prompt / 工具集 |
|
||||
| F2 | `AgentSession` 会话实例 | P0 | 4a | 绑定 session_id、bundle、turn_index、cost_so_far |
|
||||
| F3 | `submit_turn()` 最小 reference impl | P0 | 4a | 组装 LlmCycle → submit → 累计 cost;~30 行 |
|
||||
| F6 | `Plan` / `Step` / `StepStatus` 数据结构 | P0 | 4a | 含 Pending / Running / Completed / Failed / Skipped 状态机 |
|
||||
| F8 | `RuntimeBundle` 依赖注入容器 | P0 | 4a | 聚合 provider/registry/hook/config(不含 session_memory_backend) |
|
||||
| F9 | `AgentBuilder` 链式构造 | P0 | 4a | 构建 `RuntimeBundle`,retriever 存在时自动注册为 tool |
|
||||
| F10 | `AgentError` 统一错误类型 | P0 | 4a | 聚合 LlmError / ToolError / MemoryError,含 `is_recoverable()` |
|
||||
| F11a | Hook 事件扩展:OnTurnStart / OnTurnEnd + turn_index 字段 | P0 | 4a | 在 `llm/hooks.rs` 中追加 2 个事件 + 1 个字段 |
|
||||
| F12a | 烟雾测试 3-4 个(Phase 4a) | P0 | 4a | trait 可装配 / RuntimeBundle 可构造 / submit_turn 跑通 mock / Plan 数据结构 |
|
||||
| F13 | `lib.rs` 导出 `pub mod agent;` | P0 | 4a | 一行 |
|
||||
| F14 | 方案文档(本文件)+ 决策记录 | P0 | — | ✅ 已完成 |
|
||||
| F4 | `TaskAgent::run(goal)` 自主式入口 | P0 | 4b | 内部用 LLM 拆 Plan,再调用 `execute_plan` |
|
||||
| F5 | `TaskAgent::execute_plan(plan)` 外部驱动式入口 | P0 | 4b | 用户预定义 Plan,逐步执行 |
|
||||
| F7 | `PlanParser` trait + `JsonPlanParser` 参考实现 | P0 | 4b | 注入式,上层可替换 |
|
||||
| F11b | Hook 事件扩展:OnPlanStepComplete + plan_step_index 字段 | P0 | 4b | 在 `llm/hooks.rs` 中追加 1 个事件 + 1 个字段 |
|
||||
| F12b | 烟雾测试 2-3 个(Phase 4b) | P0 | 4b | TaskAgent + PlanParser 跑通 mock |
|
||||
| F15a | Roadmap 状态翻转(Phase 4a) | P0 | 4a | 实施完成后做 |
|
||||
| F15b | Roadmap 状态翻转(Phase 4b) | P0 | 4b | 实施完成后做 |
|
||||
| F16 | SessionMemory 会话级记忆 | P0 | 4c | 基于 `MemoryStore`,context 间信息桥接 |
|
||||
| F17 | RuntimeBundle / Builder 扩展 session_memory_backend | P0 | 4c | 追加字段 + setter 方法 |
|
||||
| F18 | AgentSession 接入 SessionMemory | P0 | 4c | 替换内联 HashMap,接入完整 SessionMemory |
|
||||
| F12c | 烟雾测试 2-3 个(Phase 4c) | P0 | 4c | SessionMemory set/get/snapshot |
|
||||
| F15c | Roadmap 状态翻转(Phase 4c) | P0 | 4c | 实施完成后做 |
|
||||
|
||||
### 2.2 非功能需求
|
||||
|
||||
| ID | 需求 | 说明 |
|
||||
|----|------|------|
|
||||
| NF1 | 不引入新外部依赖 | 仅使用 Phase 0-3 已有的 `async-trait` / `serde` / `thiserror` / `tokio` 等 |
|
||||
| NF2 | 错误体系完善 | `AgentError` 聚合下层错误,含 `is_recoverable()` 分类 |
|
||||
| NF3 | 线程安全 | 所有公开类型满足 `Send + Sync` |
|
||||
| NF4 | 异步优先 | 涉及 IO 的 API 全部 `async` |
|
||||
| NF5 | 模块化 | 各组件独立可替换,遵循"trait 抽象 + 轻量默认实现"惯例 |
|
||||
| NF6 | 文档注释 | 所有公开 API 必须有 `///` 文档注释 |
|
||||
| NF7 | builder 模式 | 复杂配置走 builder 链式构造 |
|
||||
| NF8 | 显式依赖 | 不引入模块级全局状态,所有依赖通过参数或 bundle 注入 |
|
||||
| NF9 | 不破坏现有 API | Phase 0-3 的公开 API 一字不改;`hooks.rs` 扩展为"追加变体 + 追加字段"(兼容) |
|
||||
| NF10 | 最小测试覆盖 | 核心 trait 至少 1 个烟雾测试;`submit_turn` 至少 1 个 mock 测试;不强求集成测试 |
|
||||
|
||||
## 3. 方案设计
|
||||
|
||||
### 3.1 总体架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ 应用层(不在 Phase 4 范围) │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
|
||||
│ │ CLI Agent │ │ Feishu Bot │ │ Web Service│ │ TUI App │ │
|
||||
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
|
||||
└─────────┼────────────────┼────────────────┼────────────────┼───────────┘
|
||||
│ │ │ │
|
||||
└────────────────┴────────────────┴────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ Agent Runtime(Phase 4) │
|
||||
│ │
|
||||
│ ┌────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Agent trait │ 1 ──── * │ AgentSession │ │
|
||||
│ │ (角色) │ │ (会话实例) │ │
|
||||
│ └────────────────┘ └──────┬───────────┘ │
|
||||
│ │ Arc<...> │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────┐ │
|
||||
│ │ RuntimeBundle │ │
|
||||
│ │ - provider │ │
|
||||
│ │ - tool_registry │ │
|
||||
│ │ - hook_executor │ │
|
||||
│ │ - memory_store? │ ◄─ 弱引用 │
|
||||
│ │ - retriever? │ ◄─ 弱引用 │
|
||||
│ │ - config │ │
|
||||
│ └──────┬───────────┘ │
|
||||
│ │ new() 时若 retriever 存在 │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────┐ │
|
||||
│ │ "retrieve" tool │ ◄─ 自动注册 │
|
||||
│ └──────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ TaskAgent trait│ │ Plan/Step/Status │ │ PlanParser trait │ │
|
||||
│ │ run() │ │ 状态机 │ │ JsonPlanParser │ │
|
||||
│ │ execute_plan()│ │ │ │ (参考实现 ~20行) │ │
|
||||
│ └────────────────┘ └──────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────┐ ┌──────────────────┐ │
|
||||
│ │ AgentError │ │ AgentBuilder │ │
|
||||
│ │ (聚合) │ │ (链式构造) │ │
|
||||
│ └────────────────┘ └──────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼ 复用
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ LLM / Tool / Prompt / Memory(Phase 0-3) │
|
||||
│ LlmCycle / ProviderRegistry / ToolRegistry / PermissionChecker / │
|
||||
│ HookExecutor / StreamEvents / CompactConfig / │
|
||||
│ PromptTemplate / PromptComposer / │
|
||||
│ MemoryStore / ConversationMemory / KnowledgeStore / MemoryRetriever│
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 接口设计
|
||||
|
||||
详细接口签名见 `docs/note-agent-runtime-design.md` §4,本节说明设计意图。
|
||||
|
||||
#### 3.2.1 `Agent` trait
|
||||
|
||||
```rust
|
||||
pub trait Agent: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn system_prompt(&self) -> Option<&str>;
|
||||
/// 列出该 Agent 想要暴露给 LLM 的工具定义。
|
||||
/// 默认实现:从 RuntimeBundle.tool_registry 取全部(最常用)。
|
||||
/// 子 trait 可覆盖做白名单/过滤。
|
||||
fn tool_definitions(&self, bundle: &RuntimeBundle) -> Vec<ToolDefinition>;
|
||||
}
|
||||
```
|
||||
|
||||
**设计意图**:
|
||||
- `name` / `system_prompt` 是 LLM 调用必需的元数据
|
||||
- `tool_definitions` 默认从 bundle 全量取,**Agent 可以在不修改 bundle 的情况下做工具白名单**——这与 Hermes 的"Skill 暴露"机制对齐
|
||||
- 不在 trait 里强制 `submit_turn`——`submit_turn` 是 `AgentSession` 的方法,不应绑死在角色定义上
|
||||
|
||||
#### 3.2.2 `RuntimeBundle`
|
||||
|
||||
```rust
|
||||
pub struct RuntimeBundle {
|
||||
pub provider: Arc<dyn LlmProvider>,
|
||||
pub tool_registry: Arc<ToolRegistry>,
|
||||
pub hook_executor: Arc<HookExecutor>,
|
||||
pub memory_store: Option<Arc<dyn MemoryStore>>, // 弱引用
|
||||
pub retriever: Option<Arc<MemoryRetriever>>, // 弱引用
|
||||
pub session_memory_backend: Option<Arc<dyn MemoryStore>>, // SessionMemory 后端(选填)
|
||||
pub config: AgentConfig,
|
||||
}
|
||||
```
|
||||
|
||||
**设计意图**:
|
||||
- 所有运行时依赖**显式打包**(OpenHarness 风格)
|
||||
- `memory_store` / `retriever` 均为 `Option`——上层应用**不传也能跑**(无记忆模式)
|
||||
- 当 `retriever` 存在时,`RuntimeBundle::new()` 内部自动注册一个名为 `"retrieve"` 的 tool(具体实现:在 `ToolRegistry` 里加一个 `RetrieveTool` 包装),让 LLM 在对话中**主动**调用检索能力
|
||||
- `session_memory_backend` 是 `SessionMemory` 的持久后端。传入时 `SessionMemory` 使用该后端(支持跨进程共享);不传时 `AgentSession` 内部自动创建 `InMemoryStore` 作为进程级隔离的后端
|
||||
- `config` 集中管理所有可调参数(max_turns、max_tool_turns、session_ttl、compact_config)
|
||||
|
||||
#### 3.2.3 `AgentSession` 与最小 reference impl
|
||||
|
||||
```rust
|
||||
pub struct AgentSession {
|
||||
pub session_id: String,
|
||||
pub agent: Arc<dyn Agent>,
|
||||
bundle: Arc<RuntimeBundle>,
|
||||
turn_index: u32,
|
||||
cost_so_far: CostTracker,
|
||||
session_memory: SessionMemory,
|
||||
}
|
||||
|
||||
impl AgentSession {
|
||||
/// 最小 reference impl(约 30 行):
|
||||
/// 1. 触发 OnTurnStart hook
|
||||
/// 2. 组装 LlmCycle(注入 system_prompt + messages 历史 + tool definitions)
|
||||
/// 3. submit_with_tools() 跑单轮对话
|
||||
/// 4. 累计 cost
|
||||
/// 5. 触发 OnTurnEnd hook
|
||||
/// 6. turn_index += 1
|
||||
/// 7. 返回 ChatResponse
|
||||
/// 不做 memory 回写(由上层独立 task 处理)
|
||||
pub async fn submit_turn(
|
||||
&mut self,
|
||||
user_input: impl Into<String>,
|
||||
) -> Result<ChatResponse, AgentError>;
|
||||
}
|
||||
```
|
||||
|
||||
**设计意图**:
|
||||
- `agent: Arc<dyn Agent>` 而非 `agent_name: String`——`submit_turn` 从 agent 获取 `system_prompt()` 和 `tool_definitions()`,同时为 v0.2+ 的"热切换 agent"预留:替换 `self.agent` 即可切换角色
|
||||
- `session_memory` 是进程内共享的会话级记忆,context 间通过它桥接信息(详见 §3.2.8)
|
||||
- "最小 reference impl" 只演示**最常见**的对话场景
|
||||
- 业务循环(多轮策略、错误重试、记忆回写时机)由上层应用或具体的 `TaskAgent` 实现决定
|
||||
- `submit_turn` 不持有 `ConversationMemory`——上层应用可独立 new 一个 `ConversationMemory`,在合适的时机(如 OnTurnEnd hook)调 `add_message`
|
||||
|
||||
#### 3.2.4 `TaskAgent` + `Plan` / `Step`
|
||||
|
||||
```rust
|
||||
pub struct Plan {
|
||||
pub id: String,
|
||||
pub goal: String,
|
||||
pub steps: Vec<Step>,
|
||||
}
|
||||
|
||||
pub struct Step {
|
||||
pub index: usize,
|
||||
pub description: String,
|
||||
pub status: StepStatus,
|
||||
}
|
||||
|
||||
pub enum StepStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed(ChatResponse),
|
||||
Failed(AgentError),
|
||||
Skipped,
|
||||
}
|
||||
```
|
||||
|
||||
**设计意图**:
|
||||
- `StepStatus` 用 enum 而非简单 bool,便于上层 UI 展示和统计
|
||||
- 状态机转换:`Pending → Running → (Completed | Failed | Skipped)`,单向不可回退(重试由上层新建 Plan)
|
||||
- `Plan` / `Step` 故意保持简单——不引入 `dependencies` / `parallel_group` 等高级字段(v0.3+ 再考虑)
|
||||
|
||||
#### 3.2.5 `PlanParser` trait + `JsonPlanParser` 参考实现
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait PlanParser: Send + Sync {
|
||||
async fn parse(&self, raw: &str, goal: &str) -> Result<Plan, AgentError>;
|
||||
}
|
||||
|
||||
pub struct JsonPlanParser;
|
||||
#[async_trait]
|
||||
impl PlanParser for JsonPlanParser {
|
||||
/// 期望 LLM 输出形如:
|
||||
/// {"steps": [{"description": "..."}, ...]}
|
||||
/// 的 JSON 文本。
|
||||
/// 解析失败返回 AgentError::PlanParse。
|
||||
async fn parse(&self, raw: &str, goal: &str) -> Result<Plan, AgentError> { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
**设计意图**:
|
||||
- **注入式**:上层应用可以注入自己的 `PlanParser`(如基于 XML / YAML / 自定义 DSL)
|
||||
- `JsonPlanParser` 是**参考实现**,不是默认实现——上层必须显式选择
|
||||
- `JsonPlanParser` 大约 20 行:`serde_json::from_str` 解析 + 字段映射
|
||||
|
||||
#### 3.2.6 `AgentError`
|
||||
|
||||
```rust
|
||||
pub enum AgentError {
|
||||
Llm(LlmError),
|
||||
Tool(ToolError),
|
||||
Memory(MemoryError),
|
||||
PlanParse(String),
|
||||
HookBlocked(String),
|
||||
LimitExceeded(String),
|
||||
Config(String),
|
||||
Other(String),
|
||||
}
|
||||
```
|
||||
|
||||
**设计意图**:
|
||||
- 聚合而非包装下层错误(避免 `Box<dyn Error>` 丢失类型)
|
||||
- `PlanParse` / `HookBlocked` / `LimitExceeded` / `Config` 是 Agent 层特有的错误类型
|
||||
- `is_recoverable()` 根据变体类型判定(如 `Memory(_)` 可恢复、`PlanParse(_)` 不可恢复)
|
||||
|
||||
#### 3.2.7 `AgentConfig` + `AgentBuilder`
|
||||
|
||||
```rust
|
||||
pub struct AgentConfig {
|
||||
pub max_turns: u32,
|
||||
pub max_tool_turns: u32,
|
||||
pub session_ttl: Option<Duration>,
|
||||
pub compact_config: Option<CompactConfig>,
|
||||
}
|
||||
|
||||
pub struct AgentBuilder { /* ... */ }
|
||||
impl AgentBuilder {
|
||||
pub fn new() -> Self;
|
||||
pub fn provider(self, p: Arc<dyn LlmProvider>) -> Self;
|
||||
pub fn tool_registry(self, r: Arc<ToolRegistry>) -> Self;
|
||||
pub fn hook_executor(self, h: Arc<HookExecutor>) -> Self;
|
||||
pub fn memory_store(self, m: Arc<dyn MemoryStore>) -> Self; // 选填
|
||||
pub fn retriever(self, r: Arc<MemoryRetriever>) -> Self; // 选填
|
||||
pub fn session_memory_backend(self, s: Arc<dyn MemoryStore>) -> Self; // 选填
|
||||
pub fn config(self, c: AgentConfig) -> Self;
|
||||
pub fn build(self) -> Result<RuntimeBundle, AgentError>;
|
||||
}
|
||||
```
|
||||
|
||||
**设计意图**:
|
||||
- `AgentBuilder` 是**唯一**的 `RuntimeBundle` 构造入口
|
||||
- 必填字段在 `build()` 时校验(`provider` / `tool_registry` / `hook_executor` 不可缺)
|
||||
- `memory_store` / `retriever` / `session_memory_backend` 选填
|
||||
- `session_memory_backend` 不传时,`AgentSession` 内部用 `InMemoryStore` 兜底(进程级隔离)
|
||||
|
||||
#### 3.2.8 `SessionMemory` — 会话级记忆
|
||||
|
||||
```rust
|
||||
pub struct SessionMemory {
|
||||
store: Arc<dyn MemoryStore>,
|
||||
namespace: String,
|
||||
}
|
||||
|
||||
impl SessionMemory {
|
||||
/// 创建新的 session 级记忆实例。
|
||||
/// store:后端存储(可跨进程共享的 MemoryStore 实现)。
|
||||
/// namespace:按 session_id 隔离,防止跨 session 泄漏。
|
||||
pub fn new(store: Arc<dyn MemoryStore>, namespace: &str) -> Self;
|
||||
|
||||
/// 写入一条 key-value 条目。
|
||||
pub async fn set(&self, key: &str, value: &str) -> Result<(), AgentError>;
|
||||
|
||||
/// 读取指定 key 的值。
|
||||
pub async fn get(&self, key: &str) -> Result<Option<String>, AgentError>;
|
||||
|
||||
/// 返回所有条目的格式化快照,适合注入 system prompt。
|
||||
/// 格式:
|
||||
/// <session-context>
|
||||
/// key1: value1
|
||||
/// key2: value2
|
||||
/// </session-context>
|
||||
pub async fn snapshot(&self) -> Result<String, AgentError>;
|
||||
|
||||
/// 删除指定 key。
|
||||
pub async fn remove(&self, key: &str) -> Result<(), AgentError>;
|
||||
|
||||
/// 清空当前 namespace 下所有条目。
|
||||
pub async fn clear(&self) -> Result<(), AgentError>;
|
||||
}
|
||||
```
|
||||
|
||||
**设计意图**:
|
||||
- `SessionMemory` 是**会话级**记忆,不是持久层(`MemoryStore`)也不是对话历史(`ConversationMemory`)——它的定位是 session 内各 context 之间的信息桥接
|
||||
- **复用 Phase 3 `MemoryStore` trait**:不引入新的存储后端机制。单进程场景用 `InMemoryStore`(零序列化开销),跨进程场景换 Redis / SQLite 等实现即可
|
||||
- **按 `namespace` 隔离**:每个 session 一个独立命名空间(`"_session_{session_id}"`),避免跨 session 意外泄漏
|
||||
- **`snapshot()` 格式化为标记文本**:专为注入 system prompt 设计,LLM 可以自然理解 `<session-context>` 标签中的内容
|
||||
- **所有方法为 `async`**:因为后端可能是跨进程的(Redis / DB),虽然 `InMemoryStore` 本身是同步操作
|
||||
- **不引入自己的错误类型**:错误通过 `AgentError::Memory` 传播(复用已有变体)
|
||||
|
||||
**三层记忆体系关系**:
|
||||
|
||||
```
|
||||
持久层(Phase 3) MemoryStore / KnowledgeStore ── 跨 session 持久,长期知识
|
||||
会话层(新增) SessionMemory ── 单 session 内共享,context 桥接
|
||||
对话层(Phase 3) ConversationMemory ── 单 context 内消息历史
|
||||
```
|
||||
|
||||
**典型使用模式**(v0.2+ context 切换场景):
|
||||
|
||||
```
|
||||
context_a (build agent)
|
||||
→ 在对话中决定某个关键结论值得记下来
|
||||
→ 调用 session_memory.set("design_decision", "用 PostgreSQL")
|
||||
→ 继续对话
|
||||
|
||||
创建 context_b (plan agent)
|
||||
→ system_prompt 末尾追加 session_memory.snapshot()
|
||||
→ LLM 看到 "<session-context>\ndesign_decision: 用 PostgreSQL\n</session-context>"
|
||||
→ 无需看 context_a 的 50 轮完整历史,但知道关键上下文
|
||||
```
|
||||
|
||||
### 3.3 状态机
|
||||
|
||||
#### 3.3.1 `StepStatus` 状态转换图
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Pending │ ◄── 初始状态
|
||||
└──────┬──────┘
|
||||
│ execute_plan() 进入
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Running │ ◄── 触发 OnPlanStepComplete(status=Running)
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌──────────┐ ┌──────────┐
|
||||
│Completed│ │ Failed │ │ Skipped │
|
||||
└─────────┘ └──────────┘ └──────────┘
|
||||
触发 OnPlanStepComplete(status=Completed)
|
||||
触发 OnPlanStepComplete(status=Failed)
|
||||
触发 OnPlanStepComplete(status=Skipped)
|
||||
```
|
||||
|
||||
**设计约束**:
|
||||
- 状态转换**单向**(Pending → Running → 终态),不回退
|
||||
- 终态(Completed / Failed / Skipped)触发 `OnPlanStepComplete` hook
|
||||
- 重试由上层应用新建 `Plan` 实现(不在 `TaskAgent` 内做自动重试)
|
||||
|
||||
#### 3.3.2 Session 状态
|
||||
|
||||
`AgentSession` 的状态机比 `Step` 简单:
|
||||
|
||||
```
|
||||
创建 (new) ──► turn_index=0 ──► submit_turn() ──► turn_index+=1 ──► ... ──► 销毁
|
||||
```
|
||||
|
||||
`turn_index` 累加,`cost_so_far` 累加,无显式状态枚举(避免过度设计)。
|
||||
|
||||
### 3.4 Hook 扩展设计
|
||||
|
||||
在 `src/llm/hooks.rs` 中追加 3 个事件 + 2 个上下文字段:
|
||||
|
||||
```rust
|
||||
pub enum HookEvent {
|
||||
// ... 现有 4 个:PreRequest / PostRequest / OnRetry / OnError ...
|
||||
|
||||
// 新增 3 个(Phase 4):
|
||||
OnTurnStart,
|
||||
OnTurnEnd,
|
||||
OnPlanStepComplete,
|
||||
}
|
||||
|
||||
pub struct HookContext {
|
||||
// ... 现有字段 ...
|
||||
|
||||
// 新增 2 个(Phase 4):
|
||||
pub turn_index: Option<u32>, // OnTurnStart / OnTurnEnd 用
|
||||
pub plan_step_index: Option<usize>, // OnPlanStepComplete 用
|
||||
}
|
||||
```
|
||||
|
||||
**设计意图**:
|
||||
- **不破坏现有 hook 兼容性**:3 个新事件是 enum 追加,2 个新字段是 `Option<T>` 默认 `None`
|
||||
- 上层应用可通过监听 `OnTurnEnd` 实现"独立 task 回写 ConversationMemory"——呼应"记忆在独立 task 处理"原则
|
||||
- `OnPlanStepComplete` 提供"步骤级别"的可观测性,与 Hermes 的"任务进度回调"对齐
|
||||
|
||||
### 3.5 错误体系
|
||||
|
||||
`AgentError` 与下层错误的关系:
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ AgentError │
|
||||
├──────────────────┤
|
||||
│ Llm(LlmError) │──► 透传 Phase 0 错误,含 is_recoverable()
|
||||
│ Tool(ToolError) │──► 透传 Phase 2 错误,含 is_recoverable()
|
||||
│ Memory(MemoryError)│─► 透传 Phase 3 错误
|
||||
│ PlanParse(String) │─► Agent 层特有
|
||||
│ HookBlocked(String)│─► Agent 层特有
|
||||
│ LimitExceeded(String)│► Agent 层特有
|
||||
│ Config(String) │──► Agent 层特有
|
||||
│ Other(String) │──► 兜底
|
||||
└──────────────────┘
|
||||
│
|
||||
▼
|
||||
is_recoverable(): 聚合判定
|
||||
- Llm/Memory 可恢复(重试)
|
||||
- PlanParse / Config 不可恢复(需人工介入)
|
||||
- Tool / HookBlocked / LimitExceeded 按内层错误判定
|
||||
```
|
||||
|
||||
**自动 From 转换**:通过 `#[from]` 宏实现 `From<LlmError>` / `From<ToolError>` / `From<MemoryError>`,让 `submit_turn` 内部可以用 `?` 运算符直接传播。
|
||||
|
||||
### 3.6 与 Phase 0-3 模块的集成
|
||||
|
||||
| Phase 4 组件 | 调用的下层 API | 调用位置 |
|
||||
|-------------|--------------|---------|
|
||||
| `AgentSession::submit_turn` | `LlmCycle::new` + `with_system_prompt` + `with_hook_executor` + `with_compact_config` + `with_messages` + `submit_with_tools` | session.rs |
|
||||
| `AgentSession::submit_turn` | `CostTracker::add`(累计 cost) | session.rs |
|
||||
| `RuntimeBundle::new` | `ToolRegistry::register`(注册 retrieve tool) | runtime.rs |
|
||||
| `TaskAgent::execute_plan` | `AgentSession::submit_turn`(每步调一次) | task.rs |
|
||||
| `JsonPlanParser::parse` | `serde_json::from_str` | task.rs |
|
||||
| `AgentError::from` | `LlmError` / `ToolError` / `MemoryError` | error.rs |
|
||||
| `HookContext` 扩展 | `HookEvent::OnTurnStart/End/OnPlanStepComplete` | llm/hooks.rs |
|
||||
| `SessionMemory::set/get/snapshot` | `MemoryStore::save/load/search` | session_memory.rs |
|
||||
|
||||
**不调用的下层 API**(明确边界):
|
||||
- ❌ `ConversationMemory`(由上层独立 task 管理)
|
||||
- ❌ `KnowledgeStore`(由上层独立 task 管理)
|
||||
- ❌ `McpClient`(已由 `ToolRegistry` 包装)
|
||||
- ❌ `StreamEvents::submit_stream`(v1 暂不暴露流式 `submit_turn`,v0.2 再说)
|
||||
- ❌ 多 context 切换管理(v0.2+ 实现,Phase 4 只预留 `SessionMemory` 桥接通道)
|
||||
- ❌ `"session_memory_set"` 等 session memory tool 自动注册(v0.2+ 可选)
|
||||
|
||||
## 4. 实施计划
|
||||
|
||||
Phase 4 拆分为三个独立子阶段:**Phase 4a(核心胶水层)** → **Phase 4b(任务执行)** → **Phase 4c(会话级记忆)**。每个子阶段独立交付、独立验证,4b 与 4c 无相互依赖。
|
||||
|
||||
### 4.1 文件清单
|
||||
|
||||
#### 新增文件(9 个)
|
||||
|
||||
```
|
||||
src/agent.rs # [4a] 模块根 + pub use 重导出
|
||||
src/agent/agent.rs # [4a] Agent trait
|
||||
src/agent/runtime.rs # [4a] RuntimeBundle + AgentConfig(不含 session_memory_backend)
|
||||
src/agent/session.rs # [4a] AgentSession(submit_turn + 内联 session_data HashMap)
|
||||
src/agent/task.rs # [4a] Plan / Step / StepStatus 纯数据 / [4b] TaskAgent + PlanParser + JsonPlanParser
|
||||
src/agent/builder.rs # [4a] AgentBuilder(不含 session_memory_backend)
|
||||
src/agent/error.rs # [4a] AgentError(不含 PlanParse 变体)/ [4b] 补充 PlanParse 变体
|
||||
src/agent/session_memory.rs # [4c] SessionMemory(基于 MemoryStore)
|
||||
```
|
||||
|
||||
#### 修改文件(2 个)
|
||||
|
||||
```
|
||||
src/lib.rs # [4a] + pub mod agent;
|
||||
src/llm/hooks.rs # [4a] + 2 事件(OnTurnStart/OnTurnEnd)+ 1 字段(turn_index)
|
||||
# [4b] + 1 事件(OnPlanStepComplete)+ 1 字段(plan_step_index)
|
||||
```
|
||||
|
||||
#### 关联文档(已完成)
|
||||
|
||||
```
|
||||
docs/note-agent-harness-references.md # ✅ 已存在
|
||||
docs/note-agent-runtime-design.md # ✅ 已存在(与本文件配套)
|
||||
docs/7-agent-runtime.md # ✅ 本文件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Phase 4a — 核心胶水层(最小 Agent Runtime)
|
||||
|
||||
**范围**:Agent trait + AgentSession + submit_turn(内联 HashMap session_data)+ RuntimeBundle/AgentBuilder + AgentError + Plan/Step 纯数据 + hooks 扩展(OnTurnStart/OnTurnEnd)
|
||||
|
||||
**任务拆解**:
|
||||
|
||||
| 顺序 | 任务 | 涉及文件 | 验证 |
|
||||
|------|------|---------|------|
|
||||
| a1 | 修改 `llm/hooks.rs` 追加 OnTurnStart / OnTurnEnd + turn_index 字段 | `src/llm/hooks.rs` | `cargo build` 通过;Phase 0 测试不挂 |
|
||||
| a2 | 新建 `agent/error.rs` 定义 `AgentError`(不含 PlanParse 变体) | `src/agent/error.rs` | `cargo build` 通过 |
|
||||
| a3 | 新建 `agent/agent.rs` 定义 `Agent` trait | `src/agent/agent.rs` | `cargo build` 通过 |
|
||||
| a4 | 新建 `agent/runtime.rs` 定义 `RuntimeBundle` + `AgentConfig`(不含 session_memory_backend) | `src/agent/runtime.rs` | `cargo build` 通过 |
|
||||
| a5 | 新建 `agent/builder.rs` 定义 `AgentBuilder`(不含 session_memory_backend 方法) | `src/agent/builder.rs` | `cargo build` 通过 |
|
||||
| a6 | 新建 `agent/session.rs` 定义 `AgentSession` + `submit_turn`(内联 `HashMap<String,String>` 做 session_data,不引 MemoryStore) | `src/agent/session.rs` | `cargo build` 通过 |
|
||||
| a7 | 新建 `agent/task.rs` 定义 `Plan` / `Step` / `StepStatus` 纯数据结构(不含 TaskAgent trait,不含 PlanParser) | `src/agent/task.rs` | `cargo build` 通过 |
|
||||
| a8 | 新建 `src/agent.rs` 模块根 + `pub use` 重导出 + 修改 `lib.rs` | `src/agent.rs` + `src/lib.rs` | `cargo build` 通过 |
|
||||
| a9 | 编写烟雾测试 3-4 个(Agent trait 可装配 / RuntimeBundle 可构造 / submit_turn 跑通 mock / Plan 数据结构) | `src/agent/*.rs` 内联 | `cargo test` 通过 |
|
||||
| a10 | 完整 `cargo test` 跑全量回归 + roadmap.md 状态更新 | — | 所有已有测试不挂 |
|
||||
|
||||
**依赖关系**:
|
||||
|
||||
```
|
||||
hooks扩展 (a1) ──┐
|
||||
├──► agent/error.rs (a2) ──► agent/agent.rs (a3)
|
||||
│ │
|
||||
│ ▼
|
||||
│ agent/runtime.rs (a4)
|
||||
│ │
|
||||
│ ▼
|
||||
│ agent/builder.rs (a5)
|
||||
│ │
|
||||
│ ▼
|
||||
│ agent/session.rs (a6)
|
||||
│ │
|
||||
│ ▼
|
||||
│ agent/task.rs (a7) [纯数据]
|
||||
│ │
|
||||
└──────────────────► src/agent.rs + lib.rs (a8)
|
||||
│
|
||||
▼
|
||||
cargo test (a9 → a10)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Phase 4b — 任务执行
|
||||
|
||||
**范围**:TaskAgent trait + PlanParser trait + JsonPlanParser 参考实现 + OnPlanStepComplete hook + AgentError PlanParse 变体
|
||||
|
||||
**前置条件**:Phase 4a 已完成并交付。
|
||||
|
||||
**任务拆解**:
|
||||
|
||||
| 顺序 | 任务 | 涉及文件 | 验证 |
|
||||
|------|------|---------|------|
|
||||
| b1 | 修改 `llm/hooks.rs` 追加 OnPlanStepComplete + plan_step_index 字段 | `src/llm/hooks.rs` | `cargo build` 通过;Phase 0 + 4a 测试不挂 |
|
||||
| b2 | `agent/error.rs` 追加 PlanParse 变体 | `src/agent/error.rs` | `cargo build` 通过 |
|
||||
| b3 | `agent/task.rs` 追加 `TaskAgent` trait + `PlanParser` trait + `JsonPlanParser` 参考实现 | `src/agent/task.rs` | `cargo build` 通过 |
|
||||
| b4 | 更新 `agent.rs` 模块根重导出(如有新增公开类型) | `src/agent.rs` | `cargo build` 通过 |
|
||||
| b5 | 编写烟雾测试 2-3 个(TaskAgent mock 执行 / JsonPlanParser 解析 / PlanParse 错误) | `src/agent/task.rs` 内联 | `cargo test` 通过 |
|
||||
| b6 | 完整 `cargo test` 跑全量回归 + roadmap.md 状态更新 | — | 所有已有测试不挂 |
|
||||
|
||||
**依赖关系**:
|
||||
|
||||
```
|
||||
hooks扩展 (b1) ──┐
|
||||
├──► error.rs 追加 (b2) ──► task.rs 追加 (b3)
|
||||
│
|
||||
▼
|
||||
agent.rs 更新 (b4)
|
||||
│
|
||||
▼
|
||||
cargo test (b5 → b6)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Phase 4c — 会话级记忆
|
||||
|
||||
**范围**:SessionMemory struct(基于 MemoryStore)+ RuntimeBundle/Build 扩展 session_memory_backend + AgentSession 接入(替换内联 HashMap)
|
||||
|
||||
**前置条件**:Phase 4a 已完成并交付(可在 4b 之前、之后或并行实施)。
|
||||
|
||||
**任务拆解**:
|
||||
|
||||
| 顺序 | 任务 | 涉及文件 | 验证 |
|
||||
|------|------|---------|------|
|
||||
| c1 | 新建 `agent/session_memory.rs` 定义 `SessionMemory`(基于 `MemoryStore`,namespace 隔离) | `src/agent/session_memory.rs` | `cargo build` 通过 |
|
||||
| c2 | `agent/runtime.rs` 追加 `session_memory_backend` 字段到 `RuntimeBundle` | `src/agent/runtime.rs` | `cargo build` 通过 |
|
||||
| c3 | `agent/builder.rs` 追加 `.session_memory_backend()` 方法 | `src/agent/builder.rs` | `cargo build` 通过 |
|
||||
| c4 | `agent/session.rs` 替换内联 HashMap 为完整 `SessionMemory` + 更新模块根重导出 | `src/agent/session.rs` + `src/agent.rs` | `cargo build` 通过 |
|
||||
| c5 | 编写烟雾测试 2-3 个(SessionMemory set/get/snapshot 基于 InMemoryStore) | `src/agent/session_memory.rs` 内联 | `cargo test` 通过 |
|
||||
| c6 | 完整 `cargo test` 跑全量回归 + roadmap.md 状态更新 | — | 所有已有测试不挂 |
|
||||
|
||||
**依赖关系**:
|
||||
|
||||
```
|
||||
session_memory.rs (c1) ──► runtime.rs 追加 (c2) ──► builder.rs 追加 (c3)
|
||||
│
|
||||
▼
|
||||
session.rs 修改 (c4)
|
||||
│
|
||||
▼
|
||||
cargo test (c5 → c6)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.5 预估工作量(按子阶段)
|
||||
|
||||
| 子阶段 | 文件 | 行数 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| **Phase 4a** | hooks 扩展(2 事件 + 1 字段) | ~10 | 追加变体 + 字段 + 文档 |
|
||||
| | agent/error.rs | ~40 | AgentError 枚举 + From + is_recoverable |
|
||||
| | agent/agent.rs | ~30 | Agent trait + docs |
|
||||
| | agent/runtime.rs | ~60 | RuntimeBundle + AgentConfig |
|
||||
| | agent/builder.rs | ~60 | 链式构造 + build 校验 |
|
||||
| | agent/session.rs | ~100 | AgentSession + submit_turn + 内联 HashMap |
|
||||
| | agent/task.rs(纯数据) | ~40 | Plan / Step / StepStatus |
|
||||
| | src/agent.rs + lib.rs | ~20 | 模块根 + 导出 |
|
||||
| | 烟雾测试 | ~80 | 3-4 个测试 |
|
||||
| | **小计** | **~440** | **核心胶水层** |
|
||||
| **Phase 4b** | hooks 扩展(1 事件 + 1 字段) | ~5 | OnPlanStepComplete + plan_step_index |
|
||||
| | error.rs 追加 PlanParse | ~5 | 1 个变体 |
|
||||
| | task.rs 追加(TaskAgent + PlanParser + JsonPlanParser) | ~130 | trait + 参考实现 + docs |
|
||||
| | 烟雾测试 | ~60 | 2-3 个测试 |
|
||||
| | **小计** | **~200** | **任务执行** |
|
||||
| **Phase 4c** | session_memory.rs | ~40 | 5 个方法 + docs |
|
||||
| | runtime.rs / builder.rs / session.rs 修改 | ~35 | 追加字段 + setter + 替换 HashMap |
|
||||
| | 烟雾测试 | ~40 | 2-3 个测试 |
|
||||
| | **小计** | **~115** | **会话级记忆** |
|
||||
| **合计** | | **~755** | 与原始预估 ~800 基本持平 |
|
||||
|
||||
## 5. 风险评估
|
||||
|
||||
### 5.1 抽象化边界(核心风险)
|
||||
|
||||
**风险描述**:Phase 4 容易"过度抽象"——参考了 OpenHarness / Hermes 后,倾向于把它们的核心能力都搬到 Rust core 库里。
|
||||
|
||||
**缓解措施**:
|
||||
- 严格遵循 §1.3 的 7 条设计原则
|
||||
- 每次添加新 trait / struct 前,先问"这属于 core 库职责吗?"
|
||||
- 业务能力(Plan 拆解、多 Agent 协同、技能加载)一律走 trait 注入或 v0.2+ 延后
|
||||
|
||||
### 5.2 对 Phase 0-3 的侵入风险
|
||||
|
||||
**风险描述**:为实现 Phase 4 需修改 `src/llm/hooks.rs`,可能破坏 Phase 0 的现有测试。
|
||||
|
||||
**缓解措施**:
|
||||
- 只追加 enum 变体和 `Option<T>` 字段(NF9)
|
||||
- 顺序:先跑 `cargo test` 确认 Phase 0 测试不挂,再开始 Phase 4
|
||||
- 详细回归验证:实施完毕后跑全量 `cargo test`
|
||||
|
||||
### 5.3 参考项目语言差异
|
||||
|
||||
**风险描述**:OpenClaw / Hermes / OpenHarness 均为 Python/TypeScript,OpenHuman 虽是 Rust + Tauri 但定位是桌面应用。直接照搬接口形状可能导致 Rust 借用检查问题、async 复杂度增加。
|
||||
|
||||
**缓解措施**:
|
||||
- §1.3 明确"借鉴不照搬"
|
||||
- 反模式列表(见 `docs/note-agent-harness-references.md` §6)作为排除项
|
||||
- 接口设计优先考虑 Rust 惯例(`Arc<dyn Trait>` / `async fn` / `Result<T, E>`)
|
||||
|
||||
### 5.4 trait 设计的稳定性风险
|
||||
|
||||
**风险描述**:Phase 4 是 v0.1 的第一个"复杂 trait 集合",如果 trait 形状不稳定,v0.2+ 添加新能力时会 breaking。
|
||||
|
||||
**缓解措施**:
|
||||
- §3.2 的所有 trait / struct 在 `docs/note-agent-runtime-design.md` §4 已固化草案
|
||||
- 实施时如需调整,应先更新决策记录再改代码
|
||||
- 预留扩展点:`Agent::tool_definitions` 的默认实现可被子 trait 覆盖
|
||||
|
||||
### 5.5 实施进度风险
|
||||
|
||||
**风险描述**:拆为 3 个子阶段后每个阶段任务量降低(4a 约 440 行、4b 约 200 行、4c 约 115 行),但阶段间衔接(4b/4c 对 4a 的依赖)可能产生等待。
|
||||
|
||||
**缓解措施**:
|
||||
- 每个子阶段独立验证,完成即交付,不阻塞后续阶段
|
||||
- 4b 和 4c 无相互依赖,可并行开工
|
||||
- 烟雾测试只验证"能跑通"不验证"业务正确"——避免陷入业务循环的细节
|
||||
- 必要时先做 `MockProvider`(Phase 0 已有模式),不依赖真实 LLM
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
### 6.1 通用代码验收(每个子阶段必须满足)
|
||||
|
||||
- [ ] `cargo build --release` 0 错误 0 警告(clippy)
|
||||
- [ ] `cargo test` 所有已有测试 + 本阶段新增测试全部通过
|
||||
- [ ] `cargo doc --no-deps` 所有公开 API 有 `///` 文档注释
|
||||
- [ ] `src/llm/hooks.rs` 仅追加(不修改现有变体或字段)
|
||||
|
||||
### 6.2 Phase 4a 验收
|
||||
|
||||
#### 6.2a 代码验收
|
||||
|
||||
- [ ] 新增代码 ~440 行(含测试 + 文档注释),与 §4.5 预估一致
|
||||
- [ ] `src/lib.rs` 新增一行 `pub mod agent;`
|
||||
- [ ] 新增文件:`agent.rs` / `agent/agent.rs` / `agent/runtime.rs` / `agent/builder.rs` / `agent/session.rs` / `agent/task.rs` / `agent/error.rs`(共 7 个文件,不含 `agent/builder.rs` 之外的 builder 则 7 个)
|
||||
|
||||
#### 6.2b 接口验收
|
||||
|
||||
- [ ] `Agent` trait 包含 `name` / `system_prompt` / `tool_definitions` 三个方法
|
||||
- [ ] `RuntimeBundle` 包含 5 个字段:provider / tool_registry / hook_executor / memory_store? / retriever? / config(不含 session_memory_backend)
|
||||
- [ ] `AgentBuilder` 提供 5 个 setter(不含 session_memory_backend)+ `build()` 校验
|
||||
- [ ] `AgentSession` 持 `Arc<dyn Agent>` 而非 `agent_name: String`
|
||||
- [ ] `AgentSession::submit_turn` 实现约 30 行,含 OnTurnStart/End hook 触发
|
||||
- [ ] `AgentSession` 用内联 `HashMap<String, String>` 做 session_data(不引 `MemoryStore`)
|
||||
- [ ] `Plan` / `Step` / `StepStatus` 纯数据结构存在,状态机正确
|
||||
- [ ] `AgentError` 聚合 6 个变体:Llm / Tool / Memory / HookBlocked / LimitExceeded / Config / Other(不含 PlanParse)
|
||||
- [ ] `AgentError::is_recoverable()` 对各变体返回正确分类
|
||||
- [ ] `HookEvent` 新增 2 个变体:`OnTurnStart` / `OnTurnEnd`
|
||||
- [ ] `HookContext` 新增 1 个 `Option` 字段:`turn_index`
|
||||
|
||||
#### 6.2c 测试验收
|
||||
|
||||
- [ ] **测试 1**:`Agent` trait 可实现 + `RuntimeBundle` 可构造(builder 链式调用)
|
||||
- [ ] **测试 2**:`AgentSession::submit_turn` 跑通 mock provider(Phase 0 `MockProvider` 模式)
|
||||
- [ ] **测试 3**:`Plan` / `Step` / `StepStatus` 状态机转换正确
|
||||
- [ ] **测试 4(可选)**:session_data set/get 基本读写
|
||||
|
||||
#### 6.2d 行为验收
|
||||
|
||||
- [ ] `AgentSession::submit_turn` 不持有 `ConversationMemory`(grep 验证无 `use crate::memory::ConversationMemory`)
|
||||
- [ ] `AgentSession` 持 `Arc<dyn Agent>`,可从 agent 获取 `system_prompt()` / `tool_definitions()`
|
||||
- [ ] `RuntimeBundle::new` 当 `retriever` 为 `Some` 时自动注册 `"retrieve"` tool
|
||||
- [ ] `AgentBuilder::build` 在必填字段缺失时返回 `AgentError::Config`(而非 panic)
|
||||
|
||||
---
|
||||
|
||||
### 6.3 Phase 4b 验收
|
||||
|
||||
#### 6.3a 代码验收
|
||||
|
||||
- [ ] 追加代码 ~200 行(增量,在 Phase 4a 基础上),与 §4.5 预估一致
|
||||
- [ ] `src/llm/hooks.rs` 追加 OnPlanStepComplete + plan_step_index(不修改 Phase 4a 新增内容)
|
||||
|
||||
#### 6.3b 接口验收
|
||||
|
||||
- [ ] `TaskAgent` trait 提供双入口 `run(goal)` + `execute_plan(plan)`
|
||||
- [ ] `PlanParser` trait 可注入,`JsonPlanParser` 参考实现基于 `serde_json`(~20 行)
|
||||
- [ ] `AgentError` 追加 PlanParse 变体(共 7 个变体)
|
||||
- [ ] `HookEvent` 追加 1 个变体:`OnPlanStepComplete`
|
||||
- [ ] `HookContext` 追加 1 个 `Option` 字段:`plan_step_index`
|
||||
|
||||
#### 6.3c 测试验收
|
||||
|
||||
- [ ] **测试 1**:`TaskAgent::execute_plan` 跑通 mock provider
|
||||
- [ ] **测试 2**:`JsonPlanParser::parse` 能解析合法 JSON,失败时返回 `AgentError::PlanParse`
|
||||
- [ ] **测试 3(可选)**:`OnPlanStepComplete` hook 触发正确
|
||||
|
||||
---
|
||||
|
||||
### 6.4 Phase 4c 验收
|
||||
|
||||
#### 6.4a 代码验收
|
||||
|
||||
- [ ] 追加代码 ~115 行(增量,在 Phase 4a 基础上),与 §4.5 预估一致
|
||||
- [ ] 新增文件:`agent/session_memory.rs`
|
||||
|
||||
#### 6.4b 接口验收
|
||||
|
||||
- [ ] `SessionMemory` 包含 5 个方法(set / get / snapshot / remove / clear),基于 `MemoryStore` 实现
|
||||
- [ ] `SessionMemory::snapshot` 返回 `<session-context>` 标签包裹的格式化文本
|
||||
- [ ] `RuntimeBundle` 追加 `session_memory_backend: Option<Arc<dyn MemoryStore>>` 字段
|
||||
- [ ] `AgentBuilder` 追加 `.session_memory_backend()` setter
|
||||
- [ ] `AgentSession` 替换内联 HashMap 为完整 `SessionMemory`,含 `session_memory: SessionMemory` 字段
|
||||
- [ ] `SessionMemory` 在 `session_memory_backend` 未传入时自动使用 `InMemoryStore` 兜底
|
||||
|
||||
#### 6.4c 测试验收
|
||||
|
||||
- [ ] **测试 1**:`SessionMemory` set / get / snapshot 基本读写(基于 `InMemoryStore`)
|
||||
- [ ] **测试 2**:session_data 内联 HashMap ↔ SessionMemory 替换后 submit_turn 行为不变
|
||||
|
||||
---
|
||||
|
||||
### 6.5 文档验收
|
||||
|
||||
- [ ] `docs/7-agent-runtime.md`(本文件)完整,6 段式结构齐备
|
||||
- [ ] `docs/note-agent-runtime-design.md` 与本文件互相引用一致
|
||||
- [ ] `docs/note-agent-harness-references.md` 与本文件互相引用一致
|
||||
- [ ] `docs/roadmap.md` 各子阶段状态按阶段翻转
|
||||
|
||||
### 6.6 风险验收
|
||||
|
||||
- [ ] 5.1 抽象化边界:交付物列表中**不包含** Multi-Agent / Skills / TUI / Gateway 等应用层能力
|
||||
- [ ] 5.2 Phase 0-3 侵入:`git diff` 显示 `src/llm/hooks.rs` 仅追加
|
||||
- [ ] 5.3 语言差异:trait 形状符合 Rust 惯例(无 Python 风格的复杂继承)
|
||||
- [ ] 5.4 trait 稳定性:决策记录与最终代码一致
|
||||
- [ ] 5.5 实施进度:每个子阶段实际工作量与 §4.5 预估偏差 < 30%
|
||||
|
||||
## 7. 一句话总结
|
||||
|
||||
> **Phase 4 = 3 个子阶段:4a(核心胶水层:Agent + AgentSession + submit_turn + RuntimeBundle + Plan/Step 纯数据,~440 行)→ 4b(任务执行:TaskAgent + PlanParser/JsonPlanParser,~200 行)→ 4c(会话级记忆:SessionMemory + 接入,~115 行),合计 ~755 行,分步交付、逐段验证,把 Phase 0-3 已有能力"装配"成"智能体"的概念。**
|
||||
@@ -0,0 +1,434 @@
|
||||
# 示例程序新增方案
|
||||
|
||||
> 作者:Proposal Agent
|
||||
> 日期:2026-06-11
|
||||
> 对应版本:agcore v0.1
|
||||
|
||||
## 背景与目标
|
||||
|
||||
### 问题
|
||||
|
||||
当前 `examples/` 目录下只有一个 `simple_visit.rs`,仅演示了 `LlmCycle::submit()` 的基本 LLM 调用(Phase 0),且依赖真实 API key 才能运行。v0.1 已实现的全部 7 个 Phase 的能力(Phase 0~4c)缺乏可运行、可独立验证的示例展示。
|
||||
|
||||
### 目标
|
||||
|
||||
1. **覆盖全 Phase** — 每个 Phase 核心能力至少有一个示例
|
||||
2. **可离线运行** — 优先选用 MockProvider 和本地逻辑,不强制依赖 API key
|
||||
3. **真实使用模式** — 示例反映库的预期使用方式(Builder 模式、trait 实现、? 错误传播)
|
||||
4. **验收辅助** — 示例跑通 = 对应模块公共 API 可用且装配正确
|
||||
|
||||
### 非目标
|
||||
|
||||
- 不替代单元测试的边界覆盖(内联测试仍负责边界条件)
|
||||
- 不引入第三方依赖(示例只使用 `agcore` 公开 API)
|
||||
- 不追求 UI 或交互式输入
|
||||
|
||||
---
|
||||
|
||||
## 当前状态分析
|
||||
|
||||
```text
|
||||
examples/
|
||||
└── simple_visit.rs # 仅 Phase 0 基础调用,需 API key
|
||||
```
|
||||
|
||||
### 现有示例覆盖缺口
|
||||
|
||||
| Phase | 模块 | 示例覆盖 | 缺口 |
|
||||
|-------|------|---------|------|
|
||||
| Phase 0 | LLM 调用周期 | `simple_visit.rs` | 流式事件、重试逻辑、Auto-compaction 未演示 |
|
||||
| Phase 1 | 提示词工程 | ❌ | 模板变量插值、消息组合、条件渲染 |
|
||||
| Phase 2 | 工具系统 | ❌ | 自定义工具注册、并行调用、权限检查 |
|
||||
| Phase 3 | 记忆系统 | ❌ | 对话记忆滑动窗口、知识页面存储、关键词检索 |
|
||||
| Phase 4a | 核心胶水层 | ❌ | Agent/AgentSession/RuntimeBundle/AgentBuilder 装配 |
|
||||
| Phase 4b | 任务执行 | ❌ | PlanParser/Step 状态机/TaskAgent |
|
||||
| Phase 4c | 会话级记忆 | ❌ | SessionMemory set/get/snapshot |
|
||||
|
||||
---
|
||||
|
||||
## 设计方案
|
||||
|
||||
### 总体架构
|
||||
|
||||
新增示例按三层优先级组织,每个示例为一个独立 `.rs` 文件,统一放在 `examples/` 目录下。
|
||||
|
||||
```
|
||||
examples/
|
||||
├── simple_visit.rs # [已有] 基本 LLM 调用(Phase 0)
|
||||
├── prompt_composer.rs # [新增] 提示词组合(Phase 1)🥇
|
||||
├── custom_tool.rs # [新增] 自定义工具(Phase 2)🥇
|
||||
├── agent_session_demo.rs # [新增] Agent 会话(Phase 4a+4c)🥇
|
||||
├── task_agent_demo.rs # [新增] 任务规划(Phase 4b)🥇
|
||||
├── conversation_memory_demo.rs # [新增] 对话记忆(Phase 3)🥈
|
||||
├── knowledge_search_demo.rs # [新增] 知识检索(Phase 3)🥈
|
||||
├── streaming_events_demo.rs # [新增] 流式事件(Phase 0)🥈
|
||||
└── full_integration.rs # [新增] 全栈集成(Phase 全栈)🥉
|
||||
```
|
||||
|
||||
### 详细设计
|
||||
|
||||
#### 🥇 示例:`prompt_composer.rs`(Phase 1)
|
||||
|
||||
**设计思路**:纯本地运行,不依赖任何外部服务。通过构造模板、组合消息来验证 Prompt Engineering 模块的公共 API。
|
||||
|
||||
**流程**:
|
||||
```
|
||||
TemplateContext 构造 → PromptTemplate 填充变量 → PromptComposer 构建消息链 → 断言验证
|
||||
```
|
||||
|
||||
**关键代码片段示意**:
|
||||
```rust
|
||||
// 1. 构造模板
|
||||
let mut registry = PromptTemplateRegistry::new();
|
||||
registry.register(PromptTemplate::new("weather", "今日{location}天气:{condition},温度{temperature}"));
|
||||
|
||||
// 2. 填充变量
|
||||
let template = registry.get("weather").unwrap();
|
||||
let rendered = template.render(&TemplateContext::from([
|
||||
("location", "北京"),
|
||||
("condition", "晴"),
|
||||
("temperature", "25°C"),
|
||||
])?;
|
||||
|
||||
// 3. 组合消息
|
||||
let composer = PromptComposer::new()
|
||||
.system("你是一个天气助手")
|
||||
.user(rendered)
|
||||
.assistant(/* 可选历史 */);
|
||||
|
||||
let messages = composer.compose();
|
||||
assert_eq!(messages.len(), 2);
|
||||
```
|
||||
|
||||
**验证点**:
|
||||
- `TemplateContext` 变量插值正确
|
||||
- `PromptComposer` 消息顺序正确
|
||||
- `PromptError` 在缺失变量时正确返回
|
||||
|
||||
**新增代码量**:约 60 行
|
||||
|
||||
---
|
||||
|
||||
#### 🥇 示例:`custom_tool.rs`(Phase 2)
|
||||
|
||||
**设计思路**:实现一个模拟工具(如 `WeatherTool`),注册到 `ToolRegistry`,演示单次调用、并行调用、权限检查。
|
||||
|
||||
**流程**:
|
||||
```
|
||||
实现 BaseTool → 注册到 ToolRegistry → invoke 单次 → invoke_all 并行 → PermissionChecker 白名单过滤
|
||||
```
|
||||
|
||||
**关键代码片段示意**:
|
||||
```rust
|
||||
// 1. 实现工具
|
||||
struct WeatherTool;
|
||||
#[async_trait]
|
||||
impl BaseTool for WeatherTool {
|
||||
fn name(&self) -> &str { "get_weather" }
|
||||
fn parameters(&self) -> Value { json!({"type":"object","properties":{"city":{"type":"string"}}}) }
|
||||
async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<Value, ToolError> {
|
||||
Ok(json!({"city": args["city"], "temperature": 22, "condition": "晴"}))
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 注册 + 调用
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(WeatherTool.into())?;
|
||||
let result = registry.invoke("get_weather", json!({"city": "北京"})).await?;
|
||||
|
||||
// 3. 并行调用
|
||||
let results = registry.invoke_all(vec![...], 30).await;
|
||||
|
||||
// 4. 权限检查
|
||||
let checker = PermissionChecker::new(PermissionConfig::white_list(vec!["get_weather"]));
|
||||
assert!(checker.check("get_weather").is_ok());
|
||||
assert!(checker.check("delete_file").is_err());
|
||||
```
|
||||
|
||||
**验证点**:
|
||||
- 工具注册/查找/调用完整链路
|
||||
- 并行调用结果数正确
|
||||
- 权限白名单/黑名单行为
|
||||
- `ToolError::NotFound` 未注册工具
|
||||
|
||||
**新增代码量**:约 80 行
|
||||
|
||||
---
|
||||
|
||||
#### 🥇 示例:`agent_session_demo.rs`(Phase 4a + 4c)
|
||||
|
||||
**设计思路**:使用 `MockProvider` 模拟 LLM 响应,完整演示 `Agent → AgentBuilder → RuntimeBundle → AgentSession` 的装配流程及 `SessionMemory` 的读写。
|
||||
|
||||
**流程**:
|
||||
```
|
||||
实现 Agent → AgentBuilder 构造 RuntimeBundle → AgentSession::new → submit_turn → session_data 读写 → snapshot 输出
|
||||
```
|
||||
|
||||
**关键代码片段示意**:
|
||||
```rust
|
||||
// 1. 定义 Agent
|
||||
struct CalculatorAgent;
|
||||
impl Agent for CalculatorAgent {
|
||||
fn name(&self) -> &str { "calculator" }
|
||||
fn system_prompt(&self) -> Option<&str> { Some("你是计算器助手") }
|
||||
}
|
||||
|
||||
// 2. 装配 RuntimeBundle
|
||||
let bundle = AgentBuilder::new()
|
||||
.provider(Arc::new(mock_provider))
|
||||
.tool_registry(Arc::new(tool_registry))
|
||||
.hook_executor(Arc::new(hook_executor))
|
||||
.build()?;
|
||||
|
||||
// 3. 创建会话
|
||||
let mut session = AgentSession::new(Arc::new(CalculatorAgent), "session-1", Arc::new(bundle));
|
||||
|
||||
// 4. 提交对话
|
||||
let response = session.submit_turn("1+1=?").await?;
|
||||
|
||||
// 5. SessionMemory 读写
|
||||
session.set_session_data("last_result", "2").await?;
|
||||
let result = session.get_session_data("last_result").await?;
|
||||
println!("{}", session.session_memory().snapshot().await?);
|
||||
```
|
||||
|
||||
**验证点**:
|
||||
- `AgentBuilder::build()` 必填字段校验
|
||||
- `submit_turn` 流程完整(hook 触发、cost 累计、turn_index 递增)
|
||||
- `SessionMemory` set/get/snapshot 正确
|
||||
- 多个 session 间数据隔离
|
||||
|
||||
**新增代码量**:约 100 行
|
||||
|
||||
---
|
||||
|
||||
#### 🥇 示例:`task_agent_demo.rs`(Phase 4b)
|
||||
|
||||
**设计思路**:使用 `JsonPlanParser` 从预定义 JSON 解析 Plan,驱动 Step 状态机转换,观察状态单向流转。
|
||||
|
||||
**流程**:
|
||||
```
|
||||
构造 JSON 输入 → JsonPlanParser::parse → Plan 数据结构 → 模拟 execute_plan → Step 状态变迁 → Hook 事件
|
||||
```
|
||||
|
||||
**关键代码片段示意**:
|
||||
```rust
|
||||
// 1. 解析 Plan
|
||||
let parser = JsonPlanParser;
|
||||
let input = r#"{"steps": [{"description": "查天气"}, {"description": "算结果"}]}"#;
|
||||
let mut plan = parser.parse(input, "完成今日任务").await?;
|
||||
|
||||
// 2. 模拟 step 执行
|
||||
assert!(plan.steps[0].status.is_pending());
|
||||
step.status = StepStatus::Running;
|
||||
step.status = StepStatus::Completed(response);
|
||||
assert!(step.status.is_terminal());
|
||||
|
||||
// 3. 失败路径
|
||||
step.status = StepStatus::Failed(AgentError::Other("API 不可用".into()));
|
||||
assert!(step.status.is_terminal());
|
||||
```
|
||||
|
||||
**验证点**:
|
||||
- 合法 JSON 解析正确
|
||||
- 非法 JSON / 空步骤 / 缺字段返回 `AgentError::PlanParse`
|
||||
- 状态机单向转换(Pending → Running → Completed/Failed/Skipped)
|
||||
- `is_terminal()` / `is_pending()` 语义正确
|
||||
|
||||
**新增代码量**:约 70 行
|
||||
|
||||
---
|
||||
|
||||
#### 🥈 示例:`conversation_memory_demo.rs`(Phase 3)
|
||||
|
||||
**设计思路**:演示 `ConversationMemory` 的多轮消息写入、滑动窗口淘汰、冷热分离存储。
|
||||
|
||||
**流程**:
|
||||
```
|
||||
ConversationMemory::new → add_message × N → 触发窗口淘汰 → get_history 验证 → MemoryStore 持久化读取
|
||||
```
|
||||
|
||||
**关键代码片段示意**:
|
||||
```rust
|
||||
let config = ConversationMemoryConfig {
|
||||
strategy: MemoryStrategy::SlidingWindow,
|
||||
max_turns: 5,
|
||||
..Default::default()
|
||||
};
|
||||
let mut memory = ConversationMemory::new(store, "session-1", config);
|
||||
|
||||
// 写入 10 条消息
|
||||
for i in 0..10 {
|
||||
memory.add_message(OpenaiChatMessage::user_text(format!("消息 {i}"))).await?;
|
||||
}
|
||||
|
||||
// 验证窗口大小为 5
|
||||
let history = memory.get_history().await?;
|
||||
assert_eq!(history.len(), 5);
|
||||
assert!(history[0].content().contains("消息 5"));
|
||||
```
|
||||
|
||||
**验证点**:
|
||||
- 滑动窗口淘汰旧消息
|
||||
- Full 策略保留全部消息
|
||||
- 冷存储 `MemoryStore` 写入/读取正确
|
||||
- `CompactConfig` 触发自动压缩
|
||||
|
||||
**新增代码量**:约 70 行
|
||||
|
||||
---
|
||||
|
||||
#### 🥈 示例:`knowledge_search_demo.rs`(Phase 3)
|
||||
|
||||
**设计思路**:演示 `KnowledgeStore` 页面存储 + `MemoryRetriever` 关键词检索与 Dice 系数评分。
|
||||
|
||||
**流程**:
|
||||
```
|
||||
KnowledgeStore 创建页面 → MemoryRetriever::search → 评分排序结果输出 → 阈值过滤观察
|
||||
```
|
||||
|
||||
**关键代码片段示意**:
|
||||
```rust
|
||||
let store = KnowledgeStore::new(memory_store);
|
||||
store.save_page("Rust 入门", "Rust 是一门系统编程语言...", vec!["rust", "编程"]).await?;
|
||||
store.save_page("Python 简介", "Python 是动态类型语言...", vec!["python", "动态"]).await?;
|
||||
|
||||
let retriever = MemoryRetriever::new(store, RetrieverConfig::default());
|
||||
let result = retriever.search("Rust 语言").await?;
|
||||
|
||||
for item in &result.items {
|
||||
println!(" 页面: {} (评分: {:.2})", item.page.title, item.score);
|
||||
assert!(item.score >= 0.0 && item.score <= 1.0);
|
||||
}
|
||||
```
|
||||
|
||||
**验证点**:
|
||||
- `KnowledgeStore` 页面存/取/搜索正确
|
||||
- `TextOverlap` Dice 系数在 [0.0, 1.0] 范围内
|
||||
- 停用词过滤正常
|
||||
- 低于 `min_score` 的结果被过滤
|
||||
|
||||
**新增代码量**:约 60 行
|
||||
|
||||
---
|
||||
|
||||
#### 🥈 示例:`streaming_events_demo.rs`(Phase 0 — 流式接口)
|
||||
|
||||
**设计思路**:调用 `LlmCycle::submit_stream()` 获取事件流,展示了语义事件的消费模式。可选使用 API key 或 MockProvider。
|
||||
|
||||
**流程**:
|
||||
```
|
||||
LlmCycle::submit_stream → 事件循环 match StreamEvent → 输出类型/内容 → TurnComplete 收尾
|
||||
```
|
||||
|
||||
**关键代码片段示意**:
|
||||
```rust
|
||||
let mut cycle = LlmCycle::new(provider, config);
|
||||
let mut stream = cycle.submit_stream("讲个笑话".into(), vec![]).await?;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
while let Some(event) = stream.next().await {
|
||||
match event {
|
||||
StreamEvent::AssistantTextDelta { text } => print!("{text}"),
|
||||
StreamEvent::TurnComplete { reason } => println!("\n\n完成,原因: {reason:?}"),
|
||||
StreamEvent::Error { message } => eprintln!("错误: {message}"),
|
||||
_ => {} // 其他事件
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**验证点**:
|
||||
- 流式链路完整(请求 → 事件 → 完成)
|
||||
- 事件枚举覆盖所有变体
|
||||
- 错误事件正确处理
|
||||
|
||||
**新增代码量**:约 80 行
|
||||
|
||||
---
|
||||
|
||||
#### 🥉 示例:`full_integration.rs`(Phase 全栈集成)
|
||||
|
||||
**设计思路**:端到端演示,将 v0.1 所有模块装配为一个可运行的智能体。需真实 API key。
|
||||
|
||||
**流程**:
|
||||
```
|
||||
创建 Agent(带 system prompt + 2 个工具)→ 注入 MemoryStore/Retriever → AgentSession → 多轮对话 → 知识检索 → SessionMemory 桥接 → 输出运行摘要
|
||||
```
|
||||
|
||||
**验证点**:
|
||||
- Phase 4 "胶水层"真正将 Phase 0~3 粘合
|
||||
- `submit_turn` 内部调用工具
|
||||
- `ConversationMemory` 回写
|
||||
- 全链路无类型/装配错误
|
||||
|
||||
**新增代码量**:约 150 行
|
||||
|
||||
---
|
||||
|
||||
## 实现计划
|
||||
|
||||
### 阶段一:第一梯队(优先级 🥇)
|
||||
|
||||
| 示例 | 预计代码量 | 可并行实施 |
|
||||
|------|-----------|-----------|
|
||||
| `prompt_composer.rs` | ~60 行 | ✅ 与 2/3 并行 |
|
||||
| `custom_tool.rs` | ~80 行 | ✅ 与 1/3 并行 |
|
||||
| `agent_session_demo.rs` | ~100 行 | ✅ 与 1/2 并行 |
|
||||
| `task_agent_demo.rs` | ~70 行 | ✅ 与 1/2/3 并行 |
|
||||
|
||||
**验证标准**:`cargo run --example <name>` 全部成功退出(code 0)。
|
||||
|
||||
### 阶段二:第二梯队(优先级 🥈)
|
||||
|
||||
| 示例 | 预计代码量 | 前置依赖 |
|
||||
|------|-----------|---------|
|
||||
| `conversation_memory_demo.rs` | ~70 行 | 无 |
|
||||
| `knowledge_search_demo.rs` | ~60 行 | 无 |
|
||||
| `streaming_events_demo.rs` | ~80 行 | 无 |
|
||||
|
||||
### 阶段三:第三梯队(优先级 🥉)
|
||||
|
||||
| 示例 | 预计代码量 | 前置依赖 |
|
||||
|------|-----------|---------|
|
||||
| `full_integration.rs` | ~150 行 | 需 `.env` 配置 API key |
|
||||
|
||||
### 总工作量估算
|
||||
|
||||
| 合计 | 代码行数 | 文件数 |
|
||||
|------|---------|-------|
|
||||
| 第一阶段 | ~310 行 | 4 个 |
|
||||
| 第二阶段 | ~210 行 | 3 个 |
|
||||
| 第三阶段 | ~150 行 | 1 个 |
|
||||
| **总计** | **~670 行** | **8 个文件** |
|
||||
|
||||
---
|
||||
|
||||
## 风险评估
|
||||
|
||||
| 风险 | 影响 | 概率 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| 示例与库 API 不同步(库重构后示例过时) | 高 | 中 | 将示例加入 CI:`cargo test --examples` |
|
||||
| MockProvider 行为与真实 Provider 差异 | 低 | 低 | 示例明确标注离线/在线模式 |
|
||||
| 示例代码量膨胀超过预期 | 低 | 低 | 每个示例控制在 200 行以内,超过则拆分子函数 |
|
||||
| `full_integration.rs` 依赖 API key,CI 会跳过 | 中 | 高 | 用 `#[cfg(not(ci))]` 或 `.env` 存在性判断优雅降级 |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. **阶段一全部完成时**:
|
||||
- `cargo run --example prompt_composer` → 成功退出
|
||||
- `cargo run --example custom_tool` → 成功退出
|
||||
- `cargo run --example agent_session_demo` → 成功退出
|
||||
- `cargo run --example task_agent_demo` → 成功退出
|
||||
|
||||
2. **阶段二全部完成时**:
|
||||
- 额外 3 个示例均可 `cargo run` 成功
|
||||
|
||||
3. **阶段三完成时**(可选):
|
||||
- `full_integration` 在有 `.env` 配置时成功运行,无配置时友好提示降级
|
||||
|
||||
4. **全局验收**:
|
||||
- `cargo build` 无新增警告
|
||||
- 所有示例输出格式清晰,有说明性 println
|
||||
- 每个示例在文件顶部有 `//!` 注释说明其演示目的
|
||||
@@ -0,0 +1,113 @@
|
||||
# LLM Provider 统一接口设计(方案 C)
|
||||
|
||||
> 本文档已被拆分为独立的子文档以便深入推演和修改。以下保留背景与架构总览作为索引。
|
||||
>
|
||||
> **拆分日期**:2026-06-15
|
||||
> **拆分方式**:原 §1-§2 保留在本文件,§3-§13 移至 `9a`-`9g` 子文档。
|
||||
> 所有"待深入推演"议题保留在对应子文档的原文位置。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 当前状态
|
||||
|
||||
`LlmProvider` trait 的请求/响应类型直接绑定到 OpenAI Chat Completion API 格式:
|
||||
|
||||
```rust
|
||||
pub type ChatRequest = OpenaiChatRequest; // 类型别名
|
||||
pub type ChatResponse = struct { message: OpenaiChatMessage, ... };
|
||||
pub type Message = OpenaiChatMessage;
|
||||
```
|
||||
|
||||
所有 "内部统一类型" 都是 OpenAI 格式的直接映射。这导致:
|
||||
|
||||
| API 类型 | 兼容性 | 代价 |
|
||||
|----------|--------|------|
|
||||
| OpenAI Chat(DeepSeek、Qwen 等) | ✅ 原生兼容 | 零 |
|
||||
| Anthropic Messages | ❌ 语义丢失 | 需逆向映射,丢失 thinking 等特性 |
|
||||
| OpenAI Response API | ❌ 范式不兼容 | ChatResponse 无法表达多类型 output |
|
||||
| 非标自定义 API | ❌ 无扩展点 | 只能走 extra_body 逃生舱 |
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
设计一套**真正与 Provider 无关的内部统一类型(IR)**,使得:
|
||||
|
||||
1. 所有 Provider 对外暴露的接口完全一致(统一 trait)
|
||||
2. 每个 Provider 内部自行完成 IR ↔ 原生格式的映射
|
||||
3. 上层(LlmCycle、AgentSession)完全感知不到具体 Provider
|
||||
4. 新 Provider 只需实现一次双向映射即可接入
|
||||
5. 各 API 的独有特性(thinking、内置工具等)有表达空间
|
||||
|
||||
### 1.3 非目标
|
||||
|
||||
- 不追求覆盖所有 API 的每一个参数(90% 核心流程即可)
|
||||
- 不追求在不改上层代码的情况下切换 Provider(接口一致足以)
|
||||
- 不试图让 OpenAI Response API 的内置工具完全融入消息循环(通过逃生舱 + 可选能力 trait)
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构总览
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 上 层(AgentSession / TaskAgent) │
|
||||
│ 只与 IR 类型和 LlmProvider trait 交互 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ LlmCycle │
|
||||
│ 循环 / 重试 / Tool 循环 / Hook / Compact / CostTracker │
|
||||
│ 内部使用 Vec<Message> + MessageRequest │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ LlmProvider trait(核心接口) │
|
||||
│ chat(MessageRequest) → Result<MessageResponse, LlmError> │
|
||||
│ chat_stream(MessageRequest) → Stream<StreamEvent> │
|
||||
│ capabilities() → ProviderCapabilities │
|
||||
├────────────────┬──────────────────────┬──────────────────────┤
|
||||
│ OpenaiProvider │ AnthropicProvider │ DeepSeekProvider ... │
|
||||
│ IR ↔ OpenAI │ IR ↔ Anthropic │ IR ↔ OpenAI 格式 │
|
||||
│ JSON │ JSON │ (兼容 Chat API) │
|
||||
└────────────────┴──────────────────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
### 2.1 分层原则
|
||||
|
||||
- **IR 层**:全项目唯一的内部表示,与任何具体 API 格式无关
|
||||
- **Provider 层**:每个 Provider 实现 IR ↔ 原生格式的双向映射,复杂度隔离在此层
|
||||
- **上层**:只与 IR 和 `LlmProvider` trait 交互,不感知具体 Provider
|
||||
|
||||
---
|
||||
|
||||
## 文件索引
|
||||
|
||||
### 核心设计
|
||||
|
||||
| 文件 | 内容 | 包含待深入推演 |
|
||||
|------|------|---------------|
|
||||
| [9a-background-and-architecture.md](9a-background-and-architecture.md) | §1 背景与目标 + §2 架构总览 | — |
|
||||
| [9b-ir-type-system.md](9b-ir-type-system.md) | §3 IR 类型体系:[ContentBlock](9b-ir-type-system.md#31-contentblock--最小的内容单元)、[Message](9b-ir-type-system.md#32-message--统一消息类型)、[StopReason](9b-ir-type-system.md#33-stopreason--统一停止原因)、[MessageRequest](9b-ir-type-system.md#36-messagerequest--统一请求)、[MessageResponse](9b-ir-type-system.md#37-messageresponse--统一响应) | ToolResult 嵌套约束、extra 类型安全性 |
|
||||
| [9c-llm-provider-trait.md](9c-llm-provider-trait.md) | §4 LlmProvider Trait:[核心接口](9c-llm-provider-trait.md#41-核心接口)、[ProviderCapabilities](9c-llm-provider-trait.md#42-providercapabilities)、[StreamEvent](9c-llm-provider-trait.md#43-流式事件-streamevent)、[汇聚算法](9c-llm-provider-trait.md#44-partialmessageresponse--流式事件的汇聚算法) | — |
|
||||
| [9d-provider-implementations.md](9d-provider-implementations.md) | §5 Provider 实现:[OpenAI](9d-provider-implementations.md#51-openai-provider兼容-chat-api)、[Anthropic](9d-provider-implementations.md#52-anthropicprovidermessages-api)、[Response API](9d-provider-implementations.md#53-openai-response-api草案)、[DeepSeek/Qwen](9d-provider-implementations.md#54-deepseek--qwen-等兼容-provider-的落地策略) | OpenAI 流式转换、Anthropic 流式状态机、Response API 映射、DeepSeek/Qwen 落地 |
|
||||
| [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) | §6-§8:LlmCycle 改造(build_request、tool 循环、submit_stream、compact)+ 上层影响 + 兼容策略 | system prompt 双重表达冲突、compact 适配 |
|
||||
| [9f-edge-cases.md](9f-edge-cases.md) | §9 边界情况:工具定义传递、Thinking 端到端、Multiple ContentBlock、多 Choice、内置工具 | — |
|
||||
|
||||
### 辅助参考
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| [9g-risk-and-migration.md](9g-risk-and-migration.md) | §10 风险评估 + §11 类型差异总结 + §12 迁移路径(4 Phase)+ §13 验收标准(A1-A10) |
|
||||
|
||||
### 待深入推演完整清单
|
||||
|
||||
| # | 议题 | 所在文件 | 优先级 |
|
||||
|---|------|---------|--------|
|
||||
| 1 | ToolResult 嵌套约束 | [9b-ir-type-system.md](9b-ir-type-system.md#31-contentblock--最小的内容单元) | ✅ 已推演(方案 C:运行时过滤) |
|
||||
| 2 | extra 的类型安全性 | [9b-ir-type-system.md](9b-ir-type-system.md#36-messagerequest--统一请求) | ✅ 已推演(方案 B:Result-based access) |
|
||||
| 3 | StreamEvent 汇聚为 MessageResponse 算法 | [9c-llm-provider-trait.md](9c-llm-provider-trait.md#44-partialmessageresponse--流式事件的汇聚算法) | ✅ 已推演(方案 B:显式边界 + BTreeMap 分桶) |
|
||||
| 4 | ToolCallStart index 归一化 | [9c-llm-provider-trait.md](9c-llm-provider-trait.md#43-流式事件-streamevent) | ✅ 已推演(自动解决,ToolCallStart 合并到 ContentBlockStart) |
|
||||
| 5 | OpenAI 流式转换实现 | [9d-provider-implementations.md](9d-provider-implementations.md#51-openai-provider兼容-chat-api) | ✅ 已推演(方案 A:SseByteStream 通用层 + OpenaiStreamToEvents 状态机,ToolCallEnd 依赖 finalize 兜底,忽略多 Choice) |
|
||||
| 6 | Anthropic 流式状态机设计 | [9d-provider-implementations.md](9d-provider-implementations.md#52-anthropicprovidermessages-api) | ✅ 已推演(轻量分发器:3 状态 + 7 种事件映射 + 零 index 映射) |
|
||||
| 7 | OpenAI Response API 完整映射表 | [9d-provider-implementations.md](9d-provider-implementations.md#53-openai-response-api草案) | 低 |
|
||||
| 8 | DeepSeek/Qwen Provider 落地策略 | [9d-provider-implementations.md](9d-provider-implementations.md#54-deepseek--qwen-等兼容-provider-的落地策略) | 低 |
|
||||
| 9 | system prompt 双重表达冲突 | [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md#62-build_request--新签名) | ✅ 已推演(方案 D:移除 system 字段,IR 只留一个入口,Provider 层负责映射) |
|
||||
| 10 | compact 在 IR 上的改法与 token 估算 | [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md#66-compact-逻辑调整) | ✅ 已推演(三个子议题各有方案决策 + 二维决策框架) |
|
||||
| 11 | Thinking signature 端到端传递 | [9f-edge-cases.md](9f-edge-cases.md#92-thinking-的端到端流程) | ✅ 已推演(方案 C:MessageComplete 兜底 + finalize 回填) |
|
||||
@@ -0,0 +1,70 @@
|
||||
# 背景与架构总览
|
||||
|
||||
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §1 背景与目标 + §2 架构总览。
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 当前状态
|
||||
|
||||
`LlmProvider` trait 的请求/响应类型直接绑定到 OpenAI Chat Completion API 格式:
|
||||
|
||||
```rust
|
||||
pub type ChatRequest = OpenaiChatRequest; // 类型别名
|
||||
pub type ChatResponse = struct { message: OpenaiChatMessage, ... };
|
||||
pub type Message = OpenaiChatMessage;
|
||||
```
|
||||
|
||||
所有 "内部统一类型" 都是 OpenAI 格式的直接映射。这导致:
|
||||
|
||||
| API 类型 | 兼容性 | 代价 |
|
||||
|----------|--------|------|
|
||||
| OpenAI Chat(DeepSeek、Qwen 等) | ✅ 原生兼容 | 零 |
|
||||
| Anthropic Messages | ❌ 语义丢失 | 需逆向映射,丢失 thinking 等特性 |
|
||||
| OpenAI Response API | ❌ 范式不兼容 | ChatResponse 无法表达多类型 output |
|
||||
| 非标自定义 API | ❌ 无扩展点 | 只能走 extra_body 逃生舱 |
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
设计一套**真正与 Provider 无关的内部统一类型(IR)**,使得:
|
||||
|
||||
1. 所有 Provider 对外暴露的接口完全一致(统一 trait)
|
||||
2. 每个 Provider 内部自行完成 IR ↔ 原生格式的映射
|
||||
3. 上层(LlmCycle、AgentSession)完全感知不到具体 Provider
|
||||
4. 新 Provider 只需实现一次双向映射即可接入
|
||||
5. 各 API 的独有特性(thinking、内置工具等)有表达空间
|
||||
|
||||
### 1.3 非目标
|
||||
|
||||
- 不追求覆盖所有 API 的每一个参数(90% 核心流程即可)
|
||||
- 不追求在不改上层代码的情况下切换 Provider(接口一致足以)
|
||||
- 不试图让 OpenAI Response API 的内置工具完全融入消息循环(通过逃生舱 + 可选能力 trait)
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构总览
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 上 层(AgentSession / TaskAgent) │
|
||||
│ 只与 IR 类型和 LlmProvider trait 交互 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ LlmCycle │
|
||||
│ 循环 / 重试 / Tool 循环 / Hook / Compact / CostTracker │
|
||||
│ 内部使用 Vec<Message> + MessageRequest │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ LlmProvider trait(核心接口) │
|
||||
│ chat(MessageRequest) → Result<MessageResponse, LlmError> │
|
||||
│ chat_stream(MessageRequest) → Stream<StreamEvent> │
|
||||
│ capabilities() → ProviderCapabilities │
|
||||
├────────────────┬──────────────────────┬──────────────────────┤
|
||||
│ OpenaiProvider │ AnthropicProvider │ DeepSeekProvider ... │
|
||||
│ IR ↔ OpenAI │ IR ↔ Anthropic │ IR ↔ OpenAI 格式 │
|
||||
│ JSON │ JSON │ (兼容 Chat API) │
|
||||
└────────────────┴──────────────────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
### 2.1 分层原则
|
||||
|
||||
- **IR 层**:全项目唯一的内部表示,与任何具体 API 格式无关
|
||||
- **Provider 层**:每个 Provider 实现 IR ↔ 原生格式的双向映射,复杂度隔离在此层
|
||||
- **上层**:只与 IR 和 `LlmProvider` trait 交互,不感知具体 Provider
|
||||
@@ -0,0 +1,479 @@
|
||||
# IR 类型体系
|
||||
|
||||
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §3 IR 类型体系。
|
||||
>
|
||||
> **相关文件:**
|
||||
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — 使用本文定义的 IR 类型的 LlmProvider trait
|
||||
> - [9d-provider-implementations.md](9d-provider-implementations.md) — Provider 的 IR ↔ 原生格式映射
|
||||
> - [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) — LlmCycle 改造(内部使用 `Vec<Message>` + `MessageRequest`)
|
||||
> - [9f-edge-cases.md](9f-edge-cases.md) — 边界情况(Thinking 端到端、Multiple ContentBlock 等)
|
||||
|
||||
## 3. IR 类型体系
|
||||
|
||||
### 3.1 ContentBlock —— 最小的内容单元
|
||||
|
||||
```rust
|
||||
/// 跨 Provider 统一的内容块。
|
||||
///
|
||||
/// 设计思路:
|
||||
/// - ToolUse/ToolResult 作为 content block(Anthropic 风格)
|
||||
/// - Thinking 作为一等公民
|
||||
/// - Extension 作为逃生舱
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ContentBlock {
|
||||
/// 纯文本
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
/// 图片(base64 或 URL)
|
||||
Image {
|
||||
source: ImageSource,
|
||||
},
|
||||
/// 音频输入
|
||||
Audio {
|
||||
source: AudioSource,
|
||||
},
|
||||
/// 文件上传
|
||||
File {
|
||||
source: FileSource,
|
||||
},
|
||||
/// 工具调用请求(由 Assistant 发起)
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
/// 工具调用结果(回传)
|
||||
ToolResult {
|
||||
tool_use_id: String,
|
||||
content: Vec<ContentBlock>,
|
||||
is_error: bool,
|
||||
},
|
||||
```
|
||||
|
||||
> **✅ 推演结论(2026-06-16):采用方案 C —— 运行时过滤 + 构造时辅助 + 文档约定**
|
||||
>
|
||||
> **决策:** 保持 `content: Vec<ContentBlock>` 不变,不引入 `ToolResultContent` 类型。
|
||||
>
|
||||
> **理由:**
|
||||
> 1. ToolResult 主要由 LlmCycle 的工具循环构建(`Message::tool_result()`),而非用户手写,
|
||||
> 构造链本身已倾向于只产生 Text block。运行时过滤只是安全网。
|
||||
> 2. 引入 `ToolResultContent` 会膨胀类型体系,遍历 content 的代码需要为 ToolResult
|
||||
> 单独写一层,开发者负担大于收益。
|
||||
> 3. 未来如果 Provider 放宽约束(如 Anthropic 支持 tool_result 嵌套 tool_use),
|
||||
> 方向 B 只需要删掉过滤代码,方向 A 需要改类型定义 + 所有 match 分支,成本更高。
|
||||
>
|
||||
> **具体做法:**
|
||||
> - `ContentBlock::ToolResult.content` 保持 `Vec<ContentBlock>` 不变
|
||||
> - 新增辅助方法 `ContentBlock::is_valid_in_tool_result(&self) -> bool`,
|
||||
> 返回 `self` 是否是 ToolResult 中允许的类型(Text、Image、Audio、File、Extension)
|
||||
> - 每个 Provider 的 IR→原生格式映射层,在将 ToolResult content 转为 Provider 格式时,
|
||||
> **静默过滤 + warn log**:过滤掉 Thinking、ToolUse、ToolResult 等不允许的 block,
|
||||
> 使用 `tracing::warn!("忽略 ContentBlock::{:?} 在 ToolResult 中", block)` 记录
|
||||
> - `Message::tool_result()` 构造函数确保只产生 Text block(但不对类型做强制约束)
|
||||
> - **不返回错误**:非法嵌套不阻塞主流程,静默丢掉无关内容
|
||||
>
|
||||
> **何时实现:** Phase 4 实现 AnthropicProvider 的 ToolResult 映射时一起做。
|
||||
> **影响范围:** Provider 映射层(约 3-5 行过滤代码)+ `Message::tool_result()` 构造。
|
||||
|
||||
/// 思考内容(Anthropic thinking / OpenAI reasoning)
|
||||
Thinking {
|
||||
text: String,
|
||||
/// Anthropic 的 thinking 签名(用于验证思考未被篡改)。
|
||||
/// OpenAI 无此概念,为 None。
|
||||
signature: Option<String>,
|
||||
},
|
||||
/// 逃生舱 —— Provider 特有且无法映射的 content block
|
||||
Extension {
|
||||
kind: String,
|
||||
data: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImageSource {
|
||||
pub data: String, // base64 或 URL
|
||||
pub mime_type: String, // "image/png", "image/jpeg", "image/webp"
|
||||
pub is_url: bool, // true = URL, false = base64
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioSource {
|
||||
pub data: String, // base64
|
||||
pub format: AudioFormat, // 复用现有 AudioFormat
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileSource {
|
||||
pub data: String,
|
||||
pub filename: Option<String>,
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
#### 设计决策:为什么把 ToolUse 放在 ContentBlock 中
|
||||
|
||||
| 维度 | OpenAI 风格(独立 tool_calls 字段) | Anthropic 风格(ContentBlock 内) |
|
||||
|------|-----------------------------------|----------------------------------|
|
||||
| Assistant 消息结构 | `{ content, tool_calls }` | `{ content: [text, tool_use, ...] }` |
|
||||
| 文本与工具的顺序 | 分离,无法交错 | 按序排列,可交错 |
|
||||
| 多工具表达 | `tool_calls: [...]` 数组 | content 中多个 `tool_use` block |
|
||||
| **统一后的处理逻辑** | 需同时检查 content 和 tool_calls | **只需遍历一次 content blocks** |
|
||||
|
||||
选择 Anthropic 风格作为统一表达,因为遍历 content 即可获取所有信息,处理逻辑更简单。
|
||||
|
||||
### 3.2 Message —— 统一消息类型
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
System {
|
||||
content: Vec<ContentBlock>,
|
||||
},
|
||||
User {
|
||||
content: Vec<ContentBlock>,
|
||||
},
|
||||
Assistant {
|
||||
content: Vec<ContentBlock>,
|
||||
// 注意:ToolUse 在 content 中,不需要独立字段
|
||||
},
|
||||
Tool {
|
||||
content: Vec<ContentBlock>,
|
||||
/// 关联的 tool_call_id(OpenAI 格式需要)
|
||||
tool_call_id: String,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### 与当前 OpenaiChatMessage 的映射
|
||||
|
||||
| 当前类型 | 新 IR 类型 | 说明 |
|
||||
|---------|-----------|------|
|
||||
| `OpenaiChatMessage::Developer { content, name }` | `Message::System { content }` | 合并到 System(Anthropic 无 Developer role) |
|
||||
| `OpenaiChatMessage::System { content, name }` | `Message::System { content }` | name 暂不保留 |
|
||||
| `OpenaiChatMessage::User { content, name }` | `Message::User { content }` | `ContentField` 统一为 `Vec<ContentBlock>` |
|
||||
| `OpenaiChatMessage::Assistant { content, tool_calls, refusal, name }` | `Message::Assistant { content }` | tool_calls → ContentBlock::ToolUse;refusal → ContentBlock::Text |
|
||||
| `OpenaiChatMessage::Tool { content, tool_call_id }` | `Message::Tool { content, tool_call_id }` | 基本一致 |
|
||||
| `OpenaiChatMessage::Function { content, name }` | `Message::Tool { tool_call_id: name }` | 已废弃,映射到 Tool |
|
||||
|
||||
#### 便捷构造函数
|
||||
|
||||
```rust
|
||||
impl Message {
|
||||
pub fn user(text: impl Into<String>) -> Self;
|
||||
pub fn assistant(text: impl Into<String>) -> Self;
|
||||
pub fn system(text: impl Into<String>) -> Self;
|
||||
pub fn tool_result(tool_call_id: impl Into<String>, text: impl Into<String>) -> Self;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 StopReason —— 统一停止原因
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StopReason {
|
||||
/// 正常结束
|
||||
Stop,
|
||||
/// 达到 token 上限
|
||||
Length,
|
||||
/// 触发工具调用
|
||||
ToolUse,
|
||||
/// 被内容过滤
|
||||
ContentFilter,
|
||||
/// 达到最大 token 数
|
||||
MaxTokens,
|
||||
/// 命中停止序列
|
||||
StopSequence,
|
||||
/// 其他 / Provider 特有
|
||||
Other,
|
||||
}
|
||||
```
|
||||
|
||||
#### 跨 Provider 映射
|
||||
|
||||
| IR StopReason | OpenAI (finish_reason) | Anthropic (stop_reason) |
|
||||
|---|---|---|
|
||||
| `Stop` | `"stop"` | `"end_turn"` |
|
||||
| `Length` / `MaxTokens` | `"length"` | `"max_tokens"` |
|
||||
| `ToolUse` | `"tool_calls"` | `"tool_use"` |
|
||||
| `ContentFilter` | `"content_filter"` | — |
|
||||
| `StopSequence` | — | `"stop_sequence"` |
|
||||
| `Other` | `"function_call"` / 其他 | 其他 |
|
||||
|
||||
### 3.4 ThinkingConfig —— 思考配置
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThinkingConfig {
|
||||
/// 思考 token 预算
|
||||
pub budget_tokens: u32,
|
||||
}
|
||||
```
|
||||
|
||||
跨 Provider 特性:Anthropic 原生支持,OpenAI 通过 `reasoning_tokens` 间接支持。
|
||||
|
||||
### 3.5 ToolDefinition —— 统一工具定义
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolDefinition {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub parameters: serde_json::Value,
|
||||
pub strict: Option<bool>,
|
||||
}
|
||||
// ToolChoice 枚举保持不变
|
||||
```
|
||||
|
||||
与当前 `OpenaiToolDefinition` 一致,不需要改动。
|
||||
|
||||
### 3.6 MessageRequest —— 统一请求
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageRequest {
|
||||
// ════════════════════════════════════════════
|
||||
// 核心共通参数(所有 Provider 都有的概念)
|
||||
// ════════════════════════════════════════════
|
||||
pub model: String,
|
||||
pub messages: Vec<Message>,
|
||||
// 没有 `system` 字段——系统提示统一通过 `Message::System { content }`
|
||||
// 在 `messages` 中表达。各 Provider 在 IR→原生映射层自行处理差异。
|
||||
pub tools: Vec<ToolDefinition>,
|
||||
pub tool_choice: ToolChoice,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
pub top_p: Option<f32>,
|
||||
pub stop_sequences: Vec<String>,
|
||||
pub stream: bool,
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 跨 Provider 但非全部支持
|
||||
// ════════════════════════════════════════════
|
||||
pub thinking: Option<ThinkingConfig>,
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 逃生舱:Provider 特有参数
|
||||
// ════════════════════════════════════════════
|
||||
/// Provider 特有的扩展参数。
|
||||
/// 约定:每个 Provider 声明自己读取哪些 key;
|
||||
/// 遇到不认识的 key 静默忽略。
|
||||
pub extra: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
```
|
||||
|
||||
> **✅ 推演结论(2026-06-16):方案 B —— Result-based access + 可选 get_extra_as 扩展**
|
||||
>
|
||||
> **决策:** 保持 `HashMap<String, serde_json::Value>` 作为存储格式,不引入 Per-Provider extra struct
|
||||
> 作为硬约束。核心改动是将 `get_extra` 返回类型从 `Option<T>` 改为 `Result<Option<T>, ExtraError>`,
|
||||
> 使类型错误可被发现和传播。
|
||||
>
|
||||
> **理由:**
|
||||
> 1. extra 的本质是逃生舱——如果给逃生舱做全类型安全,就失去了逃生舱的灵活性。
|
||||
> 方向 A(Per-Provider struct)的 N+1 膨胀和维护负担超过了收益。
|
||||
> 2. 三种 extra 参数特性不同,需要不同的处理策略:
|
||||
> - **非关键参数**(`frequency_penalty`、`seed` 等):类型错了静默降级即可
|
||||
> - **关键参数**(`response_format`、`previous_response_id` 等):类型错了必须报错
|
||||
> - **整体读取**:Provider 想一次性结构化读取时,通过 `get_extra_as` 自行定义 struct
|
||||
> 3. `get_extra_as` 提供了"struct 方案"的可选路径,但不作为硬约束,
|
||||
> 每个 Provider 在自己的模块中决定是否使用。
|
||||
>
|
||||
> **具体做法:**
|
||||
> - 新增 `ExtraError` 枚举(`TypeMismatch` / `Deserialize` 两种变体)
|
||||
> - `get_extra` 签名改为 `Result<Option<T>, ExtraError>`;key 不存在 = `Ok(None)`,类型不匹配 = `Err`
|
||||
> - 新增 `get_extra_opt`:类型不匹配时 warn log + 返回 `None`,用于非关键参数
|
||||
> - 新增 `get_extra_as`:整体反序列化 extra 到自定义 struct(Provider 内部使用)
|
||||
> - `set_extra` 保持 `impl Into<Value>` 不变,不引入 Result(调用点无错误处理负担)
|
||||
> - Provider 映射层:关键参数用 `get_extra` + `?`,非关键参数用 `get_extra_opt`
|
||||
> - **不引入运行时 key 声明验证**——Provider 支持的 keys 仅在代码注释中声明,依赖集成测试保证正确性
|
||||
>
|
||||
> **何时实现:** Phase 2 实现 `MessageRequest` 类型时一起完成
|
||||
> **影响范围:** `MessageRequest`(方法签名变更)+ 每个 Provider 的映射层(适配新签名)
|
||||
|
||||
### ExtraError —— extra 参数访问错误
|
||||
|
||||
```rust
|
||||
/// extra 参数访问错误。
|
||||
///
|
||||
/// 注意:`NotFound` 不等价于"错误"——optional 参数未设置(key 不存在)是正常状态,
|
||||
/// 返回 `Ok(None)` 而非错误。本类型只覆盖"存在但类型不对"的场景。
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ExtraError {
|
||||
/// key 存在但值的类型与要求不匹配。
|
||||
#[error("extra 字段 `{key}` 类型不匹配: {details}")]
|
||||
TypeMismatch {
|
||||
key: String,
|
||||
details: String,
|
||||
},
|
||||
/// 整体反序列化失败(用于 get_extra_as 场景)。
|
||||
#[error("extra 反序列化失败: {0}")]
|
||||
Deserialize(String),
|
||||
}
|
||||
```
|
||||
|
||||
### MessageRequest —— extra 访问方法
|
||||
|
||||
```rust
|
||||
impl MessageRequest {
|
||||
/// 核心方法:安全读取单个 extra 字段。
|
||||
///
|
||||
/// - key 不存在 → `Ok(None)`
|
||||
/// - key 存在且类型匹配 → `Ok(Some(value))`
|
||||
/// - key 存在但类型不匹配 → `Err(ExtraError::TypeMismatch)`
|
||||
///
|
||||
/// Provider 映射层对**关键参数**(如 response_format)使用此方法 + `?`。
|
||||
pub fn get_extra<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, ExtraError> {
|
||||
match self.extra.get(key) {
|
||||
None => Ok(None),
|
||||
Some(value) => serde_json::from_value(value.clone())
|
||||
.map(Some)
|
||||
.map_err(|e| ExtraError::TypeMismatch {
|
||||
key: key.to_string(),
|
||||
details: e.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 宽松读取 —— 类型不匹配时 warn log + 返回 `None`。
|
||||
///
|
||||
/// 适用于**非关键参数**(如 frequency_penalty、seed、presence_penalty)。
|
||||
/// Provider 映射层无需处理 Result,遇到类型错误静默降级。
|
||||
pub fn get_extra_opt<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
|
||||
match self.get_extra(key) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("忽略 extra 参数 `{}`: {}", key, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 整体反序列化 extra 到自定义结构体。
|
||||
///
|
||||
/// 适用于 Provider 想在映射层内一次性读取所有 extra 参数的场景。
|
||||
/// Provider 在自己的模块中定义 struct,借助 `#[serde(deny_unknown_fields)]` 获得约束:
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// // 在 openai/provider.rs
|
||||
/// #[derive(Deserialize)]
|
||||
/// #[serde(deny_unknown_fields)]
|
||||
/// struct OpenaiExtraParams {
|
||||
/// frequency_penalty: Option<f32>,
|
||||
/// seed: Option<i64>,
|
||||
/// response_format: Option<ResponseFormat>,
|
||||
/// }
|
||||
///
|
||||
/// // 映射层中:
|
||||
/// let extra: OpenaiExtraParams = request.get_extra_as()?;
|
||||
/// ```
|
||||
///
|
||||
/// **注意:** 使用 `deny_unknown_fields` 时,来自其他 Provider 的 extra key
|
||||
/// 会导致反序列化失败。不设置 `deny_unknown_fields` 则自动忽略无关 key。
|
||||
pub fn get_extra_as<T: DeserializeOwned>(&self) -> Result<T, ExtraError> {
|
||||
let obj = self
|
||||
.extra
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
serde_json::from_value(serde_json::Value::Object(obj))
|
||||
.map_err(|e| ExtraError::Deserialize(e.to_string()))
|
||||
}
|
||||
|
||||
/// 设置 extra 参数。
|
||||
///
|
||||
/// 值通过 `impl Into<serde_json::Value>` 传入,支持:
|
||||
/// - `set_extra("user", "abc")` —— `&str` → `Value::String`
|
||||
/// - `set_extra("seed", Value::from(42_i64))` —— 显式 Value 构造
|
||||
/// - `set_extra("logit_bias", json!({"2435": -100}))` —— json! 宏
|
||||
///
|
||||
/// 对复杂类型推荐使用 `json!()` 宏以保证可读性。
|
||||
/// 无返回值 —— 调用点无错误处理负担(HashMap insert 不会失败)。
|
||||
pub fn set_extra(&mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) {
|
||||
self.extra.insert(key.into(), value.into());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Provider 映射层使用模式
|
||||
|
||||
```rust
|
||||
// === OpenAI provider IR→native 映射示例 ===
|
||||
|
||||
// 非关键参数 —— get_extra_opt,类型错了静默降级
|
||||
let frequency_penalty: Option<f32> = request.get_extra_opt("frequency_penalty");
|
||||
let presence_penalty: Option<f32> = request.get_extra_opt("presence_penalty");
|
||||
let seed: Option<i64> = request.get_extra_opt("seed");
|
||||
|
||||
// 关键参数 —— get_extra + ?,类型错误必须报出来
|
||||
let response_format: Option<ResponseFormat> = request.get_extra("response_format").map_err(|e| {
|
||||
LlmError::Other(format!("extra 参数读取失败: {e}"))
|
||||
})?;
|
||||
|
||||
// 或者 Provider 定义自己的 struct 一把读取
|
||||
// let extra: OpenaiExtraParams = request.get_extra_as()?;
|
||||
```
|
||||
|
||||
### 使用约定
|
||||
|
||||
- 每个 Provider 在模块顶部的注释中声明自己读取的 extra key
|
||||
- Provider 遇到不认识的 key **静默忽略**
|
||||
- **key 命名规范**:使用 `snake_case`,与 Provider 原生 API 的参数名一致
|
||||
- **key 去重规则**:extra 中出现的 key 不能与 `MessageRequest` 的命名参数字段重复
|
||||
(如 `max_tokens` 是命名参数,不能在 extra 中重复设置)
|
||||
- 非关键参数优先使用 `get_extra_opt`,关键参数使用 `get_extra` + 显式错误处理
|
||||
|
||||
### 典型 extra key 约定
|
||||
|
||||
| key | 值类型 | 使用者 | 读取方式 | 说明 |
|
||||
|-----|--------|--------|---------|------|
|
||||
| `frequency_penalty` | `f32` | OpenAI | `get_extra_opt` | 频率惩罚 |
|
||||
| `presence_penalty` | `f32` | OpenAI | `get_extra_opt` | 存在惩罚 |
|
||||
| `logit_bias` | `HashMap<String, f32>` | OpenAI | `get_extra_opt` | Token 偏置 |
|
||||
| `seed` | `i64` | OpenAI | `get_extra_opt` | 随机种子 |
|
||||
| `service_tier` | `String` | OpenAI | `get_extra_opt` | 服务等级 |
|
||||
| `user` | `String` | OpenAI | `get_extra_opt` | 最终用户标识 |
|
||||
| `response_format` | `ResponseFormat` | OpenAI | `get_extra` | **关键**:响应格式 |
|
||||
| `parallel_tool_calls` | `bool` | OpenAI | `get_extra_opt` | 是否并行工具 |
|
||||
| `built_in_tools` | `Vec<String>` | OpenAI Response | `get_extra_opt` | 内置工具列表 |
|
||||
| `previous_response_id` | `String` | OpenAI Response | `get_extra` | **关键**:前序响应 ID |
|
||||
|
||||
### 3.7 MessageResponse —— 统一响应
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageResponse {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub message: Message,
|
||||
pub usage: Usage, // 复用现有 Usage 类型
|
||||
pub stop_reason: StopReason,
|
||||
/// Provider 特有扩展数据
|
||||
pub extra: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl MessageResponse {
|
||||
/// 提取纯文本内容(拼接所有 Text block)
|
||||
pub fn text(&self) -> String;
|
||||
}
|
||||
```
|
||||
|
||||
#### Usage 的跨 Provider 兼容性
|
||||
|
||||
当前 `Usage` 类型:
|
||||
|
||||
```rust
|
||||
pub struct Usage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
pub completion_tokens_details: Option<CompletionTokensDetails>,
|
||||
pub prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
}
|
||||
```
|
||||
|
||||
| Provider | prompt_tokens | completion_tokens | 其他 | 映射方式 |
|
||||
|----------|--------------|-------------------|------|---------|
|
||||
| OpenAI | `usage.prompt_tokens` | `usage.completion_tokens` | `details.*` | 直接使用 |
|
||||
| Anthropic | `usage.input_tokens` | `usage.output_tokens` | `cache_*` 映射到 `details.cached_tokens` | 映射赋值 |
|
||||
|
||||
`Usage` 类型可以直接复用,无需修改。
|
||||
@@ -0,0 +1,446 @@
|
||||
# LlmProvider Trait 设计
|
||||
|
||||
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §4 LlmProvider Trait 设计。
|
||||
>
|
||||
> **相关文件:**
|
||||
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型定义(MessageRequest、MessageResponse、StreamEvent 等)
|
||||
> - [9d-provider-implementations.md](9d-provider-implementations.md) — 各 Provider 的具体实现策略
|
||||
> - [9f-edge-cases.md](9f-edge-cases.md) — Thinking signature 等边界情况(与 StreamEvent 设计关联)
|
||||
|
||||
## 4. LlmProvider Trait 设计
|
||||
|
||||
### 4.1 核心接口
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
/// 发送消息请求,获取完整响应。
|
||||
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError>;
|
||||
|
||||
/// 流式消息请求,返回语义化事件流。
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<
|
||||
Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>,
|
||||
LlmError,
|
||||
>;
|
||||
|
||||
/// 返回 Provider 的能力描述。
|
||||
fn capabilities(&self) -> ProviderCapabilities;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 ProviderCapabilities
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderCapabilities {
|
||||
/// Provider 标识名称
|
||||
pub provider_name: &'static str,
|
||||
/// 支持的模型列表(None = 不限制)
|
||||
pub supported_models: Option<Vec<String>>,
|
||||
/// 特性标记
|
||||
pub features: ProviderFeatures,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ProviderFeatures {
|
||||
pub streaming: bool,
|
||||
pub thinking: bool,
|
||||
pub vision: bool,
|
||||
pub audio_input: bool,
|
||||
pub tool_use: bool,
|
||||
pub parallel_tool_calls: bool,
|
||||
/// true=OpenAI风格(system in messages), false=Anthropic风格(system as param)
|
||||
pub system_prompt_in_messages: bool,
|
||||
pub max_context_window: u32,
|
||||
}
|
||||
```
|
||||
|
||||
#### capabilities() 的使用场景
|
||||
|
||||
1. **LlmCycle**:根据 `features.thinking` 决定是否启用 thinking 模式
|
||||
2. **AgentBuilder**:在构建时校验模型是否支持所需特性
|
||||
3. **UI/CLI**:展示 Provider 的能力矩阵
|
||||
4. **智能路由**:根据能力自动选择最佳 Provider
|
||||
|
||||
### 4.3 流式事件 StreamEvent
|
||||
|
||||
```rust
|
||||
/// 流式事件 —— 流式响应的语义化增量构建过程。
|
||||
/// 一组 StreamEvent 最终可汇聚为一个完整的 MessageResponse。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StreamEvent {
|
||||
// ── Meta ──
|
||||
/// 消息开始
|
||||
MessageStart { id: String, model: String },
|
||||
|
||||
// ── Content Block 边界 ──
|
||||
/// Content block 开始(index = 全局 content block 序号)
|
||||
ContentBlockStart { index: u32, block_type: ContentBlockType },
|
||||
/// Content block 结束
|
||||
ContentBlockEnd { index: u32 },
|
||||
|
||||
// ── 块内增量(无 index,隐含属于当前活跃 block)──
|
||||
/// 文本增量
|
||||
TextDelta { text: String },
|
||||
/// 思考增量(Anthropic thinking block)
|
||||
ThinkingDelta { text: String },
|
||||
/// 拒绝增量(OpenAI refusal,汇聚为 ContentBlock::Text)
|
||||
RefusalDelta { text: String },
|
||||
|
||||
// ── Tool Call 参数(index = block index)──
|
||||
/// 工具参数增量
|
||||
ToolCallArgumentsDelta { index: u32, arguments: String },
|
||||
/// 工具参数结束,可尝试解析 JSON
|
||||
ToolCallEnd { index: u32 },
|
||||
|
||||
// ── 汇总 ──
|
||||
/// Token 用量(字段级合并,见 PartialUsage)
|
||||
CostUpdate { usage: PartialUsage },
|
||||
/// 消息完成(thinking_signature 仅 Anthropic 场景,回填到最后的 Thinking block)
|
||||
MessageComplete { stop_reason: StopReason, thinking_signature: Option<String> },
|
||||
|
||||
// ── 错误 ──
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
/// ContentBlock 的类型标识,嵌入在 ContentBlockStart 事件中。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ContentBlockType {
|
||||
Text,
|
||||
Thinking,
|
||||
Refusal,
|
||||
ToolUse { id: String, name: String },
|
||||
}
|
||||
|
||||
/// CostUpdate 中携带的用量数据——只含本次更新中真正下发的字段。
|
||||
///
|
||||
/// 汇聚算法做字段级合并(覆盖各字段的 Some 值),而非全量覆盖。
|
||||
/// 解决 Provider 分多次下发 Usage 的问题:
|
||||
/// - OpenAI: 一次全量下发(所有字段都有值)
|
||||
/// - Anthropic: message_start 时下发 input_tokens,message_delta 时下发 output_tokens
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PartialUsage {
|
||||
pub prompt_tokens: Option<u32>,
|
||||
pub completion_tokens: Option<u32>,
|
||||
pub total_tokens: Option<u32>,
|
||||
pub completion_tokens_details: Option<CompletionTokensDetails>,
|
||||
pub prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
}
|
||||
```
|
||||
|
||||
#### 设计决策:为什么移除 ToolCallStart
|
||||
|
||||
`ToolCallStart { index, id, name }` 的语义本质是一个 ContentBlock 的开始(类型为 ToolUse),
|
||||
没有单独存在的必要。合并到 `ContentBlockStart.block_type = ToolUse { id, name }` 后:
|
||||
- **index 语义统一**:ContentBlockStart.index 是全文档唯一的 content block 序号
|
||||
- **事件减少**:每次 tool_use block 开始少发一个事件,Anthropic 映射更自然(`content_block_start` → `ContentBlockStart`)
|
||||
- **#4议题自动解决**:index 在 IR 层面始终为全局序号,OpenAI Provider 内部的局部→全局映射完全封装在 Provider 层,
|
||||
不在 IR 中暴露差异
|
||||
|
||||
#### 设计决策:为什么 TextDelta 不带 index
|
||||
|
||||
- 单 SSE 连接内 TCP 保证字节序,TextDelta 必然属于最后一个 ContentBlockStart 开始的 block
|
||||
- 不携带 index 可减少事件体积
|
||||
- 如果未来 LlmCycle 需要跨 Provider 合并流,可向后兼容地添加 index 字段(降级策略:无 index 时默认归属当前活跃 block)
|
||||
|
||||
#### 设计决策:CostUpdate 使用 PartialUsage 做字段级合并
|
||||
|
||||
覆盖策略(取最新值)在 Anthropic 场景下出错——Anthropic 分两次下发:
|
||||
```
|
||||
CostUpdate #1: { prompt_tokens: Some(100), completion_tokens: None, total_tokens: Some(100) }
|
||||
CostUpdate #2: { prompt_tokens: None, completion_tokens: Some(50), total_tokens: Some(150) }
|
||||
```
|
||||
如果直接 `Usage` 全量覆盖,第一次的 prompt_tokens 会被第二次的 `None` 清零。
|
||||
改为 `PartialUsage`(字段级 `Option`)后,汇聚算法只更新 `Some` 的字段,避免清零。
|
||||
|
||||
#### 与当前 StreamEvent 的对比
|
||||
|
||||
| 当前 StreamEvent | 新 StreamEvent | 理由 |
|
||||
|-----------------|---------------|------|
|
||||
| `AssistantTextDelta { text }` | `TextDelta { text }` | 简洁化 |
|
||||
| — | `ThinkingDelta { text }` | Anthropic 需要 |
|
||||
| — | `RefusalDelta { text }` | OpenAI 需要 |
|
||||
| `ToolExecutionStarted { tool_name, input, tool_call_id }` | `ContentBlockStart { index, ToolUse { id, name } }` + `ToolCallArgumentsDelta { index, arguments }` | 拆分为 block 边界 + 参数增量 |
|
||||
| `ToolExecutionCompleted` | **移除** | LlmCycle 层事件,非 Provider 层 |
|
||||
| — | `ContentBlockStart` / `ContentBlockEnd` | 显式 block 边界标记 |
|
||||
| — | `BlockContentType` | ContentBlock 类型标识 |
|
||||
| `CostUpdate { usage: Usage }` | `CostUpdate { usage: PartialUsage }` | 字段级合并,兼容多 Provider |
|
||||
| `TurnComplete { reason }` | `MessageComplete { stop_reason, thinking_signature: Option<String> }` | 语义更准确 + thinking 签名回填 |
|
||||
| `Error { message }` | 保留 | ✅ |
|
||||
| — | `MessageStart { id, model }` | Anthropic 需要 |
|
||||
| — | `ToolCallEnd { index }` | 明确参数完整时间点 |
|
||||
| `ToolCallStart { index, id, name }` | **移除** | 合并到 ContentBlockStart.ToolUse |
|
||||
|
||||
---
|
||||
|
||||
### 4.4 PartialMessageResponse —— 流式事件的汇聚算法
|
||||
|
||||
> **✅ 推演结论(2026-06-17):采用方案 B(显式边界)—— ContentBlockStart/End 声明 block 边界,BTreeMap 按 index 分桶组装**
|
||||
>
|
||||
> **核心思路:** 放弃"类型切换推断 block 边界"的隐含方案。为 StreamEvent 增加 `ContentBlockStart` 和 `ContentBlockEnd`
|
||||
> 事件,使每个 block 的开始和结束都有精确的事件标记。汇聚算法由"线性累积 + 隐含 flush + 延迟排序"改为
|
||||
> **"按 index 分桶 + 排序组装"**,彻底消除对事件到达顺序的依赖。
|
||||
>
|
||||
> **决策理由:**
|
||||
> 1. 类型切换推断在 ToolCallEnd 后跟 TextDelta 的场景下导致顺序错乱(ToolUse 被延迟插入到 Text 之后)
|
||||
> 2. 显式边界将位置信息绑定到 index,不依赖事件到达时序,容错性更强
|
||||
> 3. Anthropic 的 `content_block_start/stop` 天然映射为 `ContentBlockStart/End`
|
||||
> 4. OpenAI 的无边界流式由 Provider 内部分析 delta 类型来合成边界,封装在 Provider 层
|
||||
> 5. #4(ToolCallStart index 归一化)自动解决——index 统一为全局 content block 序号
|
||||
|
||||
#### PartialMessageResponse 结构体
|
||||
|
||||
```rust
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use serde_json::Value;
|
||||
|
||||
/// 流式事件的累积状态 —— 按 index 分桶,逐个构建 ContentBlock。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PartialMessageResponse {
|
||||
// ── 来自 MessageStart ──
|
||||
pub id: Option<String>,
|
||||
pub model: Option<String>,
|
||||
|
||||
/// 按 index 分桶的 ContentBlock 构建器(BTreeMap 保证升序遍历)
|
||||
pub blocks: BTreeMap<u32, ContentBlockBuilder>,
|
||||
|
||||
/// Tool call 参数累积(按 block index 关联)
|
||||
pub tool_call_args: HashMap<u32, String>,
|
||||
|
||||
/// 已收到 ContentBlockEnd 的 block index 集合
|
||||
pub block_completion: HashSet<u32>,
|
||||
|
||||
/// 当前最后打开的 block index(供无 index 的 TextDelta 定位)
|
||||
pub last_open_index: Option<u32>,
|
||||
|
||||
/// Token 用量(字段级合并后的最终值)
|
||||
pub usage: Usage,
|
||||
|
||||
/// 结束状态
|
||||
pub stop_reason: Option<StopReason>,
|
||||
|
||||
/// MessageComplete 携带的 thinking signature(留待 finalize 回填)
|
||||
pub thinking_signature: Option<String>,
|
||||
|
||||
pub is_errored: bool,
|
||||
pub is_complete: bool,
|
||||
}
|
||||
|
||||
/// 按 index 分桶的 ContentBlock 构建器。
|
||||
#[derive(Debug)]
|
||||
pub enum ContentBlockBuilder {
|
||||
Text(String),
|
||||
Thinking { buffer: String, signature: Option<String> },
|
||||
Refusal(String),
|
||||
ToolUse { id: String, name: String },
|
||||
}
|
||||
```
|
||||
|
||||
#### apply_to 算法
|
||||
|
||||
```rust
|
||||
impl StreamEvent {
|
||||
/// 将当前事件应用到 PartialMessageResponse 上。
|
||||
/// 返回 true 表示正常处理,false 表示应停止处理后续事件。
|
||||
pub fn apply_to(&self, state: &mut PartialMessageResponse) -> bool {
|
||||
match self {
|
||||
// ──────────────── Meta ────────────────
|
||||
StreamEvent::MessageStart { id, model } => {
|
||||
state.id = Some(id.clone());
|
||||
state.model = Some(model.clone());
|
||||
true
|
||||
}
|
||||
|
||||
// ──────────────── Block 边界 ────────────────
|
||||
StreamEvent::ContentBlockStart { index, block_type } => {
|
||||
let builder = match block_type {
|
||||
ContentBlockType::Text => ContentBlockBuilder::Text(String::new()),
|
||||
ContentBlockType::Thinking => ContentBlockBuilder::Thinking {
|
||||
buffer: String::new(),
|
||||
signature: None,
|
||||
},
|
||||
ContentBlockType::Refusal => ContentBlockBuilder::Refusal(String::new()),
|
||||
ContentBlockType::ToolUse { id, name } =>
|
||||
ContentBlockBuilder::ToolUse { id: id.clone(), name: name.clone() },
|
||||
};
|
||||
state.blocks.entry(*index).or_insert(builder);
|
||||
state.last_open_index = Some(*index);
|
||||
true
|
||||
}
|
||||
|
||||
StreamEvent::ContentBlockEnd { index } => {
|
||||
state.block_completion.insert(*index);
|
||||
true
|
||||
}
|
||||
|
||||
// ──────────────── 块内增量 ────────────────
|
||||
StreamEvent::TextDelta { text } => {
|
||||
// 定位到最后打开的 block
|
||||
if let Some(idx) = state.last_open_index {
|
||||
if let Some(ContentBlockBuilder::Text(ref mut buf)) = state.blocks.get_mut(&idx) {
|
||||
buf.push_str(text);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("TextDelta 到达时无活跃 block");
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
StreamEvent::ThinkingDelta { text } => {
|
||||
if let Some(idx) = state.last_open_index {
|
||||
if let Some(ContentBlockBuilder::Thinking { ref mut buffer, .. }) =
|
||||
state.blocks.get_mut(&idx)
|
||||
{
|
||||
buffer.push_str(text);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("ThinkingDelta 到达时无活跃 block");
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
StreamEvent::RefusalDelta { text } => {
|
||||
if let Some(idx) = state.last_open_index {
|
||||
if let Some(ContentBlockBuilder::Refusal(ref mut buf)) =
|
||||
state.blocks.get_mut(&idx)
|
||||
{
|
||||
buf.push_str(text);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("RefusalDelta 到达时无活跃 block");
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ──────────────── Tool Call 参数 ────────────────
|
||||
StreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
||||
state
|
||||
.tool_call_args
|
||||
.entry(*index)
|
||||
.or_default()
|
||||
.push_str(arguments);
|
||||
true
|
||||
}
|
||||
|
||||
StreamEvent::ToolCallEnd { index } => {
|
||||
// ToolCallEnd 只做标记,参数在 finalize 时解析
|
||||
state.block_completion.insert(*index);
|
||||
true
|
||||
}
|
||||
|
||||
// ──────────────── 汇总 ────────────────
|
||||
StreamEvent::CostUpdate { usage } => {
|
||||
Self::apply_cost_update(state, usage);
|
||||
true // 即使 is_complete 后 CostUpdate 仍然可以到达
|
||||
}
|
||||
|
||||
StreamEvent::MessageComplete {
|
||||
stop_reason,
|
||||
thinking_signature,
|
||||
} => {
|
||||
state.stop_reason = Some(*stop_reason);
|
||||
state.thinking_signature = thinking_signature.clone();
|
||||
state.is_complete = true;
|
||||
true
|
||||
}
|
||||
|
||||
// ──────────────── 错误 ────────────────
|
||||
StreamEvent::Error { message } => {
|
||||
state.is_errored = true;
|
||||
tracing::error!("流式处理中发生错误: {}", message);
|
||||
false // 终止处理
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 字段级合并 CostUpdate(仅覆盖 Some 的字段)。
|
||||
fn apply_cost_update(state: &mut PartialMessageResponse, update: &PartialUsage) {
|
||||
if let Some(v) = update.prompt_tokens {
|
||||
state.usage.prompt_tokens = v;
|
||||
}
|
||||
if let Some(v) = update.completion_tokens {
|
||||
state.usage.completion_tokens = v;
|
||||
}
|
||||
if let Some(v) = update.total_tokens {
|
||||
state.usage.total_tokens = v;
|
||||
}
|
||||
if update.completion_tokens_details.is_some() {
|
||||
state.usage.completion_tokens_details = update.completion_tokens_details;
|
||||
}
|
||||
if update.prompt_tokens_details.is_some() {
|
||||
state.usage.prompt_tokens_details = update.prompt_tokens_details;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### finalize —— 完成逻辑
|
||||
|
||||
```rust
|
||||
impl PartialMessageResponse {
|
||||
/// 将所有累积状态转化为最终的 MessageResponse。
|
||||
pub fn finalize(mut self) -> Result<MessageResponse, LlmError> {
|
||||
let id = self.id.unwrap_or_else(|| "stream-unknown".to_string());
|
||||
let model = self.model.unwrap_or_else(|| "unknown".to_string());
|
||||
let stop_reason = self.stop_reason.unwrap_or(StopReason::Other);
|
||||
|
||||
// 1. 按 index 升序遍历所有 blocks,转换为 ContentBlock
|
||||
let mut content: Vec<ContentBlock> = Vec::new();
|
||||
for (_index, builder) in std::mem::take(&mut self.blocks).into_iter() {
|
||||
let block = match builder {
|
||||
ContentBlockBuilder::Text(text) => ContentBlock::Text { text },
|
||||
ContentBlockBuilder::Thinking { buffer, mut signature } => {
|
||||
// 如果 Thinking block 的 signature 尚未填充,用 MessageComplete 的回填
|
||||
if signature.is_none() {
|
||||
signature = self.thinking_signature.clone();
|
||||
}
|
||||
ContentBlock::Thinking {
|
||||
text: buffer,
|
||||
signature,
|
||||
}
|
||||
}
|
||||
ContentBlockBuilder::Refusal(text) => ContentBlock::Text { text },
|
||||
ContentBlockBuilder::ToolUse { id, name } => {
|
||||
let arguments = self.tool_call_args.remove(&(_index as u32))
|
||||
.unwrap_or_default();
|
||||
let input: Value = serde_json::from_str(&arguments)
|
||||
.unwrap_or(Value::Null);
|
||||
ContentBlock::ToolUse { id, name, input }
|
||||
}
|
||||
};
|
||||
content.push(block);
|
||||
}
|
||||
|
||||
Ok(MessageResponse {
|
||||
id,
|
||||
model,
|
||||
message: Message::Assistant { content },
|
||||
usage: self.usage,
|
||||
stop_reason,
|
||||
extra: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 检查是否已收到足以构造"有意义"响应的数据。
|
||||
pub fn is_meaningful(&self) -> bool {
|
||||
self.id.is_some() && (self.is_complete || self.is_errored)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 边界情况处理
|
||||
|
||||
| 边界场景 | 处理策略 | 理由 |
|
||||
|----------|---------|------|
|
||||
| MessageStart 前收到 TextDelta | warn log,忽略增量 | 不合法流,防御性处理 |
|
||||
| ToolCallArgumentsDelta 乱序 | HashMap key 天然容忍 | index 作为关联 key,到达顺序不影响最终累积 |
|
||||
| 多次 CostUpdate | 字段级合并(PartialUsage) | OpenAI 一次全量,Anthropic 分两次,OpenAI Response 可能多次 |
|
||||
| 同一 index 收到多次 ContentBlockStart | `entry(*index).or_insert()` 幂等 | 第二次不覆盖已有内容 |
|
||||
| MessageComplete 后 CostUpdate 才到 | 不阻断,仍然合并 | Usage 最终值可能最后才到 |
|
||||
| ContentBlockEnd 缺失 | finalize 时仍然输出内容 | 不完整的 block 也是内容 |
|
||||
| 无任何 ContentBlockStart | finalize 返回空的 Assistant 消息 | 边界场景,不阻断 |
|
||||
| Error 后收到其他事件 | Error 返回 false,上层停止调用 apply_to | 一旦出错,不再处理后续事件 |
|
||||
| thinking_signature 未填 | Thinking block 的 signature 为 None | 安全降级 |
|
||||
@@ -0,0 +1,459 @@
|
||||
# Provider 实现策略
|
||||
|
||||
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §5 Provider 实现策略。
|
||||
>
|
||||
> **相关文件:**
|
||||
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型定义(ContentBlock、Message、MessageRequest/Response 等)
|
||||
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — LlmProvider trait 定义(chat、chat_stream 签名)
|
||||
> - [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) — LlmCycle 改造(system prompt 冲突等与 Provider 相关)
|
||||
> - [9f-edge-cases.md](9f-edge-cases.md) — Thinking signature 端到端传递(与 Anthropic 流式强相关)
|
||||
|
||||
## 5. Provider 实现策略
|
||||
|
||||
### 5.1 OpenaiProvider(兼容 Chat API)
|
||||
|
||||
```
|
||||
MessageRequest
|
||||
│
|
||||
├── model → model
|
||||
├── messages → messages (逐条映射,见下方)
|
||||
├── system → 插入为首条 System message(如无 System message 时)
|
||||
├── tools → tools (OpenaiTool::Function)
|
||||
├── tool_choice → tool_choice
|
||||
├── max_tokens → max_tokens
|
||||
├── temperature → temperature
|
||||
├── top_p → top_p
|
||||
├── stop_sequences → stop (StopSequence::Multiple)
|
||||
├── thinking → 忽略(OpenAI 不支持)
|
||||
└── extra.* → 对应字段 / extra_body
|
||||
|
||||
Message → OpenaiChatMessage:
|
||||
System { content } → System { content: to_openai_content(content) }
|
||||
User { content } → User { content: to_openai_content(content) }
|
||||
Assistant { content } → Assistant {
|
||||
content: to_openai_content(text_blocks),
|
||||
tool_calls: extract_tool_calls(content)
|
||||
}
|
||||
Tool { content, tool_call_id } → Tool { content, tool_call_id }
|
||||
|
||||
ContentBlock → OpenaiContentPart:
|
||||
Text { text } → Text { text }
|
||||
Image { source } → Image { image_url: { url, detail } }
|
||||
Audio { source } → InputAudio { input_audio }
|
||||
File { source } → File { file }
|
||||
ToolUse { id, name, input } → 转为 OpenaiToolCall 放入 tool_calls 字段
|
||||
ToolResult { .. } → 通过 tool_call_id 关联到 Tool 消息
|
||||
Thinking { .. } → 忽略
|
||||
Extension { .. } → 忽略
|
||||
|
||||
OpenaiChatResponse → MessageResponse:
|
||||
id → id
|
||||
model → model
|
||||
usage → usage
|
||||
choices[0].finish_reason → stop_reason
|
||||
choices[0].message → Message::Assistant {
|
||||
content: [
|
||||
ContentBlock::Text { text },
|
||||
... (msg.tool_calls → ContentBlock::ToolUse)
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **✅ 推演结论(2026-06-18):**
|
||||
>
|
||||
> ### 架构分层
|
||||
>
|
||||
> OpenAI 的流式转换拆为**两层**,字节解析层通用、事件转换层 OpenAI 特定:
|
||||
>
|
||||
> ```
|
||||
> bytes_stream()
|
||||
> │
|
||||
> ▼
|
||||
> SseByteStream [sse.rs — 通用层]
|
||||
> │ 逐行分割、按空行分帧、解析 event:/data: 行前缀、
|
||||
> │ 过滤 "[DONE]"/ping、缓冲拼接碎片、多行 data: 自动拼接
|
||||
> ▼
|
||||
> Stream<Item=Result<SseEvent, LlmError>> ← 结构化 SSE 帧
|
||||
> │ SseEvent { event_name: Option<String>, data: String }
|
||||
> │ - OpenAI: event_name = None(未命名事件)
|
||||
> │ - Anthropic: event_name = Some("message_start" | "content_block_delta" | ...)
|
||||
> │
|
||||
> ├─ OpenAI ────→ OpenaiStreamToEvents [openai/stream.rs]
|
||||
> │ 忽略 event_name,反序列化 data → OpenaiChatChunk → 状态机
|
||||
> │
|
||||
> └─ Anthropic ──→ AnthropicStreamToEvents [anthropic/stream.rs]
|
||||
> 按 event_name 分发事件类型 → 事件映射
|
||||
> ```
|
||||
>
|
||||
> **Layer 1 — `SseByteStream<S>`(新增 `src/llm/provider/sse.rs`)**
|
||||
>
|
||||
> 从当前 `SseChunkStream`(`openai.rs` 内联实现)提取字节解析逻辑为通用 SSE 解析器。
|
||||
> 产出 `SseEvent` 结构体,携带 `event_name` + `data` 两部分信息。
|
||||
> OpenAI 和 Anthropic 均直接复用此层,各自的事件转换器按需使用 `event_name`。
|
||||
>
|
||||
> **Layer 2 — `OpenaiStreamToEvents`(新增 `src/llm/provider/openai/stream.rs`)**
|
||||
>
|
||||
> 接收 `SseEvent` 流,忽略 `event_name`(OpenAI 为 `None`),
|
||||
> 将 `data` 反序列化为 `OpenaiChatChunk`(保留作为内部格式),
|
||||
> 通过状态机转换为 `StreamEvent` 事件流。
|
||||
>
|
||||
> ### 状态机设计
|
||||
>
|
||||
> ```rust
|
||||
> pub struct OpenaiStreamToEvents<S> {
|
||||
> inner: S, // Stream<Item=Result<String, LlmError>>
|
||||
> // ── 状态 ──
|
||||
> global_index: u32, // 下一个可用 content block 序号
|
||||
> current_block: Option<(u32, CurrentBlockType)>, // 当前活跃 block
|
||||
> tool_call_indices: HashMap<u32, u32>, // OpenAI tool_call.index → 全局序号
|
||||
> is_complete: bool,
|
||||
> }
|
||||
>
|
||||
> enum CurrentBlockType { Text, Refusal, ToolUse }
|
||||
> ```
|
||||
>
|
||||
> **每条 JSON 行的处理流程:**
|
||||
>
|
||||
> ```
|
||||
> 收到一行 JSON 字符串
|
||||
> ├─ 解析为 OpenaiChatChunk
|
||||
> │
|
||||
> ├─ Phase 1: 处理 delta 内容(先增量)
|
||||
> │ ├─ delta.content → ensure_block(Text) → TextDelta
|
||||
> │ ├─ delta.refusal → ensure_block(Refusal) → RefusalDelta
|
||||
> │ └─ delta.tool_calls
|
||||
> │ ├─ 新 tool call(function.name 有值)
|
||||
> │ │ → ensure_block(ToolUse, id, name) → (不产参数事件,等后续 arguments)
|
||||
> │ └─ 已有 tool call(function.arguments 有值)
|
||||
> │ → ToolCallArgumentsDelta(index, arguments)
|
||||
> │
|
||||
> └─ Phase 2: 处理汇总(后收束)
|
||||
> ├─ finish_reason 存在 → close_current_block() + MessageComplete
|
||||
> ├─ usage 存在 → CostUpdate
|
||||
> └─ 同时存在 → CostUpdate → close_block → MessageComplete
|
||||
> ```
|
||||
>
|
||||
> **核心抽象 `ensure_block`:** 当新 chunk 的 delta 类型与当前活跃 block 不同时,
|
||||
> 自动关闭当前 block(emit `ContentBlockEnd`)并开启新 block(emit `ContentBlockStart`)。
|
||||
> 同类型继续时只发增量事件,不切换。
|
||||
>
|
||||
> **状态转移表:**
|
||||
>
|
||||
> | 当前状态 | 收到 delta.content | 收到 delta.tool_calls (new) | 收到 delta.tool_calls (延续) | 收到 finish_reason |
|
||||
> |----------|-------------------|----------------------------|----------------------------|-------------------|
|
||||
> | 无活跃 block | → ContentBlockStart(Text)<br>→ TextDelta | → ContentBlockStart(ToolUse) | (不应发生) | → MessageComplete |
|
||||
> | Text 活跃中 | → TextDelta | → ContentBlockEnd<br>→ ContentBlockStart(ToolUse) | (不应发生,与 content 互斥) | → ContentBlockEnd<br>→ CostUpdate<br>→ MessageComplete |
|
||||
> | ToolUse 活跃中 | → ContentBlockEnd<br>→ ContentBlockStart(Text) | → ContentBlockEnd<br>→ ContentBlockStart(new ToolUse) | → ToolCallArgumentsDelta | → ContentBlockEnd<br>→ CostUpdate<br>→ MessageComplete |
|
||||
>
|
||||
> ### 各议题结论
|
||||
>
|
||||
> | # | 议题 | 结论 | 理由 |
|
||||
> |---|------|------|------|
|
||||
> | 1 | **SSE 字节解析复用** | 提取为通用 `SseByteStream`(`sse.rs`),支持 `event:` + `data:` 双行解析;`OpenaiStreamToEvents` / `AnthropicStreamToEvents` 分别在其上封装 | Anthropic 复用相同字节协议,通过 `SseEvent.event_name` 区分事件类型;分层测试、职责清晰 |
|
||||
> | 2 | **ContentBlockStart/End 合成** | "类型切换推断边界"策略——来什么类型就关旧开新,依赖 content/tool_calls 互斥保证 | 状态机 3 种当前类型覆盖全部场景,假设验证通过 |
|
||||
> | 3 | **Tool call 序号映射** | `HashMap<openai_index, global_block_index>`,新 tool call 出现时分配全局序号 | OpenAI index 是 tool 数组级别全局的,但与 IR content block 体系不同,需映射 |
|
||||
> | 4 | **多 Choice** | 忽略 choices[1..],不暴露 | 当前架构无多 choice 概念,80% 场景 n=1,非目标已明确 |
|
||||
> | 5 | **Usage 时机** | 先发 `CostUpdate` → 再发 `MessageComplete`,同一 chunk 内串行 | 汇聚算法兼容两者顺序,但语义上先用量后完成更合理 |
|
||||
> | 6 | **ToolCallEnd 发出** | OpenAI 层不显式发出 `ToolCallEnd`,依赖汇聚算法 `finalize()` 兜底 | `ToolCallEnd` 保留给 Anthropic(`content_block_stop` 场景);`ContentBlockEnd` 已标 block 完成,`finalize` 时 `tool_call_args` 已累积完整 |
|
||||
> | 7 | **Refusal 处理** | 检测 `delta.refusal`,emit `ContentBlockStart(Refusal)` + `RefusalDelta` + `ContentBlockEnd` | OpenAI 特有字段,IR 已有 `ContentBlockType::Refusal` |
|
||||
> | 8 | **代码消重** | 删除 `stream.rs::parse_chunk_stream`(无人调用);`cycle.rs::submit_stream` 直接消费 `Result<StreamEvent>` 流 | 转换逻辑统一到 Provider 层,LlmCycle 只负责编排和 hook |
|
||||
>
|
||||
> ### 边界情况
|
||||
>
|
||||
> | 场景 | 处理方式 |
|
||||
> |------|---------|
|
||||
> | **`[DONE]` 行** | `SseByteStream` 层过滤,不传递到事件层(当前已有逻辑) |
|
||||
> | **delta 为空 + finish_reason** | 只处理 Phase 2,关闭当前 block 后 emit MessageComplete |
|
||||
> | **同一 chunk 含 delta.content + finish_reason** | Phase 1 先处理 delta 发 TextDelta,Phase 2 关闭 block 发 Complete |
|
||||
> | **同一 chunk 含 delta.tool_calls + finish_reason** | 先处理所有 tool calls(映射 + arguments 累积),再关 block |
|
||||
> | **usage 单独 chunk 下发** | 触发 Phase 2 但 finish_reason 为 None → 只发 CostUpdate,不发 MessageComplete |
|
||||
> | **网络断开** | reqwest 返回 Err → emit StreamEvent::Error,is_complete = true |
|
||||
> | **JSON 解析失败** | emit StreamEvent::Error,终止流(防御性处理) |
|
||||
>
|
||||
> ### 文件变更清单
|
||||
>
|
||||
> | 操作 | 文件 | 说明 |
|
||||
> |------|------|------|
|
||||
> | 新增 | `src/llm/provider/sse.rs` | 通用 SSE 字节解析层,含 `SseEvent` 结构体;从当前 `openai.rs` 的 `SseChunkStream` 提取核心逻辑并增强为支持 `event:` + `data:` 双行解析 + 空行分帧 |
|
||||
> | 新增 | `src/llm/provider/openai/stream.rs` | `OpenaiStreamToEvents` 转换器 + 状态机 |
|
||||
> | 修改 | `src/llm/provider/openai.rs` | `chat_stream` 返回 `Result<StreamEvent>`,组合 `SseByteStream` + `OpenaiStreamToEvents` |
|
||||
> | 修改 | `src/llm/provider.rs` | `LlmProvider::chat_stream` 签名改为 `Result<StreamEvent>` |
|
||||
> | 修改 | `src/llm/cycle.rs` | `submit_stream` 直接消费 `StreamEvent` 流,移除内联 chunk→event 转换 |
|
||||
> | 修改 | `src/llm/stream.rs` | 删除 `parse_chunk_stream`(无人调用) |
|
||||
>
|
||||
> 优先级:高(Phase 2 OpenAI Provider 重构的核心任务)
|
||||
|
||||
### 5.2 AnthropicProvider(Messages API)
|
||||
|
||||
```
|
||||
MessageRequest → Anthropic Messages Request:
|
||||
model → "anthropic-xxx"
|
||||
messages → [只包含 User / Assistant / Tool role]
|
||||
system → system (顶层参数)
|
||||
tools → tools (Anthropic 原生格式)
|
||||
tool_choice → tool_choice
|
||||
max_tokens → max_tokens
|
||||
temperature → temperature
|
||||
top_p → top_p
|
||||
stop_sequences → stop_sequences
|
||||
thinking → thinking (原生支持)
|
||||
extra.* → 忽略或不支持
|
||||
|
||||
Message → Anthropic Message:
|
||||
System { } → 跳过(已在 system 参数中)
|
||||
User { content } → { role: "user", content: to_anthropic_content(content) }
|
||||
Assistant { content } → { role: "assistant", content: to_anthropic_content(content) }
|
||||
Tool { content, tool_call_id } → { role: "user", content: [tool_result block] }
|
||||
|
||||
ContentBlock → Anthropic Content Block:
|
||||
Text { text } → { type: "text", text }
|
||||
Image { source } → { type: "image", source: { type: "base64", ... } }
|
||||
ToolUse { id, name, input } → { type: "tool_use", id, name, input }
|
||||
ToolResult { tool_use_id, content, is_error } → { type: "tool_result", ... }
|
||||
Thinking { text, signature } → { type: "thinking", thinking: text, signature }
|
||||
Audio / File / Extension → 忽略或不支持
|
||||
|
||||
Anthropic Response → MessageResponse:
|
||||
id → id
|
||||
model → model
|
||||
content → Message::Assistant { content: map_blocks(content) }
|
||||
usage.input_tokens → usage.prompt_tokens
|
||||
usage.output_tokens → usage.completion_tokens
|
||||
stop_reason → stop_reason
|
||||
```
|
||||
|
||||
> **✅ 推演结论(2026-06-18):**
|
||||
>
|
||||
> ### 设计思路:轻量分发器
|
||||
>
|
||||
> Anthropic 的流式事件**自带语义块边界**(`content_block_start/stop` 显式声明),
|
||||
> 不像 OpenAI 需从扁平 delta 推断。因此 Anthropic 状态机采用**轻量分发器**模式——
|
||||
> 每个事件自描述,状态机仅做顺序合法性校验,不做 block 边界推断或 index 映射。
|
||||
>
|
||||
> **与 OpenAI 流式转换的核心差异:**
|
||||
>
|
||||
> | 维度 | OpenaiStreamToEvents | AnthropicStreamToEvents |
|
||||
> |------|---------------------|------------------------|
|
||||
> | 核心复杂度 | 中——需从扁平 delta 推断 block 边界 | 低——事件自带语义边界 |
|
||||
> | 状态数 | 3(无活跃、Text、ToolUse) | 3(PendingStart、Active、Terminated) |
|
||||
> | index 管理 | `HashMap<openai_idx, global_idx>` 映射 | 直接使用 Anthropic index(1:1) |
|
||||
> | Block 边界推断 | 类型切换推断 | 原生 content_block_start/stop |
|
||||
> | Thinking 处理 | 无 | 通过 message_delta.thinking.signature |
|
||||
>
|
||||
> ### 架构分层
|
||||
>
|
||||
> 沿用 OpenAI 的两层架构,`SseByteStream` 共享(已增强为支持命名事件),
|
||||
> `AnthropicStreamToEvents` 在事件层按 `event_name` 分发:
|
||||
>
|
||||
> ```
|
||||
> bytes_stream()
|
||||
> │
|
||||
> ▼
|
||||
> SseByteStream [sse.rs — 通用层(已增强)]
|
||||
> │ 逐行分割、按空行分帧、解析 event:/data: 行前缀、过滤 "[DONE]"/ping
|
||||
> ▼
|
||||
> Stream<Item=Result<SseEvent, LlmError>> ← SseEvent { event_name: Option<String>, data }
|
||||
> │
|
||||
> ▼
|
||||
> AnthropicStreamToEvents [anthropic/stream.rs — Anthropic 特定]
|
||||
> │ 按 SseEvent.event_name 分发事件类型 → 直接映射
|
||||
> ▼
|
||||
> Stream<Item=Result<StreamEvent, LlmError>> ← IR 语义事件
|
||||
> ```
|
||||
>
|
||||
> ### 结构体设计
|
||||
>
|
||||
> ```rust
|
||||
> pub struct AnthropicStreamToEvents<S> {
|
||||
> inner: S, // Stream<Item=Result<SseEvent, LlmError>>
|
||||
> state: AnthropicStreamState, // 仅做顺序校验
|
||||
> usage: PartialUsage, // 从 message_start + message_delta 累积
|
||||
> pending_thinking_signature: Option<String>, // message_delta 中到达
|
||||
> }
|
||||
>
|
||||
> /// 状态机状态 —— 仅做合法性校验,事件本身已自描述。
|
||||
> enum AnthropicStreamState {
|
||||
> PendingStart, // 等待 message_start
|
||||
> Active, // 已收到 message_start,正在接收 content block 事件
|
||||
> Terminated, // 已终结,不再处理后续事件
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> 状态足够简单的原因:Anthropic 每个事件自带完整语义——
|
||||
> - `content_block_delta` 自带 `index`,不需要追踪"当前活跃 block"
|
||||
> - `content_block_stop` 自带 `index`,不需要追踪"当前关闭哪个"
|
||||
> - 状态只拒绝非法到达顺序的事件
|
||||
>
|
||||
> ### 事件映射表(完整)
|
||||
>
|
||||
> | Anthropic SSE 事件 | 产出的 StreamEvent | 说明 |
|
||||
> |-------------------|-------------------|------|
|
||||
> | `message_start` | `MessageStart { id, model }`<br>`CostUpdate { prompt_tokens }` | 从 `message.usage.input_tokens` 提取 |
|
||||
> | `ping` | —(忽略) | Anthropic 心跳 |
|
||||
> | `content_block_start`<br>`block.type="text"` | `ContentBlockStart { index, Text }` | |
|
||||
> | `content_block_start`<br>`block.type="tool_use"` | `ContentBlockStart { index, ToolUse { id, name } }` | block 自带 id + name |
|
||||
> | `content_block_start`<br>`block.type="thinking"` | `ContentBlockStart { index, Thinking }` | |
|
||||
> | `content_block_delta`<br>`delta.type="text_delta"` | `TextDelta { text: delta.text }` | |
|
||||
> | `content_block_delta`<br>`delta.type="thinking_delta"` | `ThinkingDelta { text: delta.thinking }` | |
|
||||
> | `content_block_delta`<br>`delta.type="input_json_delta"` | `ToolCallArgumentsDelta { index, arguments: delta.partial_json }` | index 透传 |
|
||||
> | `content_block_stop` | `ContentBlockEnd { index }` | |
|
||||
> | `message_delta` | `CostUpdate { completion_tokens }`<br>`MessageComplete { stop_reason, thinking_signature }` | signature 从 `delta.thinking?.signature` 提取 |
|
||||
> | `message_stop` | —(流结束标记,不产事件) | 仅切状态到 Terminated |
|
||||
> | `error` | `Error { message: error.message }` | 切状态到 Terminated |
|
||||
>
|
||||
> ### 状态转移表
|
||||
>
|
||||
> **当前状态:`PendingStart`**
|
||||
>
|
||||
> | 输入事件 | 输出 StreamEvent | 新状态 | 备注 |
|
||||
> |---------|----------------|--------|------|
|
||||
> | `message_start` | → `MessageStart` + `CostUpdate` | `Active` | ✅ 正常流程入口 |
|
||||
> | 其他任何事件 | — ⚠ warn | 不变 | 防御性跳过 |
|
||||
> | `error` | → `Error` | `Terminated` | ❌ 错误路径 |
|
||||
>
|
||||
> **当前状态:`Active`**
|
||||
>
|
||||
> | 输入事件 | 输出 StreamEvent | 新状态 | 备注 |
|
||||
> |---------|----------------|--------|------|
|
||||
> | `ping` | —(忽略) | `Active` | ✅ 心跳 |
|
||||
> | `content_block_start` | → `ContentBlockStart` | `Active` | ✅ 新 block 开始 |
|
||||
> | `content_block_delta` | → `TextDelta` / `ThinkingDelta` / `ToolCallArgumentsDelta` | `Active` | ✅ 块内增量 |
|
||||
> | `content_block_stop` | → `ContentBlockEnd` | `Active` | ✅ block 结束 |
|
||||
> | `message_delta` | → `CostUpdate` + `MessageComplete` | `Active` | ✅ 消息完成信息 |
|
||||
> | `message_stop` | — | `Terminated` | ✅ 正常结束 |
|
||||
> | `error` | → `Error` | `Terminated` | ❌ 错误路径 |
|
||||
> | 未知 delta type | — ⚠ warn | `Active` | 防御性忽略 |
|
||||
>
|
||||
> **当前状态:`Terminated`**
|
||||
>
|
||||
> | 输入事件 | 输出 StreamEvent | 新状态 | 备注 |
|
||||
> |---------|----------------|--------|------|
|
||||
> | 任何事件 | — ⚠ warn "已完结" | `Terminated` | 防御性忽略 |
|
||||
>
|
||||
> ### 各议题结论
|
||||
>
|
||||
> | # | 议题 | 结论 | 理由 |
|
||||
> |---|------|------|------|
|
||||
> | 1 | **状态机模式** | 轻量分发器(3 状态),不做 block 推断 | Anthropic 事件自带语义边界,不需要像 OpenAI 那样推断 |
|
||||
> | 2 | **index 映射** | 无需映射,直接使用 Anthropic index(1:1) | Anthropic 的 `index` 是全局 content block 序号,与 IR 完全对齐 |
|
||||
> | 3 | **Thinking signature** | ✅ 已推演(方案 C):message_delta 提取 → MessageComplete 传递 → finalize 回填 | 已在 [9f-edge-cases.md](9f-edge-cases.md#92-thinking-的端到端流程) 中完成推演 |
|
||||
> | 4 | **SSE 字节解析复用** | 通过增强的 `SseByteStream`(支持 `event:` 行解析)与 OpenAI 共享通用层 | 同一字节协议,仅在事件解析层差异化 |
|
||||
> | 5 | **Usage 分次到达** | `message_start` 提取 `input_tokens`,`message_delta` 提取 `output_tokens`,`PartialUsage` 字段级合并 | 与 StreamEvent 汇聚算法兼容 |
|
||||
> | 6 | **错误恢复** | `error` 事件 → `StreamEvent::Error` + state=Terminated,后续事件全部忽略 | 不同于 OpenAI 的 HTTP 错误路径,但 IR 层统一为 `StreamEvent::Error` |
|
||||
> | 7 | **ContentBlockType::ToolUse 嵌入** | `content_block_start` 中的 `id` + `name` 直接填入 `ContentBlockType::ToolUse { id, name }` | 与已推演的 ContentBlockStart 设计一致 |
|
||||
> | 8 | **block 切换** | content_block_stop(index) → content_block_start(index') 自然过渡,状态机不追踪 | 事件本身已确定边界,无需状态机参与 |
|
||||
>
|
||||
> ### 与已推演设计的对齐
|
||||
>
|
||||
> **与 Thinking signature 方案的对齐(方案 C):**
|
||||
> ```
|
||||
> content_block_start { type: "thinking" }
|
||||
> → ContentBlockStart(Thinking) ← signature 未到达
|
||||
> content_block_delta { thinking_delta }
|
||||
> → ThinkingDelta(...)
|
||||
> content_block_stop
|
||||
> → ContentBlockEnd ← signature 仍未到达
|
||||
> message_delta { delta.thinking.signature = "0x..." }
|
||||
> → MessageComplete { thinking_signature: Some("0x...") }
|
||||
> → finalize() 回填到最后一个 Thinking block
|
||||
> ```
|
||||
>
|
||||
> **与 StreamEvent 汇聚算法的对齐:**
|
||||
> 本状态机产出的 StreamEvent 可直接喂入已推演的 `PartialMessageResponse::apply_to()` 算法。
|
||||
> 上述事件序列在汇聚算法中:
|
||||
> 1. `MessageStart` → state.id/model
|
||||
> 2. `ContentBlockStart/Delta/End` → `BTreeMap` 按 index 分桶组装
|
||||
> 3. `CostUpdate` → PartialUsage 字段级合并
|
||||
> 4. `MessageComplete` → stop_reason + thinking_signature + is_complete
|
||||
> 5. `finalize()` → 回填 signature → `MessageResponse`
|
||||
>
|
||||
> ### 边界情况
|
||||
>
|
||||
> | 场景 | 处理方式 |
|
||||
> |------|---------|
|
||||
> | **message_start 前收 content_block_start** | ⚠ warn 忽略,不发射事件 |
|
||||
> | **message_delta 前收 message_stop** | ⚠ warn,强制 Terminated |
|
||||
> | **content_block_stop 无对应 start** | ⚠ warn 忽略(index 无对应) |
|
||||
> | **index 跳跃(0 → 2)** | 正常处理,index 透传,content 数组留空位 |
|
||||
> | **delta index 与最新 start 不匹配** | ⚠ warn,仍然按 delta 自带 index 处理 |
|
||||
> | **message_delta 缺 thinking.signature** | `thinking_signature`: None |
|
||||
> | **message_delta 缺 usage** | 不发射 CostUpdate,仅发射 MessageComplete |
|
||||
> | **两次 message_delta** | ⚠ warn,第二次忽略 |
|
||||
> | **content_block_stop 后同 index 又来 delta** | ⚠ warn 忽略 |
|
||||
> | **ping 事件** | 忽略,不发射任何事件 |
|
||||
> | **网络断开** | emit `Error`,state = Terminated |
|
||||
> | **JSON 解析失败** | emit `Error`,state = Terminated |
|
||||
> | **stop_reason 映射** | `"end_turn"`→`Stop`, `"max_tokens"`→`MaxTokens`, `"tool_use"`→`ToolUse`, `"stop_sequence"`→`StopSequence`, 其他→`Other` |
|
||||
>
|
||||
> ### 文件变更清单
|
||||
>
|
||||
> | 操作 | 文件 | 说明 |
|
||||
> |------|------|------|
|
||||
> | 新增 | `src/llm/provider/anthropic.rs` | AnthropicProvider 实现(chat + chat_stream) |
|
||||
> | 新增 | `src/llm/provider/anthropic/` | 目录,按 2018 版风格组织 |
|
||||
> | 新增 | `src/llm/provider/anthropic/stream.rs` | `AnthropicStreamToEvents` 转换器 + 事件分发器 |
|
||||
> | 增强 | `src/llm/provider/sse.rs` | `SseByteStream` 增强为支持 `event:` 行 + 空行分帧(已在 §5.1 中描述) |
|
||||
> | 修改 | `src/llm/provider.rs` | `ProviderType` 增加 `Anthropic`;`create_provider` 增加分支 |
|
||||
> | 无变更 | `src/llm/cycle.rs` | StreamEvent 事件序列格式不变,无需改动 |
|
||||
> | 无变更 | `src/llm/stream.rs` | 汇聚算法 `apply_to/finalize` 不变 |
|
||||
>
|
||||
> 优先级:高(Phase 4 AnthropicProvider 实现的前提条件)
|
||||
|
||||
### 5.3 OpenAI Response API(草案)
|
||||
|
||||
```rust
|
||||
// 核心思路:Response API 的 "input as messages" 模式映射到 IR
|
||||
|
||||
// MessageRequest → Response API Request:
|
||||
// model → model
|
||||
// messages → input (作为 multi-turn conversation)
|
||||
// tools → tools (tool 定义)
|
||||
// extra.previous_response_id → previous_response_id
|
||||
// extra.built_in_tools → tools (内置工具配置)
|
||||
// extra.instructions → instructions
|
||||
|
||||
// Response → MessageResponse:
|
||||
// output[0] (type="message") → message
|
||||
// output[1..n] → ContentBlock::Extension
|
||||
|
||||
// 内置工具(web_search 等)需要额外的能力 trait:
|
||||
#[async_trait]
|
||||
pub trait BuiltInToolsCapable: LlmProvider {
|
||||
fn available_builtin_tools(&self) -> Vec<(&'static str, serde_json::Value)>;
|
||||
async fn execute_builtin_tool(&self, name: &str, input: Value) -> Result<Value, LlmError>;
|
||||
}
|
||||
```
|
||||
|
||||
> **🔄 待深入推演:OpenAI Response API 完整映射表**
|
||||
> 当前 §5.3 只有注释级别的草案,缺乏完整映射。
|
||||
> **需要推演:**
|
||||
> 1. `input` 字段支持三种模式:字符串、`Vec<Message>`(IR 消息列表)、`response_id`(前序响应)。
|
||||
> IR 的 `MessageRequest.messages` 能否同时覆盖这三种?`previous_response_id` 通过 extra 传递后,
|
||||
> `messages` 是否还需要存在?
|
||||
> 2. `output` 中的每种类型(`message`, `web_search_call`, `file_search_call`,
|
||||
> `code_interpreter_call`, `computer_call`, `reasoning`)如何映射到 `ContentBlock`?
|
||||
> 当前 `Extension` 逃生舱能否承载?是否需要新增 ContentBlock variant?
|
||||
> 3. Response API 的 `tools` 参数除了定义 function 工具外,还支持配置内置工具的参数
|
||||
> (如 `web_search` 的 `search_context_size`)。`ToolDefinition` 能否表达?
|
||||
> 4. Streaming 差异:Response API 的流式事件类型(`response.output_items.added` 等)与 Chat API
|
||||
> 完全不同,如何映射到 StreamEvent?
|
||||
> 优先级:低(Phase 4 之后的远期计划)
|
||||
|
||||
### 5.4 DeepSeek / Qwen 等兼容 Provider 的落地策略
|
||||
|
||||
当前 `ProviderType` 枚举中已列出 DeepSeek 和 Qwen,但实现均标记为 `unimplemented!()`。
|
||||
|
||||
> **🔄 待深入推演:DeepSeek/Qwen Provider 的落地策略**
|
||||
> 这些 Provider 通常兼容 OpenAI Chat API 格式。在新 IR 设计下,有两种落地路径:
|
||||
> **路径 A — 复用 OpenaiProvider(推荐):**
|
||||
> 在 `ProviderRegistry` 中注册时直接使用 `OpenaiProvider::new(base_url, api_key, model)`,
|
||||
> 仅需换 base_url。适合 DeepSeek、Qwen、Groq、Azure 等 API 格式与 OpenAI 完全一致的场景。
|
||||
> 此时 `ProviderType` 枚举可能不再需要(`OpenaiProvider` 通过 `capabilities().provider_name`
|
||||
> 标识自身为 "openai-compatible" 或具体名称)。
|
||||
> **路径 B — 独立 Provider 实现:**
|
||||
> 如果某 Provider 在 OpenAI 格式基础上做了扩展/修改(如自定义参数、不同的错误格式),
|
||||
> 可独立实现 `LlmProvider` trait,复用 IR 类型,仅在 IR ↔ 原生格式映射层做差异处理。
|
||||
> **需要推演:**
|
||||
> 1. `ProviderType` 枚举在新的注册体系中是否还有存在的必要(工厂函数模式 vs 直接 new Provider)
|
||||
> 2. `OpenaiProvider` 是否要重命名为更通用的 `OpenaiCompatibleProvider`?
|
||||
> 优先级:低(Phase 4 后梳理)
|
||||
@@ -0,0 +1,435 @@
|
||||
# LlmCycle 改造与上层适配
|
||||
|
||||
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §6 LlmCycle 改造 + §7 对上层的影响 + §8 兼容性策略。
|
||||
>
|
||||
> **相关文件:**
|
||||
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型定义(Message、MessageRequest、ContentBlock 等)
|
||||
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — LlmProvider trait(chat、chat_stream 签名)
|
||||
> - [9d-provider-implementations.md](9d-provider-implementations.md) — Provider 实现策略(system prompt 处理方式)
|
||||
> - [9f-edge-cases.md](9f-edge-cases.md) — 边界情况(tool 定义传递路径等)
|
||||
|
||||
## 6. LlmCycle 改造
|
||||
|
||||
### 6.1 内部存储变化
|
||||
|
||||
```rust
|
||||
pub struct LlmCycle {
|
||||
provider: Arc<dyn LlmProvider>,
|
||||
config: CycleConfig,
|
||||
usage: CostTracker,
|
||||
messages: Vec<Message>, // ← 原 Vec<OpenaiChatMessage>
|
||||
system_prompt: Option<String>,
|
||||
hook_executor: Option<Arc<HookExecutor>>,
|
||||
compact_config: Option<CompactConfig>,
|
||||
compact_state: CompactState,
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 build_request → 新签名
|
||||
|
||||
```rust
|
||||
fn build_request(&self, tools: &[ToolDefinition]) -> MessageRequest {
|
||||
let mut messages = self.messages.clone();
|
||||
|
||||
if let Some(sys_prompt) = &self.system_prompt {
|
||||
// `system_prompt` 是权威来源:显式设置时替换 messages 中的任何 System
|
||||
messages.retain(|m| !matches!(m, Message::System { .. }));
|
||||
messages.insert(0, Message::system(sys_prompt));
|
||||
}
|
||||
// 如果 `system_prompt` 为 None,messages 中的 System 保持原样,
|
||||
// 由各 Provider 在 IR→原生映射层各自处理
|
||||
|
||||
MessageRequest {
|
||||
model: self.config.model.clone(),
|
||||
messages,
|
||||
tools: tools.to_vec(),
|
||||
tool_choice: ToolChoice::Auto,
|
||||
max_tokens: self.config.max_tokens,
|
||||
temperature: self.config.temperature,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **✅ 推演结论(2026-06-22):方案 D —— 移除 `system` 字段,IR 中只留一个入口**
|
||||
>
|
||||
> **决策:** 从 `MessageRequest` 中移除 `system: Option<String>` 字段,统一通过 `messages: Vec<Message>`
|
||||
> 中的 `Message::System { content }` 表达系统提示。各 Provider 在 IR→原生 映射层自行处理差异。
|
||||
>
|
||||
> **理由:**
|
||||
> 1. **与整套 IR 设计的理念一致**——IR 只描述"有什么",不关心"怎么传"。ToolResult 嵌套约束、
|
||||
> Thinking 端到端、StreamEvent 汇总等问题的解决方向都是"Provider 层负责格式差异",
|
||||
> system prompt 的双重入口是同一个问题,应用同样的原则。
|
||||
> 2. **消除歧义的最佳方式是砍掉一个入口**——两个入口导致的"谁优先"问题在类型层面就解决了,
|
||||
> 不需要运行时规则。
|
||||
> 3. **每个 Provider 做自己的转换本来就是 Provider 层的职责**——OpenaiProvider 几乎零成本
|
||||
> (System 直接序列化为 role=`system`),AnthropicProvider 做提取+移除(约 10 行代码),
|
||||
> DeepSeek/Qwen 同 OpenAI。没有 Provider 需要额外做反向工作。
|
||||
>
|
||||
> **具体做法:**
|
||||
>
|
||||
> **① `MessageRequest`([9b-ir-type-system.md](9b-ir-type-system.md#36-messagerequest--统一请求))**
|
||||
> 删除 `system: Option<String>` 字段。`messages: Vec<Message>` 是系统提示的唯一载体。
|
||||
>
|
||||
> **② `LlmCycle::build_request`(本节上方代码)**
|
||||
> 增加"替换"语义:`self.system_prompt` 设置时,先 `retain` 移除 messages 中已有的所有
|
||||
> `Message::System`,再插入新的。确保 `system_prompt` 作为权威来源。
|
||||
>
|
||||
> **③ `OpenaiProvider::ir_to_native`**
|
||||
> 零改动。`Message::System { content }` 直接映射为 `role: "system"`(或 `role: "developer"`)。
|
||||
>
|
||||
> **④ `AnthropicProvider::ir_to_native`**([9d-provider-implementations.md](9d-provider-implementations.md))
|
||||
> 新增提取逻辑:
|
||||
> ```rust
|
||||
> // 1. 遍历 messages,收集所有 System 的纯文本内容
|
||||
> // 2. 若有多个 System,合并为一个字符串(Anthropic 只接受一个)
|
||||
> // 3. 设置 Anthropic 请求的顶层 `system` 参数
|
||||
> // 4. 从 messages 中移除所有 System 消息
|
||||
> // 5. 非文本 ContentBlock 静默丢弃 + warn! log
|
||||
> ```
|
||||
>
|
||||
> **边界情况处理:**
|
||||
> | `self.system_prompt` | messages 中已有的 System | 结果 |
|
||||
> |---|---|---|
|
||||
> | `None` | 无 System | messages 不变 |
|
||||
> | `None` | `System("B")` | 保留,Provider 层处理 |
|
||||
> | `Some("A")` | 无 System | 插入 System("A") |
|
||||
> | `Some("A")` | `System("B")` | **移除 B,插入 A**(显式设置优先) |
|
||||
> | `Some("A")` | 多个 System("B1"), ("B2") | **移除所有,插入 A** |
|
||||
>
|
||||
> **何时实现:** Phase 2-3 实现 AnthropicProvider 时同步完成。
|
||||
> **影响范围:** `MessageRequest` 删除一个字段 + `build_request` 增 2 行 `retain` + AnthropicProvider 增约 10 行提取逻辑。
|
||||
|
||||
主要变化:
|
||||
- 返回类型 `MessageRequest`(非 `ChatRequest`)
|
||||
- `tools` 直接传入,不再需要 `OpenaiTool::Function` 包装
|
||||
- `..Default::default()` 填充剩余字段
|
||||
|
||||
### 6.3 submit_with_tools —— 新的 tool 循环逻辑
|
||||
|
||||
```rust
|
||||
pub async fn submit_with_tools(
|
||||
&mut self,
|
||||
prompt: String,
|
||||
registry: &ToolRegistry,
|
||||
) -> Result<MessageResponse, LlmError> {
|
||||
let tools = registry.definitions();
|
||||
let max_turns = self.config.max_tool_turns.unwrap_or(10);
|
||||
|
||||
self.messages.push(Message::user(prompt));
|
||||
self.maybe_compact();
|
||||
|
||||
let mut turn = 0;
|
||||
loop {
|
||||
turn += 1;
|
||||
if turn > max_turns { /* error */ }
|
||||
|
||||
let response = self.submit_request(&tools).await?;
|
||||
|
||||
// 从 content blocks 中检测 ToolUse(不再需要额外函数)
|
||||
let tool_uses: Vec<&ContentBlock> = response.message.content.iter()
|
||||
.filter_map(|b| if let ContentBlock::ToolUse { .. } = b { Some(b) } else { None })
|
||||
.collect();
|
||||
let should_execute = matches!(response.stop_reason, StopReason::ToolUse) && !tool_uses.is_empty();
|
||||
|
||||
self.messages.push(response.message.clone());
|
||||
if !should_execute { return Ok(response); }
|
||||
|
||||
let calls: Vec<(String, Value)> = tool_uses.iter()
|
||||
.map(|b| match b {
|
||||
ContentBlock::ToolUse { name, input, .. } => (name.clone(), input.clone()),
|
||||
_ => unreachable!(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results = registry.invoke_all(calls, self.config.tool_timeout_secs).await;
|
||||
for result in results {
|
||||
let content = /* 序列化/截断逻辑 ... */;
|
||||
self.messages.push(Message::tool_result(result.tool_name, content));
|
||||
}
|
||||
self.maybe_compact();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关键简化:
|
||||
- 不再需要 `has_tool_calls_in_message()` 和 `extract_tool_calls_from_message()` 辅助函数
|
||||
- 仅仅遍历 `content` 即可发现所有 `ToolUse` block
|
||||
- 逻辑对 Provider 类型**完全透明**
|
||||
|
||||
### 6.4 submit_stream —— Provider 直接返回 StreamEvent
|
||||
|
||||
```rust
|
||||
pub async fn submit_stream(
|
||||
&mut self,
|
||||
prompt: String,
|
||||
tools: Vec<ToolDefinition>,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError> {
|
||||
self.messages.push(Message::user(prompt));
|
||||
let request = self.build_request(&tools);
|
||||
|
||||
// Provider 直接返回 StreamEvent 流,无需二次转换
|
||||
let stream = self.provider.chat_stream(request).await?;
|
||||
|
||||
// 如果需要,可在 LlmCycle 层叠加额外处理
|
||||
// (当前 pipeline:stream → hook 触发 → 直接返回)
|
||||
Ok(stream)
|
||||
}
|
||||
```
|
||||
|
||||
不再需要 `parse_chunk_stream()`(`stream.rs` 中的 `ChunkToEventStream` 可以移除)。
|
||||
|
||||
### 6.5 HookContext 引用调整
|
||||
|
||||
```rust
|
||||
pub struct HookContext<'a> {
|
||||
pub request: Option<&'a MessageRequest>, // ← 原 &'a ChatRequest
|
||||
pub error: Option<&'a LlmError>,
|
||||
pub attempt: u32,
|
||||
pub turn_index: Option<u32>,
|
||||
pub plan_step_index: Option<usize>,
|
||||
}
|
||||
```
|
||||
|
||||
### 6.6 compact 逻辑调整
|
||||
|
||||
`compact.rs` 中的 `estimate_message_tokens()`、`microcompact()` 需要从 `Vec<OpenaiChatMessage>` 改为 `Vec<Message>`,核心逻辑不变。
|
||||
|
||||
> **✅ 推演结论(2026-06-22):`microcompact` 压缩对象 + 估算策略 + Thinking 压缩策略**
|
||||
>
|
||||
> 推演覆盖三个议题(压缩对象、token 估算、Thinking 压缩),并引入**大小 × 重要性二维决策框架**作为统一的压缩决策模型。
|
||||
>
|
||||
> ---
|
||||
>
|
||||
> ### 议题 1:`microcompact` 压缩哪个?
|
||||
>
|
||||
> **决策:方案 B —— 同时压缩 `Message::Tool` 和 `Message::Assistant` 中的 `ContentBlock::ToolResult`。**
|
||||
>
|
||||
> **理由:**
|
||||
> 1. 两者存储的是相同语义的数据(工具执行结果),组织结构不同但语义等价。只压缩一种会漏掉另一种。
|
||||
> 2. LlmCycle 工具循环主路径产生 `Message::Tool`,但 Anthropic 格式反序列化后可能出现 `ToolResult` 嵌入在 Assistant 中。未来 `AnthropicProvider` 实现后后者场景会增加。
|
||||
> 3. `ContentBlock::ToolUse` **不压缩**——id/name/input 是工具调用的必需元数据,体积小(通常 < 1K),压缩反而破坏后续工具重放。
|
||||
>
|
||||
> 无需优先级排序(两者不在同一条消息中,不存在先后问题):
|
||||
> - `Message::Tool`:独立的 Tool 消息,压缩其整个 `content` 字段
|
||||
> - `Message::Assistant` 中的 `ToolResult`:遍历 content 找到所有 `ToolResult` block,压缩其内部的 `content`
|
||||
>
|
||||
> **实现示意:**
|
||||
>
|
||||
> ```rust
|
||||
> pub fn microcompact(messages: &mut [Message], keep_recent: usize, config: &CompactConfig) -> u32 {
|
||||
> if messages.len() <= keep_recent { return 0; }
|
||||
> let prune_start = messages.len() - keep_recent;
|
||||
> let mut freed_tokens: u32 = 0;
|
||||
>
|
||||
> // Phase 1:估算压缩前 token 数
|
||||
> for msg in &messages[..prune_start] {
|
||||
> freed_tokens += estimate_compressible_tokens(msg, config);
|
||||
> }
|
||||
>
|
||||
> // Phase 2:执行压缩
|
||||
> for msg in &mut messages[..prune_start] {
|
||||
> apply_compact_action(msg, config);
|
||||
> }
|
||||
>
|
||||
> freed_tokens
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> ---
|
||||
>
|
||||
> ### 议题 2:`estimate_message_tokens` 的估算策略
|
||||
>
|
||||
> **决策:方案 B —— 按 ContentBlock 类型分档估算,非 Text block 使用差异化经验值。**
|
||||
>
|
||||
> **理由:**
|
||||
> - compact 是启发式压缩,不需要精确 token 计数(最终由 Provider 的 tokenizer 精确计算),方案 C 的精度收益不值得这个复杂度
|
||||
> - 方案 A(统一固定 50)精度太低——Thinking block 可长达几万 token,固定 50 会导致严重低估
|
||||
>
|
||||
> **分档表:**
|
||||
>
|
||||
> | `ContentBlock` 类型 | 估算方式 | 说明 |
|
||||
> |---|---|------|
|
||||
> | `Text { text }` | `(len * 4).div_ceil(3)` | 保持当前字符估算逻辑 |
|
||||
> | `Image { .. }` | 固定 85 | OpenAI 低分辨率固定定价 |
|
||||
> | `Audio { .. }` | 固定 100 | 音频通常大于图片 |
|
||||
> | `File { source }` | 50 + 文件名文本估算 | 元数据开销 + 文件名 |
|
||||
> | `ToolUse { name, input }` | `estimate_text(name) + estimate_text(input.to_string())` | name + JSON 参数 |
|
||||
> | `ToolResult { content }` | 递归估算内部 content block 列表 | 递归到叶子节点 |
|
||||
> | `Thinking { text }` | `estimate_text(text)` | 按文本估算(内容往往很长) |
|
||||
> | `Extension { data }` | `estimate_text(data.to_string())` | 逃生舱,按 JSON 大小估算 |
|
||||
>
|
||||
> ---
|
||||
>
|
||||
> ### 议题 3:Thinking 是否在压缩范围内
|
||||
>
|
||||
> **决策:方案 A —— 默认不压缩 Thinking,`CompactConfig` 增加 `compact_thinking: bool` 选项。**
|
||||
>
|
||||
> **理由:**
|
||||
> 1. Thinking 不同于 ToolResult——ToolResult 是"已执行的事实结果",压缩后语义无损;Thinking 是"推理过程",压缩后可能丢失决策上下文
|
||||
> 2. 但 thinking 内容确实可能很长(尤其 Anthropic extended thinking 模式),不应完全放弃压缩能力
|
||||
> 3. 提供选项让用户根据场景自行决定:任务型 Agent(可压) vs 推理密集型 Agent(不压)
|
||||
>
|
||||
> ```rust
|
||||
> #[derive(Debug, Clone)]
|
||||
> pub struct CompactConfig {
|
||||
> pub context_window: u32,
|
||||
> pub reserved_tokens: u32,
|
||||
> pub keep_recent: usize,
|
||||
> /// 是否压缩 Thinking 内容。默认 false。
|
||||
> pub compact_thinking: bool,
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> 压缩时将 Thinking block 的 `text` 替换为 `"[pruned thinking]"` 以区别于 ToolResult 的 `"[pruned]"`。
|
||||
>
|
||||
> ---
|
||||
>
|
||||
> ### ⭐ 新推演维度:大小 × 重要性二维决策框架
|
||||
>
|
||||
> 上述三个议题各自独立解决,但**压缩强度的选择**需要融合两个互补指标:
|
||||
>
|
||||
> | 维度 | 解决什么问题 | 来源 | 性质 |
|
||||
> |------|------------|------|------|
|
||||
> | **大小** | "这东西有多重?"——为释放空间值不值得动手 | 纯计算(字符数) | 精确 |
|
||||
> | **重要性** | "动了之后损失什么?"——压缩对后续推理的影响 | 多来源综合 | 语义 |
|
||||
>
|
||||
> #### 二维决策矩阵
|
||||
>
|
||||
> ```
|
||||
> 重 要 性
|
||||
> 低 (Action) 高 (Factual)
|
||||
> ┌────────────────────────────────
|
||||
> 小 │ 跳过 跳过
|
||||
> │ (< 200 char, 重要但小,不值得为它费劲
|
||||
> 大 | 不值得)
|
||||
> | │
|
||||
> 小 │ 截断保留开头 结构化摘要
|
||||
> │ ("[前200字]...") (保留 JSON 骨架 + 截断数据体)
|
||||
> │
|
||||
> 大 │ 替换 [pruned] 截断优先 → 元数据保留
|
||||
> │ (零价值 + 大体积) (实在太大才 [pruned])
|
||||
> ```
|
||||
>
|
||||
> #### 重要性的四个来源
|
||||
>
|
||||
> | 来源 | 时机 | 输入 | 输出 |
|
||||
> |------|------|------|------|
|
||||
> | **① 工具注册声明** | 编译/初始化时 | `ToolDefinition.result_semantic: ResultSemantic` | 基础分:Factual=+1, Action=-1, Mixed=0 |
|
||||
> | **② 内容模式启发式** | compact 触发时 | 检测结果中的 JSON 特征 | 加分:列表数据+1,状态确认-1,错误结果→跳过 |
|
||||
> | **③ 对话轮次衰退** | compact 触发时 | 距离最后一次被引用的轮次 | 每超 K 轮 → -0.5 |
|
||||
> | **④ 后续引用跟踪** | 事后(为下次积累) | Assistant 内容与结果的关键词重叠 | 更新引用时间戳 |
|
||||
>
|
||||
> ```rust
|
||||
> /// 工具注册时声明结果的语义类型
|
||||
> #[derive(Debug, Clone, Copy)]
|
||||
> pub enum ResultSemantic {
|
||||
> /// 结果是"事实依据"——后续对话可能反复引用(搜索、文档查询)
|
||||
> Factual,
|
||||
> /// 结果是"一次性动作确认"——执行完就过了(发送邮件、创建记录)
|
||||
> Action,
|
||||
> /// 兼具两者特征 / 不确定(默认)
|
||||
> Mixed,
|
||||
> }
|
||||
>
|
||||
> /// 压缩动作 —— 由 [大小, 重要性] 综合决定
|
||||
> pub enum CompactAction {
|
||||
> /// 不压缩
|
||||
> Skip,
|
||||
> /// 截断保留前 N 字符
|
||||
> Truncate { keep: usize },
|
||||
> /// 尝试保留 JSON 结构 + 截断数据体
|
||||
> TruncateStructured { keep: usize },
|
||||
> /// 替换为 [pruned] 或 [pruned thinking]
|
||||
> Replace,
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> #### 综合评分 → 动作映射
|
||||
>
|
||||
> ```rust
|
||||
> fn decide_strategy(size: usize, score: i8) -> CompactAction {
|
||||
> match (size, score) {
|
||||
> (0..=200, _) => CompactAction::Skip, // 太小不压
|
||||
> (201..=2000, s) if s >= 1 => CompactAction::Skip, // 重要 + 中等 → 保留
|
||||
> (201..=2000, _) => CompactAction::Truncate { keep: 200 },
|
||||
> (2001.., s) if s >= 2 => CompactAction::TruncateStructured { keep: 500 },
|
||||
> (2001.., s) if s >= 1 => CompactAction::Truncate { keep: 200 },
|
||||
> (2001.., _) => CompactAction::Replace, // 大 + 不重要 → 全换
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **何时实现:** Phase 3 适配 LlmCycle 时同步修改 compact.rs。
|
||||
> **影响范围:** `compact.rs` 全部重写(保留函数签名,内部逻辑适配 IR)+ `CompactConfig` 变更 + 需要在 `ToolDefinition` 中新增 `result_semantic` 字段(Phase 3)+ `llm-cycle.rs` 中 compact 调用点的接口适配。
|
||||
|
||||
---
|
||||
|
||||
## 7. 对上层的影响
|
||||
|
||||
### 7.1 ProviderRegistry —— 零改动
|
||||
|
||||
```rust
|
||||
pub struct ProviderRegistry {
|
||||
providers: HashMap<String, Box<dyn LlmProvider>>,
|
||||
default_name: Option<String>,
|
||||
}
|
||||
// 所有方法逻辑不变
|
||||
```
|
||||
|
||||
### 7.2 AgentSession —— 极小影响
|
||||
|
||||
```rust
|
||||
// 当前
|
||||
let response: ChatResponse = cycle.submit_with_tools(input, ®istry).await?;
|
||||
self.cost_so_far.add(&response.usage); // Usage 类型不变
|
||||
|
||||
// 新
|
||||
let response: MessageResponse = cycle.submit_with_tools(input, ®istry).await?;
|
||||
self.cost_so_far.add(&response.usage); // 仍然可用——Usage 类型一致
|
||||
```
|
||||
|
||||
`response.usage` 类型不变(仍是 `Usage`),`response.text()` 替代了 `response.message.content` 的文本提取。
|
||||
|
||||
### 7.3 Agent trait —— 零改动
|
||||
|
||||
`Agent::tool_definitions()` 返回 `Vec<ToolDefinition>`,类型不变。
|
||||
|
||||
### 7.4 PromptComposer —— 内部类型替换
|
||||
|
||||
`PromptComposer` 内部存储从 `Vec<OpenaiChatMessage>` 改为 `Vec<Message>`,公共方法签名不变(返回 `Message` 类型)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 兼容性策略
|
||||
|
||||
### 8.1 From trait 双向转换
|
||||
|
||||
提供新旧类型之间的转换,平滑迁移:
|
||||
|
||||
```rust
|
||||
// IR → 旧类型(兼容层)
|
||||
impl From<ChatResponse> for MessageResponse { ... }
|
||||
impl From<MessageResponse> for ChatResponse { ... }
|
||||
impl From<OpenaiChatMessage> for Message { ... }
|
||||
impl From<Message> for OpenaiChatMessage { ... }
|
||||
impl From<ChatRequest> for MessageRequest { ... }
|
||||
impl From<MessageRequest> for ChatRequest { ... }
|
||||
```
|
||||
|
||||
### 8.2 LlmCycle 兼容 getter
|
||||
|
||||
```rust
|
||||
impl LlmCycle {
|
||||
// 新
|
||||
pub fn messages(&self) -> &[Message] { &self.messages }
|
||||
// 兼容旧
|
||||
pub fn messages_openai(&self) -> Vec<OpenaiChatMessage> {
|
||||
self.messages.iter().map(|m| m.clone().into()).collect()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 测试代码的过渡
|
||||
|
||||
测试中大量使用 `MockProvider` 和旧类型,需要更新为 IR 类型。可在 Phase 1 中先保留旧类型别名以减少改动。
|
||||
@@ -0,0 +1,121 @@
|
||||
# 边界情况
|
||||
|
||||
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §9 边界情况。
|
||||
>
|
||||
> **相关文件:**
|
||||
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型定义(ContentBlock、ThinkingConfig、MessageRequest/Response 等)
|
||||
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — StreamEvent 类型定义(ThinkingDelta 等)
|
||||
> - [9d-provider-implementations.md](9d-provider-implementations.md) — Anthropic 流式状态机(与 Thinking signature 强相关)
|
||||
> - [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) — LlmCycle 的 tool 循环与 compact 逻辑
|
||||
|
||||
## 9. 边界情况
|
||||
|
||||
### 9.1 工具定义的传递路径变化
|
||||
|
||||
| 阶段 | 路径 |
|
||||
|------|------|
|
||||
| **当前** | `ToolRegistry.definitions()` → `Vec<ToolDefinition>` → `build_request()` 包装为 `Vec<OpenaiTool>` → `ChatRequest.tools` |
|
||||
| **方案 C** | `ToolRegistry.definitions()` → `Vec<ToolDefinition>` → `build_request()` 直接放入 → `MessageRequest.tools` → **Provider 内部**包装为原生格式 |
|
||||
|
||||
工具定义的抽象层次从 LlmCycle 下移到 Provider 内部,更合理。
|
||||
|
||||
### 9.2 Thinking 的端到端流程
|
||||
|
||||
```
|
||||
Agent 启用 thinking:
|
||||
config.thinking = Some(ThinkingConfig { budget_tokens: 16000 })
|
||||
|
||||
LlmCycle.build_request():
|
||||
→ MessageRequest { thinking: config.thinking, ... }
|
||||
|
||||
OpenaiProvider:
|
||||
→ capabilities().features.thinking == false
|
||||
→ 忽略 thinking 字段(或转为 extra.reasoning_tokens)
|
||||
|
||||
AnthropicProvider:
|
||||
→ capabilities().features.thinking == true
|
||||
→ 将 thinking 写入 Anthropic 请求参数
|
||||
→ 流式响应中返回 StreamEvent::ThinkingDelta
|
||||
→ 最终消息的 content 中包含 ContentBlock::Thinking
|
||||
```
|
||||
|
||||
> **✅ 推演结论(2026-06-17):方案 C —— MessageComplete 兜底 + finalize 统一回填**
|
||||
>
|
||||
> **决策:** Thinking signature 不在 event 级传递,而是通过 `MessageComplete.thinking_signature` 携带,
|
||||
> 由 `PartialMessageResponse::finalize()` 统一回填到最后一个 Thinking block。
|
||||
>
|
||||
> **具体路径:**
|
||||
> ```
|
||||
> Anthropic message_delta
|
||||
> → AnthropicProvider 提取 thinking.signature
|
||||
> → 发出 MessageComplete { stop_reason, thinking_signature: Some("0x...") }
|
||||
>
|
||||
> PartialMessageResponse:
|
||||
> ContentBlockEnd(thinking_idx) ← signature 还没到,builder 中 signature = None
|
||||
> ...
|
||||
> MessageComplete { thinking_signature: Some("0x...") }
|
||||
> → state.thinking_signature = Some("0x...")
|
||||
>
|
||||
> finalize():
|
||||
> 遍历 blocks → 找到 Thinking builder
|
||||
> → builder.signature 为 None,用 state.thinking_signature 回填
|
||||
> → ContentBlock::Thinking { text, signature: Some("0x...") }
|
||||
> ```
|
||||
>
|
||||
> **理由:**
|
||||
> 1. 不新增独立事件类型(保持 StreamEvent 简洁)
|
||||
> 2. signature 在 message_delta 中下发(晚于 content_block_stop),MessageComplete 是该时刻的天然载体
|
||||
> 3. Anthropic 同一响应中最多一个 thinking block,不存在"多 thinking block 归属"的歧义
|
||||
> 4. 非流式响应中,thinking block 的 signature 直接通过 IR→原生映射填充,路径不变
|
||||
>
|
||||
> **影响范围:**
|
||||
> - `StreamEvent::MessageComplete` 增加 `thinking_signature: Option<String>` 字段
|
||||
> - `PartialMessageResponse` 增加 `thinking_signature: Option<String>` 暂存字段
|
||||
> - `PartialMessageResponse::finalize()` 增加回填逻辑(约 3 行代码)
|
||||
> - AnthropicProvider 流式状态机在 `message_delta` 处理中提取 thinking.signature
|
||||
>
|
||||
> **何时实现:** Phase 4 AnthropicProvider 流式状态机实现时一并完成
|
||||
|
||||
### 9.3 Multiple ContentBlock 的处理
|
||||
|
||||
消息的 `content` 为 `Vec<ContentBlock>`,可包含多种类型的混合:
|
||||
|
||||
```rust
|
||||
Message::Assistant {
|
||||
content: vec![
|
||||
ContentBlock::Text { text: "让我思考一下..." },
|
||||
ContentBlock::Thinking { text: "先用加法工具...", signature: None },
|
||||
ContentBlock::Text { text: "答案是 3" },
|
||||
ContentBlock::ToolUse { id: "call_1", name: "add", input: json!({"a":1,"b":2}) },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
LlmCycle 处理 tool 循环时只关心 `ToolUse` block,其他 block 按原样传递给消息历史。
|
||||
|
||||
### 9.4 多 Choice 场景
|
||||
|
||||
OpenAI 的 `n > 1` 参数在 IR 中通过 `extra` 传递:
|
||||
|
||||
```rust
|
||||
// 请求
|
||||
request.set_extra("n", json!(3));
|
||||
// 响应
|
||||
response.extra["all_choices"] = json!([...choices 2..n]);
|
||||
```
|
||||
|
||||
`MessageResponse` 只承载 `choices[0]`(主消息),其他 choices 放 `extra`。
|
||||
|
||||
### 9.5 OpenAI Response API 的内置工具
|
||||
|
||||
通过 `extra` + 可选能力 trait 支持:
|
||||
|
||||
```rust
|
||||
// 配置内置工具
|
||||
request.set_extra("built_in_tools", json!(["web_search", "file_search"]));
|
||||
|
||||
// Response API 返回的搜索结果
|
||||
// → ContentBlock::Extension { kind: "web_search_result", data: {...} }
|
||||
```
|
||||
|
||||
如果未来内置工具成为跨 Provider 通用特性,将 `BuiltInToolsCapable` 升级为核心 trait。
|
||||
@@ -0,0 +1,96 @@
|
||||
# 风险评估、迁移路径与验收标准
|
||||
|
||||
> 本文档从 `9-llm-provider-unified-interface.md` 拆分而来,包含 §10 风险评估 + §11 类型差异总结 + §12 迁移路径 + §13 验收标准。
|
||||
>
|
||||
> **相关文件:**
|
||||
> - [9a-background-and-architecture.md](9a-background-and-architecture.md) — 背景与架构总览
|
||||
> - [9b-ir-type-system.md](9b-ir-type-system.md) — IR 类型体系
|
||||
> - [9c-llm-provider-trait.md](9c-llm-provider-trait.md) — LlmProvider Trait 设计
|
||||
> - [9d-provider-implementations.md](9d-provider-implementations.md) — Provider 实现策略
|
||||
> - [9e-llm-cycle-and-upstream.md](9e-llm-cycle-and-upstream.md) — LlmCycle 改造与上层适配
|
||||
> - [9f-edge-cases.md](9f-edge-cases.md) — 边界情况
|
||||
|
||||
## 10. 风险评估
|
||||
|
||||
| 风险 | 等级 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| ContentField::String 与 Vec<ContentBlock> 的统一导致文本消息需要包装 | 低 | Message::user("text") 便捷函数自动包装 |
|
||||
| 无法完整覆盖 OpenAI 所有参数 | 中 | extra 逃生舱兜底 |
|
||||
| IR ↔ OpenAI 的转换有性能开销 | 低 | 纯字段映射,相对 HTTP 延迟可忽略 |
|
||||
| HookContext 引用类型变更影响 Hook 实现 | 中 | 影响范围小(主要为测试代码) |
|
||||
| LlmCycle 返回类型变化破坏上层 | 中 | 提供兼容层 + From 转换 |
|
||||
| compact.rs 需要适配新 Message 类型 | 低 | 核心逻辑不变,仅改类型匹配 |
|
||||
| 流式事件格式变化破坏现有 StreamEvent 使用者 | 中 | 影响 LlmCycle::submit_stream 的调用者(主要是测试层) |
|
||||
| ProviderType 枚举需要扩展 | 低 | 新增变体即可 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 当前类型与 IR 的差异总结
|
||||
|
||||
| 当前类型 | 方案 C IR | 核心变化 |
|
||||
|---------|----------|---------|
|
||||
| `ChatRequest` (= `OpenaiChatRequest`) | `MessageRequest` | 独立类型,不绑定 OpenAI |
|
||||
| `ChatResponse` | `MessageResponse` | 独立类型,content 用 ContentBlock |
|
||||
| `OpenaiChatMessage` | `Message` | tool_calls 融入 content |
|
||||
| `ContentField` | `Vec<ContentBlock>` | 统一为数组,不再有 String variant |
|
||||
| `OpenaiContentPart` | `ContentBlock` | 新增 ToolUse/ToolResult/Thinking/Extension |
|
||||
| `FinishReason` | `StopReason` | 语义化(如 tool_calls → ToolUse) |
|
||||
| `OpenaiChatChunk` | — | 移除(StreamEvent 取代)|
|
||||
| `OpenaiChatResponse` | — | 不再暴露(Provider 内部使用)|
|
||||
| `OpenaiToolCall` | `ContentBlock::ToolUse` | 融入 content block |
|
||||
| `OpenaiToolDefinition` | `ToolDefinition` | 不变 |
|
||||
| `Usage` | `Usage` | 不变 |
|
||||
| `StreamEvent` | `StreamEvent` | 扩展(ThinkingDelta、MessageStart、ToolCallStart 等) |
|
||||
|
||||
---
|
||||
|
||||
## 12. 迁移路径
|
||||
|
||||
### Phase 1:定义 IR 类型 + From 转换
|
||||
|
||||
- 新增 `src/llm/types/ir.rs`,包含完整的 IR 类型定义
|
||||
- 实现 IR ↔ 现有类型的 `From`/`Into` trait
|
||||
- 新增 `pub type` 别名保持现有代码可编译
|
||||
- ✅ 零已有代码改动
|
||||
|
||||
### Phase 2:重写 LlmProvider trait
|
||||
|
||||
- 修改 `src/llm/provider.rs`:trait 签名改为 IR 类型
|
||||
- 重构 `OpenaiProvider`:内部 IR → OpenAI → IR 转换
|
||||
- 新增 `chat_stream` 的 `StreamEvent` 实现
|
||||
- 新增 `capabilities()` 方法
|
||||
- ❌ OpenaiProvider 需重构;LlmCycle 暂时不兼容
|
||||
|
||||
### Phase 3:适配 LlmCycle
|
||||
|
||||
- LlmCycle 内部消息历史改为 `Vec<Message>`
|
||||
- `build_request` 改为生成 `MessageRequest`
|
||||
- tool 循环逻辑改为遍历 `ContentBlock`
|
||||
- `HookContext` 引用改为 `MessageRequest`
|
||||
- `compact.rs` 适配新消息类型
|
||||
- ❌ LlmCycle API 变更;AgentSession 需适配
|
||||
|
||||
### Phase 4:实现 AnthropicProvider + 清理
|
||||
|
||||
- 新增 `src/llm/provider/anthropic.rs`
|
||||
- 实现 IR ↔ Anthropic JSON 映射 + 流式转换
|
||||
- 移除旧的 `parse_chunk_stream`、`ChunkToEventStream`
|
||||
- 清理不再需要的旧类型公开使用
|
||||
- 补测试
|
||||
|
||||
---
|
||||
|
||||
## 13. 验收标准
|
||||
|
||||
| 编号 | 标准 | 验证方式 |
|
||||
|------|------|----------|
|
||||
| A1 | `OpenaiProvider` 通过 IR trait 正常工作 | 现有测试通过 + ChatCompletion 集成测试 |
|
||||
| A2 | `AnthropicProvider` 通过 IR trait 正常工作 | Anthropic Messages API 集成测试 |
|
||||
| A3 | Tool 循环在 IR 上正确工作(在 content 中检测 ToolUse) | `submit_with_tools` 测试通过 |
|
||||
| A4 | 流式事件包含 ThinkingDelta 等新类型 | Provider 流式测试 |
|
||||
| A5 | ProviderCapabilities 正确描述 Provider 特性 | 单元测试 |
|
||||
| A6 | `extra` 逃生舱可传递 Provider 特有参数 | 测试各 Provider 的 extra 参数 |
|
||||
| A7 | 兼容层保持旧 API 可用 | 旧代码编译通过 |
|
||||
| A8 | `compact.rs` 在 IR 上正常工作 | 压缩测试通过 |
|
||||
| A9 | HookContext 使用 MessageRequest | Hook 测试通过 |
|
||||
| A10 | AgentSession 编译通过 | 编译检查 |
|
||||
@@ -0,0 +1,200 @@
|
||||
# AG Core Roadmap — Unsorted
|
||||
|
||||
> 本文件存放**尚未归到任何具体版本**的 roadmap 内容:跨版本的全局视图、面向未来的展望、风险与建议、阶段总回顾。
|
||||
>
|
||||
> **已分版本的内容**:请查阅
|
||||
> - [`roadmap-v0.1.0.md`](./roadmap-v0.1.0.md) — Phase 0–4c + v0.1.0 Release
|
||||
> - [`roadmap-v0.2.0.md`](./roadmap-v0.2.0.md) — Phase 5–12 + v0.2.0-rc.1
|
||||
> - [`roadmap-v0.3.0.md`](./roadmap-v0.3.0.md) — Phase 13–19(全部完成)
|
||||
> - [`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` | P0(LLM Cycle 扩展) |
|
||||
| Provider 注册发现 | ✅ 完整 | `docs/3-phase0-remaining.md` | P0(Provider 接口扩展) |
|
||||
| 流式事件系统 | ✅ 完整 | `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 够用 |
|
||||
| 路线 B(StateGraph 通用图引擎) | 预留为路线 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` 等新公开 API,v0.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 warning,11 个离线示例全部 exit 0
|
||||
- ✅ **Phase 11 测试与检索补强** — `src/memory/vector.rs` 新增 `VectorRetriever` trait(index + 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-export;9 个 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 个增量 Phase(Phase 5-12),17 个可验证 Step,覆盖 P0-P2 全部 12 项功能 + ContextSlot
|
||||
- ✅ **v0.3.0 Phase 13 完成** — 技术债清理(3 旧 types 文件 + ChatResponse 删除)+ ContextSlot fork/merge(9 新测试),M9 里程碑达成
|
||||
- ✅ **v0.3.0 Phase 14 完成** — Document 类型(id/content/metadata/mime_type)+ `RecursiveCharacterSplitter` 两阶段算法(按 separator 优先级递归分割 + 贪心合并 overlap,全部 `chars_len()` 字符级比较)+ `Embedding` trait(async + `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 warning;M11 里程碑达成
|
||||
- ✅ **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 0;17 个内联测试(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 个增量 Phase(A-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 FS(CoW)** | 运行中 session 保护、防止 submit_turn 失败污染 | 不能替代 checkpoint 链、不支持多时间点回退 |
|
||||
|
||||
**触发条件**:当单 session checkpoint 超过 500KB 且频繁保存导致性能瓶颈时,考虑实现分层模型。
|
||||
@@ -0,0 +1,242 @@
|
||||
# AG Core Roadmap — v0.1.0
|
||||
|
||||
> 本文件聚焦 **v0.1.0 版本** 的规划与交付(Phase 0–4c),已于 2026-07-04 完成发布。
|
||||
> 返回总入口:[`roadmap.md`](./roadmap.md)
|
||||
|
||||
## v0.1.0 愿景
|
||||
|
||||
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
|
||||
|
||||
## v0.1.0 总体范围
|
||||
|
||||
**总体规模**:5 个主体 Phase(Phase 0–4c)+ Provider IR 重构 + LlmCycle 简化 + v0.1 Release 收尾,182 个测试全绿,clippy 0 警告,7 个离线示例全 exit 0。
|
||||
|
||||
---
|
||||
|
||||
### Phase 0 — Foundation(基础设施)
|
||||
|
||||
**目标**:实现 LLM 调用周期的核心功能,作为所有上层模块的基础。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `llm/types.rs` — 核心数据类型(Message, ContentBlock, ChatRequest/Response, ToolDefinition, StopReason)
|
||||
2. ✅ `llm/error.rs` — 错误体系(LlmError 枚举,可重试/不可重试判断)
|
||||
3. ✅ `llm/provider.rs` + `llm/provider/openai.rs` — Provider 接口 + OpenAI 兼容实现
|
||||
4. ✅ `llm/provider/registry.rs` — ProviderRegistry(多 Provider 注册发现)
|
||||
5. ✅ `llm/cycle.rs` + `llm/cycle/{retry,usage}.rs` — 生命周期引擎(重试策略 + 用量追踪)
|
||||
6. ✅ `llm/hooks.rs` — HookExecutor 接口(生命周期钩子)
|
||||
7. ✅ `llm/stream.rs` — StreamEvents 流式事件系统(AssistantTextDelta, ToolExecutionStarted 等)
|
||||
8. ✅ `llm/compact.rs` — Auto-compaction(上下文自动压缩)
|
||||
9. ✅ `Cargo.toml` — 添加依赖(tokio, reqwest, serde, thiserror, async-trait, tracing)
|
||||
|
||||
**依赖**:无
|
||||
|
||||
**优先级**:Must Have
|
||||
|
||||
**预估规模**:约 1000 行核心代码
|
||||
|
||||
**状态**:✅ Phase 0 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 — Prompt Engineering(提示词工程)
|
||||
|
||||
**目标**:提供提示词的组合、模板化与优化能力。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `prompt.rs` + `prompt/` 模块
|
||||
2. ✅ `PromptTemplate` — 模板引擎(支持变量插值、条件渲染)
|
||||
3. ✅ `PromptComposer` — 提示词组合器(拼接 system/user/assistant 消息)
|
||||
4. ✅ `docs/4-prompt-engineering.md` — 方案文档
|
||||
|
||||
**依赖**:无(可与 Phase 0 并行)
|
||||
|
||||
**优先级**:Should Have
|
||||
|
||||
**预估规模**:约 400 行代码
|
||||
|
||||
**状态**:✅ Phase 1 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Tool System(工具系统)
|
||||
|
||||
**目标**:实现 MCP 协议集成与自定义工具注册、调用、权限控制。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `tools.rs` + `tools/` 模块(base/registry/permission/mcp/error)
|
||||
2. ✅ `ToolRegistry` — 工具注册表(注册、发现、调用、并行执行、超时控制)
|
||||
3. ✅ `BaseTool` trait — 工具抽象接口(含 ToolContext 执行上下文)
|
||||
4. ✅ `McpClient` — MCP 协议客户端(stdio transport,StreamableHttp 预留)
|
||||
5. ✅ `PermissionChecker` — 工具执行权限检查(白名单/黑名单/自定义权限)
|
||||
6. ✅ `docs/5-tool-system.md` — 方案设计文档
|
||||
7. ✅ 扩展 `llm/cycle.rs` 支持自动 tool 循环(`submit_with_tools()` + `submit_request()` + `maybe_compact()`)
|
||||
8. ✅ `ToolError` — 结构化错误体系(含 `is_recoverable()` 分类)
|
||||
|
||||
**依赖**:Phase 0(LlmProvider 接口传递 tool definitions)、Phase 1(提示词可能需要注入工具描述)
|
||||
|
||||
**优先级**:Should Have
|
||||
|
||||
**预估规模**:约 900 行代码(实际约 1500 行)
|
||||
|
||||
**状态**:✅ Phase 2 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Memory System(记忆系统)
|
||||
|
||||
**目标**:提供对话记忆的存储、检索与管理能力。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `memory.rs` + `memory/` 模块(store / conversation / knowledge / retriever / error / types)
|
||||
2. ✅ `MemoryStore` trait + `InMemoryStore` — 记忆存储抽象(可插拔后端)+ 默认实现
|
||||
3. ✅ `ConversationMemory` — 对话记忆管理(sliding window / 全量),复用 `llm::compact`
|
||||
4. ✅ `KnowledgeStore` — 知识页面存储(具体 struct,非 trait,基于 MemoryStore)
|
||||
5. ✅ `MemoryRetriever` — 记忆检索器(TextOverlap Dice 系数评分,单通道)
|
||||
6. ✅ `docs/6-memory-system.md` — 方案设计文档
|
||||
7. ✅ `docs/note-knowledge-graph-design.md` — KnowledgeGraph 等 Phase 4 备用设计
|
||||
8. ✅ `EvictionPolicy` — 支持 None / Ttl / Capacity 三种淘汰策略
|
||||
|
||||
**依赖**:Phase 0(llm::compact 复用)、Cargo.toml 新增 `time` 依赖
|
||||
|
||||
**优先级**:Could Have
|
||||
|
||||
**预估规模**:约 700 行代码(实际约 1242 行,含测试)
|
||||
|
||||
**状态**:✅ Phase 3 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
### Phase 4a — Agent Core Glue(核心胶水层)
|
||||
|
||||
**目标**:提供最小可用的 Agent Runtime——把 Phase 0-3 的能力"装配"成 `AgentSession::submit_turn`。上层可基于 4a 构建多轮对话应用。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `agent.rs` + `agent/` 模块(7 个文件:agent/error/runtime/builder/session/task + 模块根)
|
||||
2. ✅ `Agent` trait — 智能体角色定义(name / system_prompt / tool_definitions)
|
||||
3. ✅ `AgentSession` — 会话实例(绑定 `Arc<dyn Agent>` + `RuntimeBundle` + 内联 HashMap session_data)
|
||||
4. ✅ `RuntimeBundle` — 显式依赖注入容器(不含 session_memory_backend)
|
||||
5. ✅ `AgentBuilder` — 链式构造入口(不含 session_memory_backend)
|
||||
6. ✅ `AgentError` — 统一错误类型(7 个变体:Llm / Tool / Memory / HookBlocked / LimitExceeded / Config / Other;不含 PlanParse)
|
||||
7. ✅ `Plan` / `Step` / `StepStatus` — 纯数据结构(不含任何解析逻辑)
|
||||
8. ✅ Hook 事件扩展:OnTurnStart / OnTurnEnd + turn_index 字段
|
||||
9. ✅ `docs/7-agent-runtime.md` — 方案设计文档(含 4a/4b/4c 分阶段计划)
|
||||
|
||||
**实际新增**:
|
||||
- 新增文件 7 个(agent.rs + agent/{agent, error, runtime, builder, session, task}.rs)
|
||||
- 修改文件 3 个(lib.rs +1 行;llm/hooks.rs +13 行追加变体/字段;llm/cycle.rs 内部字段 Box→Arc + 新增 `new_with_arc` 公共方法)
|
||||
- 实际代码量约 800 行(含测试;纯实现约 470 行——略高于方案预估 440 行,因 AgentSession 的 tests 模块内联 MockProvider/StubAgent 等辅助结构)
|
||||
- 新增内联测试 22 个;全量测试 84 → 109(0 失败)
|
||||
- clippy 0 警告(agent 模块)
|
||||
- 无新增外部依赖
|
||||
|
||||
**依赖**:Phase 0, 1, 2, 3
|
||||
|
||||
**优先级**:Could Have
|
||||
|
||||
**预估规模**:约 440 行代码
|
||||
|
||||
**状态**:✅ Phase 4a 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
### Phase 4b — Task Execution(任务执行)
|
||||
|
||||
**目标**:在 Phase 4a 基础上,赋予智能体"拆解目标 → 逐步执行"的能力。
|
||||
|
||||
**前置条件**:Phase 4a 已完成。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `TaskAgent` trait — `run(goal)` 自主式 + `execute_plan(plan)` 外部驱动式
|
||||
2. ✅ `PlanParser` trait + `JsonPlanParser` 参考实现
|
||||
3. ✅ `AgentError` 追加 PlanParse 变体(共 7 个变体)
|
||||
4. ✅ Hook 事件扩展:OnPlanStepComplete + plan_step_index 字段
|
||||
|
||||
**依赖**:Phase 4a
|
||||
|
||||
**优先级**:Could Have
|
||||
|
||||
**预估规模**:约 200 行代码(增量)
|
||||
|
||||
**实际新增**:
|
||||
- 修改文件 2 个(llm/hooks.rs +5 行;agent/error.rs +10 行)
|
||||
- 新增代码约 150 行(含测试;纯实现约 90 行)
|
||||
- 新增内联测试 4 个;全量测试 109 → 113(0 失败)
|
||||
- clippy 0 警告
|
||||
- 无新增外部依赖
|
||||
|
||||
**状态**:✅ Phase 4b 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
### Phase 4c — Session Memory(会话级记忆)
|
||||
|
||||
**目标**:提供会话级 key-value 记忆,作为 session 内各 context 之间的信息桥接通道。
|
||||
|
||||
**前置条件**:Phase 4a 已完成(可与 Phase 4b 并行)。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `SessionMemory` struct — 基于 `MemoryStore`,按 session_id namespace 隔离
|
||||
2. ✅ `RuntimeBundle` + `AgentBuilder` 扩展 `session_memory_backend` 字段
|
||||
3. ✅ `AgentSession` 替换内联 HashMap 为完整 `SessionMemory`
|
||||
|
||||
**依赖**:Phase 4a(Phase 3 MemoryStore)
|
||||
|
||||
**优先级**:Could Have
|
||||
|
||||
**预估规模**:约 115 行代码(增量)
|
||||
|
||||
**实际新增**:
|
||||
- 新增文件 1 个(agent/session_memory.rs)
|
||||
- 修改文件 4 个(agent/runtime.rs +5 行;agent/builder.rs +10 行;agent/session.rs +30 行;agent.rs +2 行)
|
||||
- 新增代码约 180 行(含测试;纯实现约 100 行)
|
||||
- 新增内联测试 3 个;全量测试 113 → 116(0 失败)
|
||||
- clippy 0 警告
|
||||
- 无新增外部依赖
|
||||
|
||||
**状态**:✅ Phase 4c 全部交付物已完成
|
||||
|
||||
---
|
||||
```mermaid
|
||||
graph BT
|
||||
P0["<b>Phase 0: Foundation</b><br/>LLM Cycle<br/>ProviderRegistry<br/>HookExecutor<br/>StreamEvents<br/>Auto-compaction"]:::done
|
||||
P1["<b>Phase 1: Prompt Engineering</b><br/>PromptTemplate<br/>PromptComposer"]:::done
|
||||
P2["<b>Phase 2: Tool System</b><br/>Tool Registry<br/>PermissionChecker<br/>MCP Client"]:::done
|
||||
P3["<b>Phase 3: Memory System</b><br/>MemoryStore<br/>ConversationMemory<br/>KnowledgeStore"]:::done
|
||||
P4a["<b>Phase 4a: Core Glue</b><br/>AgentSession<br/>RuntimeBundle<br/>Plan/Step 纯数据"]:::done
|
||||
P4b["<b>Phase 4b: Task Execution</b><br/>TaskAgent<br/>PlanParser<br/>JsonPlanParser"]:::done
|
||||
P4c["<b>Phase 4c: Session Memory</b><br/>SessionMemory"]:::done
|
||||
|
||||
P1 --> P0
|
||||
P2 --> P0
|
||||
P3 --> P0
|
||||
P2 --> P1
|
||||
P4a --> P1
|
||||
P4a --> P2
|
||||
P4a --> P3
|
||||
P4b --> P4a
|
||||
P4c --> P4a
|
||||
|
||||
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
|
||||
classDef pending fill:#fbbf24,stroke:#d97706,color:#1a1a1a
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.1 发布里程碑(2026-07-04)
|
||||
|
||||
**质量基线**:
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| `cargo build --all-targets` | ✅ 通过 |
|
||||
| `cargo test --all-targets` | ✅ **182 passed / 0 failed** |
|
||||
| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 |
|
||||
| 离线示例(`cargo run --example`) | ✅ 7 个全部 exit 0 |
|
||||
|
||||
**关键交付**:
|
||||
1. **Provider IR 重构** — 统一 `Message` / `ContentBlock` / `MessageRequest` / `MessageResponse` 类型层;4 个 Provider 适配(OpenAI Chat / Anthropic Messages / DeepSeek / Qwen);`LlmProvider` trait 签名同步切换
|
||||
2. **LlmCycle 简化** — `LlmCycle` 内部消息类型切到 IR 层;移除 Phase 0 的 `OpenaiChatMessage ↔ Message` 桥接;测试从 116 → 182(含 provider 测试)
|
||||
3. **`MockProvider` 公开化** — `agcore::llm::mock::MockProvider` 支持 `chat` + `chat_stream`,无需 API key 即可运行示例
|
||||
4. **7 个离线示例** — `prompt_composer` / `custom_tool` / `agent_session_demo` / `task_agent_demo` / `conversation_memory_demo` / `knowledge_search_demo` / `streaming_events_demo`
|
||||
5. **错误消息友好化** — `AgentError` / `LlmError` / `ToolError` / `MemoryError` / `PromptError` 全部面向最终用户改写(给出可操作的建议)
|
||||
6. **文档完整** — README 完整版(快速上手 + 架构图 + 环境变量)、Apache-2.0 LICENSE
|
||||
@@ -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 个增量 Phase(Phase 5–12),17 个可验证 Step,约 2000+ 行新增代码,测试 182 → 277+。
|
||||
|
||||
---
|
||||
|
||||
## v0.2.0 — 生产就绪(Production-Ready Core)
|
||||
|
||||
**目标**:解决 Rust Agent 工具箱从"能跑"到"能被人依赖"的鸿沟。持久化、配置层、上下文管理三大块补齐后,开发者可在 30 分钟内写出生产可用的 Agent 服务。
|
||||
|
||||
**总体规模**:8 个增量 Phase(Phase 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 0(MemoryStore trait)、Phase 3(MemoryStore 持久化)
|
||||
**优先级**: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(+8,phase 5 新增 from_env 与 Ollama 相关单测)
|
||||
- clippy 0 警告
|
||||
|
||||
**依赖**:无(三个 Step 互不冲突)
|
||||
**优先级**:P0(5.1)+ P1(5.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` IR(name / 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(+1,Phase 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` trait(v0.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(+9,Phase 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-05,7 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 5(ProviderConfig from_env)+ Phase 6(ToolDef)+ Phase 7(SqliteStore)
|
||||
**优先级**: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 6(ToolDef)+ `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 warning,10 + 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 7(SqliteStore 推荐持久化后端;`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** | 文件系统 MemoryStore(JSON/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 功能按需交付 | ⏳ |
|
||||
@@ -0,0 +1,367 @@
|
||||
# AG Core Roadmap — v0.3.0
|
||||
|
||||
> 本文件聚焦 **v0.3.0 版本** 的规划与交付(Phase 13–19)。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 个增量 Phase(Phase 13–19),总新增代码约 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 个增量 Phase(Phase 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 / metadata(HashMap<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` trait(async + `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(+22,Phase 15 新增 19 测试 + 部分重叠计数 22 净增);clippy 0 警告,doc 0 warning
|
||||
- 设计文档:`docs/21-phase15-vector-store-persistence.md`(1570 行,经 3 轮审查 + 文档-代码不一致修复:`search_orthogonal_vectors` 返回 1 条 score≈0 而非空列表)
|
||||
|
||||
**依赖**:Phase 14(Document 类型 + 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()`
|
||||
- 🟡 W5:Full 模式 `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>` — 创建根 session(UUID 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 10(ContextSlot 持久化 — 消息由 slot 自己管,Checkpointer 管执行状态)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 700 行(5 新增文件 + 5 修改文件)
|
||||
**状态**:✅ Phase 17 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
**实际新增**(2026-07-15,3 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 17(SessionManager + 会话树)
|
||||
**优先级**: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**(+17,0 失败)
|
||||
- 质量基线:`cargo test --all-targets` 391 passed / 0 failed;`cargo clippy --all-targets -- -D warnings` 0 警告;`cargo doc --no-deps` 0 warning;4 个示例全部 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` trait(10 个 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 |
|
||||
@@ -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-25(Cargo features 定义 + 依赖 optional 化 + 全模块 cfg 门控);Step 3 完成 Phase 26-27(CI 测试矩阵固化 + 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 总体范围
|
||||
|
||||
**版本等级**:patch(v0.3.2),`default = ["full"]` 保持向后兼容,非破坏性变更。
|
||||
|
||||
**改造基线**:v0.3.0 已交付 23,718 行 Rust 代码,66 个源文件。当前所有依赖全量编译——引用 agcore 就意味着拉入 rusqlite bundled、reqwest、tokio full 等全部重型依赖。
|
||||
|
||||
**改造目标**:16 个 features(10 模块级 + 5 provider + 1 工具)+ 4 个快捷组合。下游可只选 `chat` 组合跳过 SQLite 和 MCP 的编译,或只选 `document` 实现纯文档分割零外部依赖。
|
||||
|
||||
**工作性质**:纯 cfg 门控 + Cargo.toml 配置变更,不新增功能代码。
|
||||
|
||||
**总体规模**:8 个增量 Phase(Phase 20–27),预计新增/修改约 330 行配置与条件编译代码。
|
||||
|
||||
---
|
||||
|
||||
## 功能清单
|
||||
|
||||
### 模块级 features(10 个)
|
||||
|
||||
| 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` | McpClient(Stdio/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 features(5 个,各自独立)
|
||||
|
||||
| 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 |
|
||||
|
||||
### 工具 features(1 个)
|
||||
|
||||
| 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+轻量记忆,跳过 SQLite;MCP 按需加 `tools-mcp`) |
|
||||
| `multi` | engine + provider-openai | 多 Agent 复合(chat + subagent + switch + checkpointer;MCP 按需加 `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 21–25**:全是 `#[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-19,Step 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-19,Step 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-19,Step 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-19,Step 1 合并)— Phase 24.3(`sqlite_store` 模块门控)+ Phase 24.4(`pub use SqliteStore` 门控)已完成;Phase 24.1(memory 模块 pub use)由 `src/lib.rs` 的 `#[cfg(feature = "memory")]` 覆盖;Phase 24.2(vector_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-19,Step 1 合并)— Phase 25.3(`src/lib.rs` 中 agent/engine 模块声明 cfg)已完成;Phase 25.1/25.2(agent/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-19(Step 1 合并) |
|
||||
| **M18** | Phase 22 | 5 个 provider 各自单独编译;cycle.rs 无 tools 时编译通过 | ✅ 2026-07-19(Step 1 合并) |
|
||||
| **M19** | Phase 23 | tools 不含 mcp 编译通过;加 tools-mcp 引入 McpClient | ✅ 2026-07-19(Step 1 合并) |
|
||||
| **M20** | Phase 24 | memory imply document+llm 编译通过;不含 sqlite 编译通过;加 memory-sqlite 引入 SqliteStore | ✅ 2026-07-19(Step 1 合并) |
|
||||
| **M21** | Phase 25 | agent + engine 全链路门控编译通过 | ✅ 2026-07-19(Step 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)
|
||||
@@ -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 个增量 Phase(Phase 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 18(SubAgent 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 A(Swarm 编排)
|
||||
**优先级**: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 14(Embedding 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 C(interrupt/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 够用 |
|
||||
| 路线 B(StateGraph 通用图引擎) | 当前编排需求在路线 A 范围内,图引擎留给未来版本 |
|
||||
| RL 轨迹导出 | 专项需求,非通用 |
|
||||
| Markdown 技能按需加载 | 独立功能 |
|
||||
@@ -0,0 +1,24 @@
|
||||
# AG Core Roadmap
|
||||
|
||||
> 拆分式 roadmap:按版本归档 + 未归类内容
|
||||
> 最后更新:2026-07-21(v0.4.0 规划完成 — Phase A-E 多 Agent 编排路线图制定)
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 范围 | 状态 |
|
||||
|------|------|------|
|
||||
| [`roadmap-v0.1.0.md`](./roadmap-v0.1.0.md) | v0.1.0 计划与交付 — Phase 0–4c + v0.1.0 Release | ✅ 已发布 2026-07-04 |
|
||||
| [`roadmap-v0.2.0.md`](./roadmap-v0.2.0.md) | v0.2.0 计划与交付 — Phase 5–12 + 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 13–19 | ✅ Phase 13-19 全部完成,v0.3.0 交付完毕 |
|
||||
| [`roadmap-v0.3.2.md`](./roadmap-v0.3.2.md) | v0.3.2 计划与交付 — Phase 20–27(Cargo 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-E(Swarm 编排、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+ 展望」开始
|
||||
Reference in New Issue
Block a user