chore(docs): 将设计文档从 docs 移至 design 目录

将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
This commit is contained in:
徐涛
2026-07-23 05:45:53 +08:00
parent 528a17f5fa
commit 28ca43ccb2
57 changed files with 0 additions and 0 deletions
View File
@@ -0,0 +1,180 @@
# Agent Harness 参考项目调研笔记
> 调研日期:2026-06-09
> 用途:为 AG Core Phase 4Agent 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 TreeSQLite 分层摘要) | 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 的衔接处
- L4Multi-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)留好接口。**
+335
View File
@@ -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 4trait + 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` 工具。
+222
View File
@@ -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` 的 identityLLM 容易"串味"。
### 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 委派子任务给 subagentsubagent 独立运作 |
| 长 session 上下文压缩 | ✅ 附带收益 | 拆分 context 后,每个 context 独立累积消息,不会互相拖长 |
| 并行 context 执行 | ⚠️ 拓展场景 | context_a 和 context_b 可各自独立推进 |
---
## 2. 三个候选方案
### 方案 AOpenCode 式(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 的三种选项
| 选项 | 描述 | 评估 |
|------|------|------|
| **选项 1AgentSession 自动写** | 每轮对话后自动摘录关键信息 | ❌ 摘录什么?容易变成精简版对话历史,失去"关键信息"的定位 |
| **选项 2LLM 通过 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 只铺"水管接口",不装"水循环系统"。**
+266
View File
@@ -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 只是一个空的图存储。
+183
View File
@@ -0,0 +1,183 @@
# LangChain & LangGraph 功能调研笔记
> 调研时间:2026-07-06
> 两者关系:同一公司(LangChain Inc.)维护的堆栈上下两层,不是竞品
---
## 两者关系
```
┌──────────────────────────────────────────┐
│ LangChain (v1.0 GA) │ ← 高层框架:模型抽象、工具、提示词、600+集成
│ create_agent / LCEL / 组件库 │
├──────────────────────────────────────────┤
│ LangGraph (v1.0 GA) │ ← 底层运行时:有向图执行引擎
│ StateGraph / Checkpointing / HITL │
├──────────────────────────────────────────┤
│ LangSmith (可观测性) │
└──────────────────────────────────────────┘
```
2025年10月22日同时达到 v1.0 GA,官方分工:
> **LangChain** = agent frameworkabstractions and integrations for models, tools, and agent loops.
> **LangGraph** = orchestration runtimedurable execution, streaming, human-in-the-loop, and persistence.
LangChain v1.0 的 `create_agent` 内部已运行在 LangGraph 引擎上。
---
## LangChain v1.0
### 定位
高层应用框架,提供 agent 所需的**组件抽象**和**集成生态**。
### 精简后的核心模块
| 模块 | 功能 |
|------|------|
| `langchain.agents` | `create_agent`, `AgentState`(取代旧 AgentExecutor |
| `langchain.chat_models` | `init_chat_model`, `BaseChatModel`(统一模型初始化) |
| `langchain.tools` | `@tool`, `BaseTool` |
| `langchain.messages` | 消息类型、内容块、`trim_messages` |
| `langchain.embeddings` | `init_embeddings`, `Embeddings` |
旧组件(`LLMChain``ConversationChain` 等)移入 `langchain-classic`
### 七大组件类别
| 类别 | 关键组件 |
|------|----------|
| **Models** | Chat models, LLMs, Embeddings — 统一接口跨 provider 切换 |
| **Tools** | 600+ provider 集成:API、数据库、搜索引擎等 |
| **Agents** | `create_agent`, ReAct agents, Tool-calling agents |
| **Memory** | 消息历史、自定义状态 |
| **Retrievers** | 向量检索器、网络检索器 |
| **Document** | 加载器、分割器、转换器 |
| **Vector Stores** | Chroma, Pinecone, FAISS 等集成 |
### v1.0 关键新特性
**1. Middleware 中间件系统**`create_agent` 的钩子系统:
- `before_model` — 模型调用前注入/修改
- `after_model` — 模型调用后验证/后处理
- `wrap_tool_call` — 拦截工具调用错误
**2. Standard Message Content** — 跨 provider 标准化消息内容格式:
- 推理/思维链、引用、多模态(图片/音视频/文档)
- 工具调用、provider 特有工具(web search, code execution
- 通过 `.content_blocks` 属性访问,向后兼容
**3. `create_agent`** — 取代旧 AgentExecutor,内部运行在 LangGraph 运行时上
### 成熟度
| 维度 | 状态 |
|------|------|
| 版本 | v1.0 GA2025-10 |
| 稳定性 | 稳定,agent 层经重构后已稳定 |
| 生产证明 | Replit, Clay, Rippling, Cloudflare, Workday |
| 支持 | LTS-style support track |
| 适用场景 | RAG、信息提取、单 agent 助手、快速原型 |
---
## LangGraph v1.0
### 定位
底层编排运行时,专为**有状态、长时间运行、多步骤**工作流设计。
### 核心抽象链
```
StateGraph → Nodes (纯 Python 函数) → Edges (路由逻辑)
Shared State (TypedDict / Pydantic)
Checkpointer (每个 super-step 快照)
```
- **StateGraph**: 有状态图,参数化 State 类型
- **Nodes**: 纯函数,`(State) → updates`
- **Edges**: `add_conditional_edges`,支持循环/分支/合并
- **State**: `TypedDict` 或 Pydantic,带 reducer 处理并发更新
- **Reducers**: `add_messages` 等,自动处理追加 vs 覆盖
### 完整功能矩阵
| 功能 | 状态 | 细节 |
|------|------|------|
| **StateGraph** | ✅ 稳定 | 循环图(非 DAG),条件边缘,并行 fan-out |
| **Checkpointing** | ✅ v4.1.1 | SQLite / PostgreSQL / Redis 后端 |
| **Durable Execution** | ✅ 稳定 | 跨失败自动恢复,从精确断点继续 |
| **Human-in-the-loop** | ✅ 一等公民 | `interrupt()` + `Command(resume=...)` |
| **Time-travel 调试** | ✅ 稳定 | 回滚任意 checkpointfork 重放 |
| **流式输出** | ✅ 稳定 | Token 级 + State 级 + Event 级 |
| **多 Agent 编排** | ✅ 稳定 | Supervisor / Swarm / 层级 / Subgraph |
| **Comprehensive Memory** | ✅ 稳定 | 短时工作记忆 + 长时持久记忆 |
| **增量状态存储** | 🧪 DeltaChannel beta (v4.1.0+) | 长消息列表只存 delta |
| **跨进程状态同步** | 🧪 RemoteCheckpointer (v4.1.0+) | 分布式多 agent 架构 |
| **自动 checkpoint 清理** | ✅ keep_latest TTL (v4.0.2) | 避免无限制积累历史 |
| **LangGraph Platform** | ✅ 稳定 | Agent Server:持久化、任务队列、版本管理 |
| **LangGraph Studio** | ✅ 稳定 | 可视化 agent 工作流 |
### 成熟度
| 维度 | 状态 |
|------|------|
| 版本 | v1.0 GA2025-10),checkpointer v4.1.1 (2026-05) |
| 稳定性 | 高,持久化为架构一等公民 |
| 生产证明 | Klarna, Replit, Elastic |
| 支持 | LTS-style support track |
| 适用场景 | 多步骤 agent、多 agent 系统、人工审批、长时间运行任务 |
---
## 功能边界对比
| 维度 | LangChain | LangGraph |
|------|-----------|-----------|
| **层次** | 高层应用框架 | 底层编排运行时 |
| **核心抽象** | `create_agent`, LCEL, 组件库 | `StateGraph`, Nodes, Edges, State |
| **思维模型** | 线性或 DAG 管道 | 节点 + 边缘的循环有向图 |
| **循环/分支** | 受限 | **一等公民**:任意循环、分支、合并 |
| **状态持久化** | 无原生支持 | **一等公民**Checkpointer |
| **Human-in-loop** | 需手动编排 | **一等公民**`interrupt()` + `Command` |
| **Time-travel 调试** | 无 | **一等公民**:回滚 fork 重放 |
| **Durable Execution** | 无 | **一等公民**:跨故障自动恢复 |
| **流式** | Token 级 | Token + State + Event 每节点流式 |
| **多 Agent 编排** | 需手动组合 | **原生**Supervisor/Swarm/Subgraph |
| **模型抽象** | **核心优势** | 复用 LangChain |
| **600+ 集成** | **核心优势** | 可复用 LangChain 集成 |
| **LCEL 线性链** | **有** | 无 |
| **Middleware** | **v1.0 特有** | 无 |
| **学习曲线** | 中等 | 较陡(需图思维) |
| **部署平台** | 无独立平台 | LangGraph Platform + Studio |
---
## 决策路线
```
你的 workflow 需要什么?
├─ 线性、始终相同步骤 → LangChain (LCEL / create_agent)
├─ 需要循环/分支/重试 → LangGraph (StateGraph)
├─ 需要持久化/故障恢复 → LangGraph (Checkpointer)
├─ 需要人工审批 → LangGraph (interrupt())
├─ 需要 time-travel 调试 → LangGraph (checkpoint + fork)
├─ 需要多 agent 协作 → LangGraph (Supervisor/Swarm/Subgraph)
└─ 不确定 → 先用 create_agent,遇到瓶颈下钻到 StateGraph
```
---
## 参考来源
- [LangChain Blog: v1.0 Milestone](https://www.langchain.com/blog/langchain-langgraph-1dot0)
- [LangChain Documentation](https://docs.langchain.com/oss/python/langchain/overview)
- [LangGraph Documentation](https://docs.langchain.com/oss/python/langgraph/overview)
- [LangGraph GitHub](https://github.com/langchain-ai/langgraph)
- [Atlan: LangChain vs LangGraph 2026](https://atlan.com/know/ai-agent/ai-agent-memory/langchain-vs-langgraph/)
- [truefoundry: LangChain vs LangGraph](https://www.truefoundry.com/blog/langchain-vs-langgraph)
@@ -0,0 +1,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 2env/instructions/skills)和 Layer 3user.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 1agent 专属 prompt)。
@@ -0,0 +1,344 @@
# 笔记:opencode 子代理调度、分发与合并及工作流推进
> 基于 `/Users/midnite/Samples/opencode` 源码调研,2026-07-04
---
## 一、整体架构
```
LLM(主 Agent
├── 调用 Task tooltool call
│ ↓
│ TaskTool.execute() ← packages/opencode/src/tool/task.ts
│ │
│ ├── agent.get() ← 查找 Agent 定义(agent.ts
│ ├── deriveSubagentPermission() ← 权限合并(subagent-permissions.ts
│ ├── sessions.create() ← 创建子 session
│ │
│ ├── [前台] background.wait() + background.waitForPromotion() race
│ │ ↓ 完成
│ │ renderOutput() → XML <task> 标签返回
│ │
│ └── [后台] background.start() → notify() 异步注入结果
└── 会话循环(runLoop) ← prompt.ts
├── 检测 subtask type part → handleSubtask()
├── 检测 compaction → compaction.process()
└── 正常流程 → LLM.stream() → processor.handleEvent()
```
---
## 二、子代理调度(Dispatch
### 2.1 三种触发入口
| 入口 | 触发方式 | 调用链路 |
|------|---------|---------|
| A — LLM 自主 | LLM 调用 `task` tool | 系统提示词中注入了 Task tool 描述 + `describeTask()` 输出子代理列表 → LLM 决策 |
| B — `subtask` part | 消息中有 `type: "subtask"` 的 part | `handleSubtask()` 直接执行 TaskTool,不走 LLM |
| C — `agent` part | 消息中有 `type: "agent"` 的 part | 转为"调用 task tool 带 subagent: XXX"的提示词,引导 LLM |
### 2.2 TaskTool.execute() 完整流程(task.ts
```
execute(params, ctx):
1. background 开关检查(需 experimental flag
2. ctx.ask() 权限询问
3. agent.get(subagent_type) 查找子代理定义
4. task_id 存在 → sessions.get(task_id) 恢复已有子 session
task_id 不存在 → sessions.create() 创建新子 session
5. deriveSubagentSessionPermission() 合并权限
6. 添加默认 deny 规则(todowrite / task
7. 确定 model(继承或子代理自定义)
8. 执行 runTask() → ops.resolvePromptParts() + ops.prompt()
9. 结果格式化为 XML ← renderOutput()
```
### 2.3 关键:子 session 创建(task.ts lines 121-158
```typescript
// 权限继承
const childPermission = deriveSubagentSessionPermission({
parentSessionPermission: parent.permission ?? [],
subagent: next,
})
// 默认 deny 规则
const childToolDenies = [
// 子代理自己的 permission 没允许 todowrite → 默认 deny
...(next.permission.some(r => r.permission === "todowrite") ? []
: [{ permission: "todowrite", pattern: "*", action: "deny" }]),
// 子代理自己的 permission 没允许 task → 默认 deny(防嵌套)
...(next.permission.some(r => r.permission === "task") ? []
: [{ permission: "task", pattern: "*", action: "deny" }]),
// 主 agent 专有工具也不给子代理
...(cfg.experimental?.primary_tools?.map(p => ({ permission: p, ... })) ?? []),
]
```
---
## 三、通信格式:Tool Call / Tool Result
### 3.1 父→子:Task tool 参数
```
{
subagent_type: "explore" | "general" | ...,
description: "简短描述(3-5词)",
prompt: "子代理的完整任务描述",
task_id?: "恢复已有子 session 时使用",
command?: "触发该调用的 CLI 命令(可选)",
background?: true // 后台模式(需 experimental flag
}
```
### 3.2 子→父:XML 包装的纯文本(renderOutput
```xml
<task id="ses_xxxxx" state="completed">
<summary>任务简述</summary>
<task_result>
子 agent 输出的完整文本内容...
</task_result>
</task>
```
错误时:
```xml
<task id="ses_xxxxx" state="error">
<summary>任务失败</summary>
<task_error>
Error: 具体错误信息...
</task_error>
</task>
```
### 3.3 传递给 LLM 的方式
**前台模式**
```
TaskTool.execute() 返回 { output: "<task>...</task>" }
AI SDK 将其转为 tool result,存入数据库 tool part
下一轮 LLM 调用时,tool result 作为消息历史的一部分传入
LLM 看到 XML,自行解析使用
```
**后台模式**
```
TaskTool.execute() 立即返回 <task state="running">...
子 agent 完成后 → background.wait() 触发 → inject()
向父 session 注入合成 text partsynthetic: true
携带 <task state="completed">... 结果
父 LLM 在下一轮循环中看到该消息
```
---
## 四、分发与合并(Distribution & Merge
### 4.1 并行分发
- **无专用分发层**。依赖 LLM 在单条消息中发出多个 tool call
- `task.txt` 引导 LLM*"Launch multiple agents concurrently whenever possible"*
- 底层通过 Effect.ts 的 `Effect.forkIn(scope, { startImmediately: true })` 实现同一消息内多 tool call 并发
- **子 agent 之间完全隔离**,无直接通信
### 4.2 结果合并
**无专用合并逻辑。** 合并完全通过 LLM 的上下文理解完成:
- 前台:tool result 自然进入消息历史,LLM 下一轮读取
- CLI 命令:额外注入 "Summarize the task tool output above and continue with your task." 引导 LLM 总结
- LLM 自主调用:无额外引导,LLM 自行决定如何使用
### 4.3 前台/后台切换机制(task.ts lines 303-333
```typescript
// 前台执行
return yield* Effect.raceFirst(
background.wait({ id: nextSession.id }), // 等完成
background.waitForPromotion(nextSession.id), // 等 promote 到后台
)
```
当用户将前台任务 promote 到后台时,`waitForPromotion` 先返回(标记 `metadata.background = true`),TaskTool 转而返回后台模式的输出。
### 4.4 后台作业引擎(core/background-job.ts
纯内存、非持久化注册表。使用 Effect.ts 的 `SynchronizedRef` 做并发控制。
| 操作 | 行为 |
|------|------|
| `start()` | 创建 jobfork run effect,返回 info |
| `extend()` | 追加顺序执行的 run(通过 `Deferred` 链式等待前一个完成) |
| `wait()` | `Deferred.await(done)`,可选 timeout |
| `waitForPromotion()` | 等待 `promoted` Deferred 或检测 `background` 标记 |
| `promote()` | 标记 `background = true`,触发 `onPromote` callback |
| `cancel()` | 设置 `cancelled`close scope(中断所有子 fork |
---
## 五、工作流推进(Workflow Progression
### 5.1 核心循环(prompt.ts → runLoop
```
runLoop(sessionID):
while true:
1. MessageV2.filterCompactedEffect() 获取消息
2. MessageV2.latest() 取最近 user/assistant/tasks
3. 检查 finish 状态
- 不是 tool-calls 且有 finish → break(退出循环)
4. 取 taskssubtask / compaction 队列)
- subtask → handleSubtask() → continue
- compaction → compaction.process() → continue/break
5. 检查 overflow → 自动创建 compaction task → continue
6. 构建 assistant message
7. SessionProcessor.create() 创建 handle
8. SessionTools.resolve() 解析所有工具
9. 构建 system prompt(环境信息 + skills + MCP + instructions
10. handle.process() — 启动 LLM stream
11. 检查 result:
- "compact" → 返回给外层触发 compaction
- "stop" → break
- "continue" → 继续循环
```
### 5.2 SessionProcessor 事件处理(processor.ts
| Stream 事件 | 处理逻辑 |
|------------|---------|
| `reasoning-start/delta/end` | 创建 reasoning part → 增量追加 → 最终持久化 |
| `tool-input-start/delta/end` | 创建/更新 tool partpending 状态) |
| `tool-call` | 标记 running → 设置 input → **doom loop 检测** |
| `tool-result` | `completeToolCall()` → 持久化结果 + 附件 |
| `tool-error` | `failToolCall()` → 标记错误 |
| `provider-error` | 抛出异常 → 触发重试 |
| `text-start/delta/end` | 流式文本 → `updatePartDelta()` **增量持久化** |
| `step-start` | 创建快照(snapshot |
| `step-finish` | 生成 patch diff → 更新 usage/tokens → **overflow 检测** → 触发 summary |
| `finish` | stream 结束 |
### 5.3 Doom Loop 检测(processor.ts lines 351-377
连续 3 次**完全相同的 tool call**(相同名称 + 相同输入)触发权限询问:
```typescript
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD) // DOOM_LOOP_THRESHOLD = 3
if (recentParts.length === DOOM_LOOP_THRESHOLD &&
recentParts.every(part =>
part.type === "tool" &&
part.tool === value.name &&
part.state.status !== "pending" &&
JSON.stringify(part.state.input) === JSON.stringify(input)
)) {
yield* permission.ask({ permission: "doom_loop", ... })
}
```
### 5.4 Compaction 工作流
两种触发方式:
| 触发条件 | 行为 |
|---------|------|
| step-finish 检测到 `isOverflow()` + `auto: true` | 创建 compaction task → 下一轮循环执行 → 压缩后 continue |
| step-finish 检测到 `isOverflow()` + `auto: false` | 标记 `assistantMessage.error` → idle 等待用户干预 |
Compaction 使用专门的 `compaction` agenthidden, mode=primary, `*=deny`)执行。
压缩后的消息标记 `compacted: true`,后续通过 `MessageV2.filterCompactedEffect()` 过滤。
### 5.5 重试机制(processor.ts lines 658-672
```typescript
Effect.retry(
SessionRetry.policy({
provider: input.model.providerID,
parse, // 错误解析(区分可重试/不可重试)
set: (info) => status.set(sessionID, { type: "retry", ... }),
}),
)
```
遇 provider 错误自动重试,LLM stream 完成后 `Effect.ensuring(cleanup)` 保证资源释放。
---
## 六、六种内置 Agent
| 名称 | Mode | Hidden | 用途 | 核心权限特征 |
|------|------|--------|------|-------------|
| `build` | primary | 否 | 默认 agent,全部工具 | question/plan_enter=allow |
| `plan` | primary | 否 | 计划模式,禁用编辑 | edit=deny(除 plans), task(general)=deny |
| `general` | subagent | 否 | 通用子代理 | todowrite=deny(默认禁止改 todo |
| `explore` | subagent | 否 | 只读代码探索 | `*=deny`,仅 read/grep/glob/bash/webfetch/websearch |
| `compaction` | primary | 是 | 会话压缩(自动) | `*=deny` |
| `title` | primary | 是 | 生成会话标题 | `*=deny`step=1 时异步 fork |
| `summary` | primary | 是 | 生成消息摘要 | `*=deny`(每个 step-finish 时异步 fork |
用户可通过 `config.agent` 自定义 agent(支持 `mode: "all"`),也可通过 `agent.generate` 让 LLM 辅助生成。
---
## 七、权限模型总结
```
父 session permission
├── 仅继承 deny 规则 + external_directory 规则 ← subagent-permissions.ts
│ (父 agent 的 allow 规则不传播到子代理)
├── 子代理自身 permission(来自 agent 定义)
├── 默认 deny
│ - todowrite(除非子代理明确允许)
│ - task(除非子代理明确允许,默认防嵌套)
└── 主 agent 专有工具 deny(来自 config.experimental.primary_tools
```
子代理的 session 权限 = **父 deny + 父 external_directory + 自身 permission - 默认 deny - primary_tools deny**
---
## 八、关键设计决策
| 决策 | 意图 | 效果/局限 |
|------|------|----------|
| 结果以 XML 纯文本嵌入上下文 | 简单、LLM 可直接理解 | LLM 自行解析 XML;大结果可能被截断 |
| 无专用 merge 逻辑 | 简洁,不引入额外抽象 | 依赖 LLM 的理解能力处理返回结果 |
| 默认禁止子代理嵌套 task | 防止无限递归 | 限制了多级分解场景 |
| 同一消息多 tool call 并发 | 利用 LLM 并行能力 | 子 agent 隔离,无法协作 |
| Effect.ts 贯穿全程 | 类型安全、结构化并发 | 学习曲线陡峭 |
| session 作为隔离边界 | 天然权限/消息隔离 | 每个子 session 独立数据库记录,开销较大 |
| 后台引擎纯内存 | 有意识取舍(注释说明) | 进程重启丢失状态 |
---
## 九、参考源码路径
| 文件 | 角色 |
|------|------|
| `packages/opencode/src/tool/task.ts` | Task tool 核心实现(调度入口) |
| `packages/opencode/src/tool/task.txt` | Task tool 的 LLM 使用说明 |
| `packages/opencode/src/agent/agent.ts` | Agent 定义注册中心 |
| `packages/opencode/src/agent/subagent-permissions.ts` | 子代理权限推导 |
| `packages/opencode/src/tool/registry.ts` | 工具注册 + `describeTask()` 列出可用子代理 |
| `packages/opencode/src/session/prompt.ts` | 会话循环 + `handleSubtask()` + 提示词构建 |
| `packages/opencode/src/session/processor.ts` | LLM stream 事件处理器 |
| `packages/opencode/src/session/tools.ts` | Tool ↔ AI SDK 桥接 |
| `packages/opencode/src/session/system.ts` | 系统提示词生成(含 Task tool 说明) |
| `packages/opencode/src/background/job.ts` | 后台作业包装层 |
| `packages/core/src/background-job.ts` | 后台作业核心引擎(内存注册表) |
+243
View File
@@ -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 是独立体系,暂不纳入当前设计 | 保持当前类型系统专注 OpenAIProvider 层做转换 |
### 关键类型设计原则
- **`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] 定义 OpenaiChatMessage6种角色)+ OpenaiContentPart + ImageURL + InputAudio
6. [tool.rs] 定义 OpenaiToolDefinition + OpenaiToolCall + FunctionCall
7. [request.rs] 定义 OpenaiChatRequest35+ 字段)+ 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 数,逻辑不变
+515
View File
@@ -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-01Message 采用扁平大枚举
#### 定义
```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-02LlmProvider 感知消息类型
沿用 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-03StreamEvent 高精度 + 终端事件携带完整响应
沿用 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 signaturemessage_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-04LlmCycle 简化
沿用 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-05Provider 发现使用 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 1Provider 适配
> **前置条件**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 2LlmCycle 简化(逻辑重构)
> **说明**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 1Provider 适配) | 某个 Provider 实现不合理 | 将该 Provider 回退为 `unimplemented!()`(当前状态),不影响其他 Provider |
| Phase 1 完成时 | Provider 测试全部通过 | 打 tag `providers-v2-prototype` |
| Phase 2LlmCycle 简化) | 循环逻辑或 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` roletool_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
+340
View File
@@ -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 个 errorsession.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 A4clippy 警告清零(~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 B0MockProvider 公开化(~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 B0MockProvider 需支持 `chat_stream` |
**验收条件**:额外 3 个示例均可 `cargo run` 成功退出
---
### Phase C — 开发者体验 + 文档(Week 2 后半,与 Phase B 后段并行)
#### Task C1README 完整版(~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 D1CI 示例验证集成(~0.5d
- 确认 `cargo build` 无新增警告
- 确认 `cargo test` 全部通过(退出码 0
- 确认 `cargo test --examples` 全部通过
- 确认 `cargo clippy --lib -p agcore` 0 警告
#### Task D2Roadmap 更新(~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 D4CHANGELOG 初始化(~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 8MVP 出口)依赖 from_env |
| **预期交付物** | `ProviderConfig` 扩展;`create_provider()` 超时注入;`from_env()` + 单元测试 |
| **代码量** | ~60 行 + 测试 |
## 3. 方案设计
### 3.1 Step 5.2 — Ollama Provider(先执行)
#### 改动文件清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `src/llm/provider/ollama.rs` | **新建** | OllamaProvider newtype 包装 |
| `src/llm/provider.rs` | 修改 | `ProviderType` 新增 `Ollama` 变体;`FromStr` 加解析;`create_provider()` 加分支 |
| `src/llm/provider/mod.rs` 或其他模块注册文件 | 修改(如需要) | 注册 `pub mod ollama` |
#### 关键代码
**`src/llm/provider/ollama.rs`**(新建):
```rust
//! Ollama Provider —— OpenAI-compatible 协议的 newtype 包装,零 API key。
//!
//! 默认 base_url = `http://localhost:11434/v1`,空 api_key 也可工作。
//! 实现方式同 DeepSeekProvider / QwenProvider,共享 GenericOpenaiProvider 的 HTTP/SSE/转换逻辑。
use reqwest::Client;
use std::pin::Pin;
use async_trait::async_trait;
use futures_core::Stream;
use super::openai::GenericOpenaiProvider;
use super::{LlmProvider, ProviderCapabilities};
use crate::llm::error::LlmError;
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
pub struct OllamaProvider(pub GenericOpenaiProvider);
impl OllamaProvider {
pub fn new(base_url: String, api_key: String, model: String) -> Self {
let url = if base_url.is_empty() {
"http://localhost:11434/v1".to_string()
} else {
base_url
};
Self(GenericOpenaiProvider::new_with_name(
url,
api_key,
model,
"ollama",
))
}
/// 替换默认 HTTP Client(用于 timeout 注入等场景)。
/// 与 `OpenaiChatProvider::with_client` 和 `DeepSeekProvider::with_client` 一致。
pub fn with_client(self, client: Client) -> Self {
Self(self.0.with_client(client))
}
}
#[async_trait]
impl LlmProvider for OllamaProvider {
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
self.0.chat(request).await
}
async fn chat_stream(
&self,
request: MessageRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
self.0.chat_stream(request).await
}
fn capabilities(&self) -> ProviderCapabilities {
let mut caps = self.0.capabilities();
caps.provider_name = "ollama";
caps
}
}
```
**`src/llm/provider.rs`** 的修改:
```rust
// ProviderType 新增变体
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderType {
OpenaiChat,
OpenaiResponse,
Anthropic,
DeepSeek,
Qwen,
/// Ollama(本地),默认 base_url = `http://localhost:11434/v1`。
Ollama,
}
// FromStr 加解析
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
// ... 已有条目 ...
"ollama" => Ok(ProviderType::Ollama),
_ => Err(format!("未知的 Provider 类型: {s}")),
}
}
// create_provider() 加分支
// Step 5.2 阶段仅展示基本构造。Step 5.1ProviderConfig 扩展)
// 执行到此分支时,将同步补充 with_client 链式调用注入 timeout
//
// let client = Client::builder()
// .timeout(Duration::from_secs(config.timeout_secs))
// .build()?;
// Ok(Box::new(
// ollama::OllamaProvider::new(config.base_url, config.api_key, config.model)
// .with_client(client),
// ))
ProviderType::Ollama => Ok(Box::new(ollama::OllamaProvider::new(
config.base_url,
config.api_key,
config.model,
))),
```
#### 集成方式
OllamaProvider 的 newtype 包装模式与 `DeepSeekProvider``QwenProvider` 完全一致,`LlmProvider` trait 委托给 `self.0``capabilities().provider_name` 返回 `"ollama"`
### 3.2 Step 5.3 — `#[non_exhaustive]` 前置标记
#### 改动文件清单
| 文件 | 行号 | 枚举 | 操作 |
|------|------|------|------|
| `src/llm/provider.rs` | ~21 | `ProviderType` | 加 `#[non_exhaustive]` |
| `src/llm/types/response_v2.rs` | ~22 | `StopReason` | 加 `#[non_exhaustive]` |
| `src/llm/types/shared.rs` | ~16 | `FinishReason` | 加 `#[non_exhaustive]` |
| `src/memory/store.rs` | ~35 | `EvictionPolicy` | 加 `#[non_exhaustive]` |
**排除清单**`SlotMode`
**决策理由**`SlotMode` 枚举在 Phase 10`src/llm/context.rs`)中才实际定义,Phase 5 尚不存在此类型。`#[non_exhaustive]` 无法标注不存在的枚举,因此排除标注。Roadmapv0.2.0 §Phase 5 Step 5.3)列出的 `SlotMode`(预置) 推迟到 Phase 10 实现时一并添加。
#### 关键代码
每个枚举在 `derive` 上方或下方加一行属性:
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ProviderType {
// ...
}
```
#### 影响分析
- `#[non_exhaustive]` 是纯编译期属性,不影响运行时行为
- 同一 crate 内的 exhaustive match 不受影响(同 crate 可穷举)
- 下游 crate 的 match 必须加 `_ =>` 兜底分支,这是期望行为——确保未来新增变体时不会 silent break
- **单向门**:此步骤一旦通过 `v0.2.0` 发布到公共 API 后,**不可回退**。回退意味着移除 `#[non_exhaustive]`,可能破坏已添加 `_ =>` 的下游代码。因此必须在发布前完成并确认所有枚举变体正确
### 3.3 Step 5.1 — ProviderConfig 扩展(最后执行)
#### 改动文件清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `src/llm/provider.rs` | 修改 | `ProviderConfig` 加字段;加 `impl Default`;加 `from_env()``create_provider` 注入 timeout |
| `src/llm/provider/openai.rs` | 修改 | `GenericOpenaiProvider` 新增 `timeout_secs` 字段;`new_with_name` 接受 timeout 参数;`map_reqwest_error` 参数化 |
| `src/llm/provider/anthropic.rs` | 修改 | 新增 `timeout_secs` 字段;`new()` 接受 timeout 参数;`map_reqwest_error` 参数化 |
| `src/llm/provider/anthropic.rs` | 修改 | 新增 `with_timeout()` 方法(返回 `Result<Self, LlmError>` |
| `src/llm/provider/openai_compat.rs` | 修改 | `DeepSeekProvider``QwenProvider` 新增公开 `with_client()` 方法 |
| `src/llm/provider/ollama.rs` | 修改 | `OllamaProvider` 新增公开 `with_client()` 方法 |
| `Cargo.toml` | 修改 | 加 `temp_env` dev-dependency |
| 测试文件(`provider.rs` 内联或独立) | 新增 | `from_env` 单元测试 + timeout 传导集成测试 |
#### 数据结构
```rust
/// Provider 构造参数 —— 通用 base_url + api_key + model + timeout/retry 配置。
pub struct ProviderConfig {
pub base_url: String,
pub api_key: String,
pub model: String,
/// 请求超时秒数(默认 30)。应用于 Provider 的 HTTP Client 级别。
pub timeout_secs: u64,
/// 最大重试次数(默认 3)。当前此字段仅由 `from_env()` 采集,
/// 实际重试逻辑由 `CycleConfig.retry.max_retries` 控制。
/// 未来可合并到统一的 retry 配置。
pub max_retries: u32,
}
impl Default for ProviderConfig {
fn default() -> Self {
Self {
base_url: String::new(),
api_key: String::new(),
model: String::new(),
timeout_secs: 30,
max_retries: 3,
}
}
}
impl ProviderConfig {
/// 从环境变量构造 ProviderConfig。
///
/// 必填变量:
/// - `{prefix}_BASE_URL`
/// - `{prefix}_API_KEY`
/// - `{prefix}_MODEL`
///
/// 可选变量(有默认值):
/// - `{prefix}_TIMEOUT_SECS`(默认 30
/// - `{prefix}_MAX_RETRIES`(默认 3
pub fn from_env(prefix: &str) -> Result<Self, String> {
let base_url = std::env::var(format!("{prefix}_BASE_URL"))
.map_err(|_| format!("{prefix}_BASE_URL 环境变量未设置"))?;
let api_key = std::env::var(format!("{prefix}_API_KEY"))
.map_err(|_| format!("{prefix}_API_KEY 环境变量未设置"))?;
let model = std::env::var(format!("{prefix}_MODEL"))
.map_err(|_| format!("{prefix}_MODEL 环境变量未设置"))?;
let timeout_secs = match std::env::var(format!("{prefix}_TIMEOUT_SECS")) {
Ok(v) => v.parse().unwrap_or_else(|_| {
tracing::warn!("{prefix}_TIMEOUT_SECS='{v}' 解析失败,使用默认值 30");
30
}),
Err(_) => 30,
};
let max_retries = match std::env::var(format!("{prefix}_MAX_RETRIES")) {
Ok(v) => v.parse().unwrap_or_else(|_| {
tracing::warn!("{prefix}_MAX_RETRIES='{v}' 解析失败,使用默认值 3");
3
}),
Err(_) => 3,
};
// ponytail: max_retries 当前仅采集,不传入 Provider。
// 实际重试由 CycleConfig.retry.max_retries 控制。
// 此 warn 在应用启动时通常只触发一次,多次调用 from_env 时
// 重复输出的风险低。如有噪声,可改用 std::sync::Once 控制。
if max_retries != 3 {
tracing::warn!(
"ProviderConfig.max_retries={} 已采集但当前未生效;\
重试次数由 CycleConfig.retry.max_retries 控制",
max_retries,
);
}
Ok(Self {
base_url,
api_key,
model,
timeout_secs,
max_retries,
})
}
}
```
#### Timeout 传导模式
`create_provider()` 中,对基于 `GenericOpenaiProvider` 的 ProviderOpenAI / DeepSeek / Qwen / Ollama),通过同一模式注入 timeout:构造带 timeout 的 `Client` 后调用 `with_client(client)`
所有 OpenAI-compatible 分支新增的 `with_client()` 公开方法:
| Provider | 方法 | 位置 |
|----------|------|------|
| `OpenaiChatProvider` | 已有 `with_client(Client) -> Self` | `openai.rs` |
| `DeepSeekProvider` | 新增 `with_client(Client) -> Self` | `openai_compat.rs` |
| `QwenProvider` | 新增 `with_client(Client) -> Self` | `openai_compat.rs` |
| `OllamaProvider` | 新增 `with_client(Client) -> Self` | `ollama.rs`(新建文件) |
**关于 `new_with_client` 的说明**`DeepSeekProvider``QwenProvider` 当前已有测试用的 `new_with_client(base_url, api_key, model, client)` 方法(通过 `inner.http_client = client` 直接写字段)。新增 `with_client` 后,`new_with_client` 应重构为 `Self::new(base_url, api_key, model).with_client(client)` 代理,统一走公开 API 路径。
代码示例(以 DeepSeek 为例,OpenAI/Qwen/Ollama 模式完全一致):
```rust
ProviderType::DeepSeek => {
let client = Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.build()
.map_err(|e| LlmError::Other(format!("创建 HTTP 客户端失败: {e}")))?;
Ok(Box::new(
openai_compat::DeepSeekProvider::new(
config.base_url,
config.api_key,
config.model,
)
.with_client(client),
))
}
```
Anthropic 由于需要保留 `default_headers`,使用独立的 `with_timeout` 模式:
AnthropicProvider 新增 `with_timeout` 方法:
```rust
impl AnthropicProvider {
/// 替换默认 HTTP Client 的超时配置。
///
/// ⚠️ 副作用:此方法**完全重建** `http_client`,调用后原有通过 `with_client`
/// 注入的 Client 将被替换。headers 逻辑与 `new()` 中的构造保持一致。
pub fn with_timeout(mut self, secs: u64) -> Result<Self, LlmError> {
// ponytail: 重建 http_client 时保留已有默认 headersx-api-key / anthropic-version)。
// 如后续 AnthropicProvider 的 headers 变为动态,此方法需同步更新。
let key_header = HeaderValue::from_str(&self.api_key)
.map_err(|_| LlmError::Other("Anthropic API key 包含无效的 HTTP 头部字符".into()))?;
let version_header = HeaderValue::from_static("2023-06-01");
self.http_client = Client::builder()
.timeout(Duration::from_secs(secs))
.default_headers({
let mut headers = HeaderMap::new();
headers.insert("x-api-key", key_header);
headers.insert("anthropic-version", version_header);
headers
})
.build()
.map_err(|e| LlmError::Other(format!("创建 Anthropic HTTP 客户端失败: {e}")))?;
Ok(self)
}
}
```
#### `map_reqwest_error` 中的硬编码超时修复
`openai.rs``anthropic.rs` 中的 `map_reqwest_error` 辅助函数当前在超时错误中返回硬编码的 `Duration::from_secs(120)`
```rust
// 现状 —— 硬编码 120s,与可配置 timeout 脱节
LlmError::Timeout { duration: Duration::from_secs(120) }
```
**修复方式**:采用**方案 A**——在 Provider struct 中存储 `timeout_secs` 字段,`map_reqwest_error` 读取该字段的值而非硬编码 120s。
```rust
// 修复后 —— 参数化,从 Provider 存储的 timeout_secs 读取
// GenericOpenaiProvider 新增 timeout_secs 字段:
pub struct GenericOpenaiProvider {
http_client: Client,
base_url: String,
api_key: String,
model: String,
provider_name: &'static str,
extra_headers: Vec<(String, String)>,
timeout_secs: u64, // ← 新增,由 new_with_name 的参数传入
}
// map_reqwest_error 使用 self.timeout_secs 而非硬编码 120
LlmError::Timeout { duration: Duration::from_secs(self.timeout_secs) }
```
**方案 B(从 reqwest::Client 提取 timeout)已被否决**`reqwest::Client` 不提供 timeout getter,无法从已构造的 client 中反向读取超时配置。
如果漏掉此修复,用户设置 `AG_LLM_TIMEOUT_SECS=60` 后超时,错误消息仍显示 "LLM 请求超时(120s",与实际配置不符。
---
#### max_retries 说明
`ProviderConfig.max_retries` 当前仅由 `from_env()` 采集存储,**实际重试操作由 `CycleConfig.retry.max_retries` 控制**。两者之间的关系通过文档注释声明:
```rust
/// 最大重试次数(默认 3)。当前此字段仅由 `from_env()` 采集,
/// 实际重试逻辑由 `CycleConfig.retry.max_retries` 控制。
/// 未来 Phase 6+ 可统一合并此字段到 CycleConfig。
```
#### 测试设计
使用 `temp_env` 在单元测试中隔离环境变量:
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_config_from_env_requires_all_vars() {
// 未设置任何变量时应返回 Err
let result = ProviderConfig::from_env("TEST_PROVIDER");
assert!(result.is_err());
}
#[test]
fn provider_config_from_env_uses_defaults() {
temp_env::with_vars([
("TEST_PROVIDER_BASE_URL", Some("http://localhost:11434/v1")),
("TEST_PROVIDER_API_KEY", Some("")),
("TEST_PROVIDER_MODEL", Some("llama3")),
], || {
let config = ProviderConfig::from_env("TEST_PROVIDER").unwrap();
assert_eq!(config.timeout_secs, 30);
assert_eq!(config.max_retries, 3);
});
}
#[test]
fn provider_config_from_env_reads_custom_timeout() {
temp_env::with_vars([
("TEST_PROVIDER_BASE_URL", Some("http://x")),
("TEST_PROVIDER_API_KEY", Some("k")),
("TEST_PROVIDER_MODEL", Some("m")),
("TEST_PROVIDER_TIMEOUT_SECS", Some("60")),
("TEST_PROVIDER_MAX_RETRIES", Some("5")),
], || {
let config = ProviderConfig::from_env("TEST_PROVIDER").unwrap();
assert_eq!(config.timeout_secs, 60);
assert_eq!(config.max_retries, 5);
});
}
}
```
## 4. 实现计划
### Step 5.2 — Ollama Provider~55 行)
| 步骤 | 操作 | 验证 |
|------|------|------|
| 1 | 创建 `src/llm/provider/ollama.rs`,实现 `OllamaProvider` newtype | 编译通过 |
| 2 | 在 `provider.rs` 注册 `pub mod ollama` | 编译通过 |
| 3 | `ProviderType` 新增 `Ollama` 变体 | 编译通过 |
| 4 | `FromStr``"ollama"` 解析 | 编译通过 |
| 5 | `create_provider()``Ollama =>` 分支 | 编译通过 |
| 6 | 运行 `cargo build` | 无错误 |
### Step 5.3 — `#[non_exhaustive]` 前置标记(~4 行)
| 步骤 | 操作 | 验证 |
|------|------|------|
| 1 | `ProviderType``provider.rs`)加 `#[non_exhaustive]` | 编译通过 |
| 2 | `StopReason``response_v2.rs`)加 `#[non_exhaustive]` | 编译通过 |
| 3 | `FinishReason``shared.rs`)加 `#[non_exhaustive]` | 编译通过 |
| 4 | `EvictionPolicy``memory/store.rs`)加 `#[non_exhaustive]` | 编译通过 |
| 5 | 运行 `cargo build --all-targets` | 无 warning |
### Step 5.1 — ProviderConfig 扩展(~60 行 + 测试)
| 步骤 | 操作 | 验证 |
|------|------|------|
| 1 | `ProviderConfig``timeout_secs` / `max_retries` 字段 | 编译通过 |
| 2 | 实现 `impl Default for ProviderConfig` | 编译通过 |
| 3 | 实现 `ProviderConfig::from_env()` | 编译通过 |
| 4 | `GenericOpenaiProvider``AnthropicProvider` 新增 `timeout_secs` 字段,`new_with_name``new()` 接受 timeout 参数 | 编译通过 |
| 5 | `map_reqwest_error` 在各 Provider 中改为从 `self.timeout_secs` 读取,移除硬编码 120s | 编译通过 |
| 6 | `create_provider()` 中各分支注入 timeoutOpenAI-compatible 用 `Client::builder().timeout()` + `with_client`Anthropic 用 `with_timeout()` | 编译通过 |
| 7 | `DeepSeekProvider``QwenProvider``new_with_client` 重构为 `Self::new(...).with_client(client)` 代理 | 测试通过 |
| 8 | `Cargo.toml` 添加 `temp_env` dev-dependency | `cargo build` 通过 |
| 8 | 添加 `from_env` 单元测试 + timeout 传导集成测试 | `cargo test` 通过 |
| 9 | 完整验证 | 见第 6 节 |
## 5. 风险评估
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|----------|
| `create_provider()``Client::builder().build()` 返回 `Result`,当前代码使用 `.expect()`,改为 `map_err` 转为 `LlmError` 后需确保所有分支正确转换 | 编译期强制处理,遗漏分支直接报错 | 低 | `create_provider` 返回 `Result<Box<dyn LlmProvider>, LlmError>``map_err` 天然适配。新增的 timeout 注入路径逐一检查 |
| `AnthropicProvider``default_headers``with_timeout` 中重建时与 `new()` 中的 headers 不一致 | Anthropic 认证失败 | 低 | `with_timeout` 方法复制 `new()` 中的 headers 构造逻辑。通过已有测试验证认证通过 |
| Ollama 实际运行时行为差异:版本兼容性、API 路径、模型名等 | 运行时才能发现 | 中 | Phase 5 仅做类型级验证(`cargo build`),Phase 8 端到端测试时通过 Ollama mock 或真实实例验证 |
| `max_retries` 存储了却未实际使用,造成困惑 | 开发者误以为已生效 | 中 | 通过文档注释明确声明 `max_retries` 当前仅采集,实际重试由 `CycleConfig.retry.max_retries` 控制 |
| `temp_env` 测试在多线程并发测试中互相污染环境变量 | 偶发测试失败 | 中(Rust 默认单线程测试用 `--test-threads=1` 可避免) | 将 `from_env` 测试控制在同一测试文件,避免并行执行。必要时在 CI 中确保 `--test-threads=1` |
## 6. 验收标准
以下条件**全部满足**方可认为 Phase 5 完成:
- [ ] `cargo build --all-targets` 通过,无错误
- [ ] `cargo test --all-targets` 通过,新增测试覆盖 `from_env` 的必填/选填/默认值场景
- [ ] `cargo clippy --all-targets -- -D warnings` 通过,无任何 warning
- [ ] `cargo doc --no-deps -D warnings` 通过,所有公共 API 有文档注释(`///`
- [ ] 新增文件:1`ollama.rs`
- [ ] 修改文件:9`provider.rs``openai.rs``anthropic.rs``openai_compat.rs``response_v2.rs``shared.rs``store.rs``Cargo.toml`、测试文件)
- [ ] 净代码增量:~160 行
- [ ] `ProviderType` 新增 `Ollama` 变体,`"ollama"` 字符串可解析
- [ ] 4 个公共枚举带有 `#[non_exhaustive]` 属性
- [ ] `ProviderConfig` 可从环境变量构造(`from_env()`),含默认值
- [ ] timeout 值已传导到 `create_provider()` 中各 Provider 的 HTTP Client 配置
- [ ] timeout 传导验证通过至少一个端到端 wiremock 集成测试(模拟 HTTP 服务在超时后返回 408,验证 Provider 返回 `LlmError::Timeout`
- [ ] `DeepSeekProvider``QwenProvider``OllamaProvider` 均有公开 `with_client()` 方法,可在 `create_provider` 中注入 timeout Client
- [ ] `map_reqwest_error` 中不再硬编码 `Duration::from_secs(120)`,改为参数化读取
+236
View File
@@ -0,0 +1,236 @@
# Phase 6 — ToolDefinition IR 正式化实施方案
## 背景与目标
在 agcore v0.2 路线图中,Phase 6 旨在引入 `ToolDef` 新类型,替换已标记 `#[deprecated(since = "0.1.0")]``ToolDefinition`(即 `OpenaiToolDefinition` 类型别名),消除 OpenAI wire format 对核心类型系统的泄漏,建立 Provider 无关的工具定义中间表示(IR)。
预期成果:
- 核心类型系统不再直接依赖 `OpenaiToolDefinition`
- 所有 Provider 适配层从统一的 `ToolDef` IR 出发,各自转换为对应 wire format
- 消除 `#[allow(deprecated)]` 抑制点,恢复 clippy 零警告状态
## 当前状态分析
当前代码库中工具定义相关的关键状态如下:
1. **`OpenaiToolDefinition` 结构体**定义于 `llm/types/tool.rs`,包含 4 个字段:
- `name: String`
- `description: Option<String>`
- `parameters: Value`
- `strict: Option<bool>`
当前存在多处 `#[allow(deprecated)]` 抑制点,分布在 `llm/cycle.rs``tools/registry.rs``tools/mcp.rs``agent/agent.rs` 等文件中。
2. **`ToolDefinition` 类型别名**定义于 `llm/types/mod.rs:105`,标记为 `#[deprecated]`
```rust
#[deprecated(since = "0.1.0", note = "use OpenaiToolDefinition directly")]
pub type ToolDefinition = OpenaiToolDefinition;
```
3. **`MessageRequest.tools` 字段**类型为 `Vec<OpenaiToolDefinition>`(直接引用原始类型,而非别名)。
4. **引用该类型的 4 个源文件**:
- `llm/cycle.rs`4 个方法参数使用 `Vec<ToolDefinition>`
- `tools/registry.rs``definitions() -> Vec<ToolDefinition>` 返回类型 + struct literal 构造(含 `strict: None`
- `tools/mcp.rs``list_tools() -> Vec<ToolDefinition>` 返回类型 + struct literal 构造(含 `strict: None`
- `agent/agent.rs``fn tool_definitions() -> Vec<ToolDefinition>` trait 默认实现
5. **Provider 适配层**`openai.rs` 和 `anthropic.rs` 从 `MessageRequest.tools` 读取数据并转换为各自的 wire format。`openai_compat.rs` 和 `ollama.rs` 委托给 `GenericOpenaiProvider`,无需直接改动。
## 需求推演
### 决策 1`strict` 字段的处理
当前所有构造路径均硬编码 `strict: None``registry.rs`、`mcp.rs`),`BaseTool` trait 无 `strict` 方法,用户 API 无法设置该值。
**结论:移除。** `strict` 是 OpenAI 的 Structured Outputs 专属字段,不属于 Provider 无关的 IR。未来如需支持,走 `MessageRequest.extra` 逃生舱,在各 Provider 适配层自行消费。
### 决策 2`description` 保持 `Option<String>`
Anthropic 要求 `description` 为必填(`String`),但 MCP 等来源可能缺失该字段。保持 `Option`,由 Anthropic 适配层以 `unwrap_or_default()` 兜底。
### 决策 3`parameters` 保持 `Value`
所有 Provider 的 wire format 均接受 JSON Schema 格式的 Value。当前不做 typed 方案,保留 `Value`。
### 决策 4:采用直接切断而非阶段性 deprecation
v0.1 已标记 `#[deprecated]`,用户已有预期。pre-1.0 阶段的 breaking change 是合理的。`OpenaiToolDefinition` 保留但降级为 `#[doc(hidden)]`。
## 方案设计
### ToolDef 结构体
位置:`src/llm/types/tool.rs`(与 `OpenaiToolDefinition` 同文件,不建独立文件/模块)。
```rust
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolDef {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub parameters: Value,
}
```
实现双向 `From` 转换:
```rust
impl From<ToolDef> for OpenaiToolDefinition {
fn from(t: ToolDef) -> Self {
Self {
name: t.name,
description: t.description,
parameters: t.parameters,
strict: None,
}
}
}
impl From<OpenaiToolDefinition> for ToolDef {
fn from(t: OpenaiToolDefinition) -> Self {
Self {
name: t.name,
description: t.description,
parameters: t.parameters,
}
}
}
```
serde 属性与 `OpenaiToolDefinition` 原有属性一致,保证 JSON 序列化兼容。
明确不做:
- builder 模式(Rust struct literal + `..Default::default()` 已足够)
- `#[non_exhaustive]`(IR 类型自有完整控制权,不需要)
- 独立文件(7 行 struct 无需独立模块)
### 4 单元切割计划
每步设计为可编译的安全 checkpoint。
#### 单元 6.1 — 新增 ToolDef + From 实现
| 项目 | 内容 |
|------|------|
| 涉及文件 | `llm/types/tool.rs` |
| 变更内容 | 新增 `ToolDef` struct(约 7 行)、2 个 `From` impl(约 12 行) |
| 验证标准 | `cargo build` 编译通过(旧代码照常编译,零影响) |
| 检查点 | 新类型存在但未被消费,安全 checkpoint |
#### 单元 6.2 — 别名切换 + 构造同步修复
| 项目 | 内容 |
|------|------|
| 涉及文件 | `llm/types/mod.rs`、`llm/types/request_v2.rs`、`llm/types/request.rs`、`tools/registry.rs`、`tools/mcp.rs` |
| 变更内容 | 切换别名 `pub type ToolDefinition = ToolDef``MessageRequest.tools` 改为 `Vec<ToolDef>`registry/mcp 构造去掉 `strict: None` |
| 验证标准 | `cargo build` 编译通过 |
| 风险提示 | `cycle.rs` 方法参数使用别名,自动生效无需修改;`agent/agent.rs` trait 默认实现使用别名,自动适配;暂不移除 `#[allow(deprecated)]` |
#### 单元 6.3 — Provider 适配
| 项目 | 内容 |
|------|------|
| 涉及文件 | `llm/provider/openai.rs` |
| 变更内容 | `convert_request()` 中 `tool_defs.into_iter().map(|t| OpenaiTool::Function { function: t })` → 改为 `.map(|t| OpenaiTool::Function { function: t.into() })``t` 类型从 `OpenaiToolDefinition` 变为 `ToolDef`,需 `Into` 转换 |
| 验证标准 | `cargo test --all-targets` 全部通过 |
| 不修改的文件 | `anthropic.rs`(同名字段访问自动适配)、`openai_compat.rs`/`ollama.rs`(委托给 `GenericOpenaiProvider` |
#### 单元 6.4 — 清理
| 项目 | 内容 |
|------|------|
| 涉及文件 | `llm/cycle.rs`、`tools/registry.rs`、`tools/mcp.rs`、`agent/agent.rs`、`llm/types/mod.rs`、`llm/types/tool.rs` |
| 变更内容 | 移除所有与 `ToolDefinition` 相关的 `#[allow(deprecated)]``llm/types/mod.rs` 移除旧 `#[deprecated]` 别名(仅保留 `pub use tool::ToolDef`);`OpenaiToolDefinition` 降级为 `#[doc(hidden)]`。精确列表由 `cargo clippy -D warnings` 检出——clippy 会标记所有不再需要的 `#[allow]` |
| 验证标准 | `cargo clippy --all-targets -- -D warnings` 零警告;`cargo test --all-targets` 全部通过 |
| 无需改动 | 测试代码(无一直接引用 `ToolDefinition` |
### Provider 适配策略
Provider 适配层改动最小化,仅在序列化入口处加一层 `From` 转换:
| Provider | 适配方式 | 改动 |
|----------|---------|------|
| OpenAI`GenericOpenaiProvider` | `MessageRequest.tools: Vec<ToolDef>` → lambda 内改为 `.map(\|t\| OpenaiTool::Function { function: t.into() })`,将 `ToolDef` 通过 `Into` 转为 `OpenaiToolDefinition` | `convert_request` lambda 内 +`.into()` |
| Anthropic | `t.name` / `t.description` / `t.parameters` 字段名不变,直接访问 | 零改动 |
| OpenAI CompatDeepSeek、Qwen | 委托给 `GenericOpenaiProvider` | 零改动 |
| Ollama | 委托给 `GenericOpenaiProvider` | 零改动 |
### 变更清单汇总
| 文件 | 改动类型 | 估计行数 |
|------|---------|---------|
| `llm/types/tool.rs` | +`ToolDef` + 2x `From` | +19 |
| `llm/types/mod.rs` | 改别名 + re-export | ~3 |
| `llm/types/request_v2.rs` | 改 `tools` 字段 + import | ~2 |
| `llm/cycle.rs` | 移 `#[allow(deprecated)]` | -1 |
| `tools/registry.rs` | 构造去掉 `strict` + 移 `allow` | ~4 |
| `tools/mcp.rs` | 构造去掉 `strict` + 移 `allow` | ~4 |
| `agent/agent.rs` | 移 `#[allow(deprecated)]` | -1 |
| `llm/provider/openai.rs` | `convert_request` 加 `.map(Into::into)` | +2 |
| 新增 roundtrip 测试 | `MessageRequest` 序列化 roundtrip 验证 | +15 |
| **合计** | | **约 47 行(+ 约 15 行测试)** |
### 测试策略
新增一条 `MessageRequest` 序列化 roundtrip 测试,覆盖 `ToolDef` 的 JSON 序列化/反序列化兼容性。该测试验证 `ToolDef` 的 serde 属性与 `OpenaiToolDefinition` 一致,确保 wire format 兼容。
## 实施步骤
1. 新建分支 `phase-6-tooldef-ir`
2. 按单元 6.1 → 6.2 → 6.3 → 6.4 顺序执行,每步提交一个 commit
3. 每步执行对应的验证标准
4. 全量通过后创建 PR
```
git checkout -b phase-6-tooldef-ir
# 执行单元 6.1 → commit
# 执行单元 6.2 → commit
# 执行单元 6.3 → commit
# 执行单元 6.4 → commit
# 全量验证
```
### 用户迁移指引
Phase 6 涉及公共 API 类型替换,下游用户升级到 v0.2 时需注意:
| 旧用法 | 新用法 |
|--------|--------|
| `use agcore::llm::types::ToolDefinition` | `use agcore::llm::types::ToolDef`(别名已移除) |
| `use agcore::llm::types::OpenaiToolDefinition` | `use agcore::llm::types::ToolDef``OpenaiToolDefinition` 已降级为 `#[doc(hidden)]` |
| 直接构造 `ToolDefinition { strict: None, .. }` | 构造 `ToolDef { .. }`(去掉 `strict` 字段) |
`OpenaiToolDefinition` 仍保留但标记 `#[doc(hidden)]`,极端情况仍需使用时可通过全路径访问。
## 验证标准
| 阶段 | 验证命令 |
|------|---------|
| 单元 6.1 | `cargo build` 编译通过 |
| 单元 6.2 | `cargo build` 编译通过(新旧代码全量编译) |
| 单元 6.3 | `cargo test --all-targets` 全部通过 |
| 单元 6.4 | `cargo clippy --all-targets -- -D warnings` 零警告;`cargo test --all-targets` 全部通过 |
| 最终 | `cargo build --all-targets` + `cargo test --all-targets` + `cargo clippy --all-targets -- -D warnings` 全绿 |
## 风险与缓解
| 风险 | 说明 | 缓解措施 |
|------|------|---------|
| Struct literal 断层 | Step 6.2 切别名与构造修复若不同步,registry/mcp 中使用 `OpenaiToolDefinition` struct literal 的构造代码会编译失败 | 别名切换与构造修复合并在同一单元,原子化提交 |
| 遗漏 `#[allow(deprecated)]` | 部分抑制点因 grep 遗漏而未在 6.4 移除 | clippy `-D warnings` 可检出;6.4 前做一次全库 grep 确认无遗漏 |
| JSON 兼容性 | `ToolDef` serde 属性与 `OpenaiToolDefinition` 不一致导致 wire format 变化 | `ToolDef` serde 属性与 `OpenaiToolDefinition` 保持一致;roundtrip 测试验证 |
| Provider 适配遗漏 | 部分 Provider 分支未经测试覆盖 | `cargo test --all-targets` 包含 Provider 测试 |
## 否决记录
| 否决方案 | 原因 |
|---------|------|
| 保留 `strict` 字段 | OpenAI 专属字段,当前所有构造路径传 `None`。不属于 Provider 无关的 IR。未来支持走 `MessageRequest.extra` 逃生舱 |
| 逐步 deprecation 过渡 | pre-1.0 阶段 breaking change 合理,v0.1 已标记 deprecation,用户已有预期 |
| `ToolDef` 建独立文件 | 约 7 行的 struct 不需要独立文件,与 `OpenaiToolDefinition` 共享 `types/tool.rs` 即可 |
| `ToolDef` 放在 `tools/` 模块 | 会创造 `llm` → `tools` 的逆向依赖,破坏模块分层 |
| 添加 builder 模式 | Rust struct literal + `..Default::default()` 已足够覆盖使用场景 |
| 添加 `#[non_exhaustive]` | IR 类型自有完整控制权,不需要对外隐藏字段 |
| 同 Phase 净化 `parameters` 类型化 | 属于独立工作,留给 v0.3+ 阶段处理 |
+526
View File
@@ -0,0 +1,526 @@
# Phase 7 — SqliteStore 持久化实现方案
- **文档编号**14
- **标题**Phase 7 — SqliteStore 持久化实现方案
- **日期**2026-07-05
- **状态**:已定稿
- **涉及模块**memory/store
- **关联文档**roadmap.md, 6-memory-system.md
---
## 背景与目标
Phase 7 的核心任务是完成 MemoryStore trait 的 SQLite 后端实现,使 Agent 进程重启后记忆数据不丢失。这是 v0.2.0 从"内存玩具"走向"可用工具"的关键门槛,也是后续 Phase 8MVP 出口)和 Phase 10ContextSlot)的前置依赖。
**成功标准**
- SqliteStore 完整实现 MemoryStore trait4 个方法:save/get/delete/list
- 进程关闭后重新打开同一数据库文件,数据完整可读
- 与现有 InMemoryStore 通过 MemoryStore trait 可互换,消费者零改动
- 所有现有测试保持通过,clippy 0 警告
### Scope & Non-goals
| 范围 | 内容 |
|------|------|
| 包含 | 单表 CRUD + prefix/since/offset/limit 查询 + WAL 并发 + Mutex 串行化 + 错误映射 |
| 不包含(Phase 7 | 淘汰策略(EvictionPolicy,仅 InMemoryStore 持有,需 v0.3 纳入 SqliteStore |
| 不包含(Phase 7 | Schema 迁移框架(PRAGMA user_version 足矣,不引入 refinery/sea-query |
| 不包含(Phase 7 | 批量写入 / 事务 API(N+1 clear 延迟可接受,优化后置) |
| 不包含(Phase 7) | 跨进程共享同一数据库文件(Mutex 为单进程设计) |
---
## 当前状态分析
### 现有实现
- MemoryStore trait 已在 v0.1 Phase 3 就绪,定义 4 个异步方法
- InMemoryStore 实现稳定运行,使用 `Mutex<HashMap>` 作为后端
- 全量测试 191 个通过,clippy 0 警告
- 项目当前无 SQLite 或其他数据库依赖
### 现有消费者
通过 `crate::memory::store::MemoryStore` 路径引用的模块:
| 模块 | 文件 | 使用方式 |
|------|------|----------|
| Agent Builder | `agent/builder.rs` | RuntimeBundle 中引用 MemoryStore |
| Session Memory | `agent/session_memory.rs` | SessionMemory 实现 |
| Agent Runtime | `agent/runtime.rs` | 类型标注 |
| Agent Session | `agent/session.rs` | 默认 InMemoryStore 兜底 |
| Conversation | `memory/conversation.rs` | ConversationMemory 测试 |
| Knowledge | `memory/knowledge.rs` | KnowledgeStore 测试 |
| Retriever | `memory/retriever.rs` | MemoryRetriever 测试 |
所有消费者均通过 `MemoryStore` trait 访问,不依赖具体实现类型,因此新增 SqliteStore 不会产生编译或运行时影响。
### 目录结构现状
```
src/memory/
├── mod.rs
├── store.rs ← 包含 MemoryStore trait + InMemoryStore + EvictionPolicy
├── conversation.rs
├── knowledge.rs
├── retriever.rs
└── vector.rs
```
`store.rs` 目前是一个单体文件,同时承载 trait 定义和 InMemoryStore 实现。
---
## 调研发现
### MemoryStore trait 定义
```rust
#[async_trait]
pub trait MemoryStore: Send + Sync {
async fn save(&self, item: MemoryItem) -> Result<(), MemoryError>;
async fn get(&self, id: &str) -> Result<Option<MemoryItem>, MemoryError>;
async fn delete(&self, id: &str) -> Result<(), MemoryError>;
async fn list(&self, filter: &MemoryFilter) -> Result<Vec<MemoryItem>, MemoryError>;
}
```
### 关键类型
| 类型 | 定义 |
|------|------|
| `MemoryItem` | `{ id: String, content: String, metadata: Value, created_at: OffsetDateTime }` |
| `MemoryFilter` | `{ prefix: Option<String>, since: Option<OffsetDateTime>, offset: Option<usize>, limit: Option<usize> }` |
| `MemoryError` | 变体:`NotFound` / `Storage` / `Serialization` / `InvalidInput` / `RetrievalError` |
| `EvictionPolicy` | `None` / `Ttl { ttl_secs }` / `Capacity { max_items }` |
| `EvictionConfig` | `{ policy, check_interval }` |
### 并发模型参考
InMemoryStore 当前使用 `Mutex<HashMap>` 实现 `Send + Sync`。SqliteStore 将遵循相同模式,使用 `Arc<Mutex<Connection>>` + `spawn_blocking` 满足异步 trait 约束。
### Schema 设计考虑
- `created_at` 使用 TEXT(ISO 8601) 存储——`.to_string()` 零转换,字典序与时间序一致(前提:所有时间戳归一化到 UTC;`OffsetDateTime::to_string()` 在 UTC 下输出 `"2026-07-05T12:00:00Z"` 格式,字典序与时间序严格对应)
- 初始 schema 即创建 `created_at` 索引,避免后续大数据量全表排序
- Schema 版本管理通过 `PRAGMA user_version` 实现,零外部依赖,后续加字段只需追加 `if version < N { ALTER TABLE }`
---
## 可选方案
### A. rusqlite + Mutex\<Connection\>(推荐)
| 维度 | 评估 |
|------|------|
| 新增依赖 | 1 个(rusqlite 0.32 + bundled features |
| 实现量 | ~200 行 |
| SQL 支持 | 原生支持 prefix LIKE 过滤 + ORDER BY 排序 |
| 性能 | 有索引时查询 O(log n),写入串行化 |
| 并发 | WAL 模式 + Mutex 串行化写入,适合单进程 Agent |
| 事务支持 | 完整 ACID |
| 崩溃安全 | WAL 模式,崩溃恢复有保障 |
**适用场景**:单进程 Agent 本地持久化、嵌入式场景、需要关系查询能力的通用存储。
**外部依赖评估**
- rusqlite 0.32 — 最新稳定版(2025-12 发布),维护活跃(月均 2+ 次提交),Apache-2.0 许可证
- `bundled` feature 编译 SQLite 源码(Public Domain)进二进制,无系统级 SQLite 依赖,零外部 C 库安装步骤
- 供应链风险:bundled 模式依赖 crate 发布节奏同步 SQLite 安全更新;SQLite 安全公告频率极低(年均 <3 例),此风险可接受
### B. JSONL 文件
| 维度 | 评估 |
|------|------|
| 新增依赖 | 0 |
| 实现量 | ~150 行 |
| get() 复杂度 | O(n) 全量扫描 |
| delete() 复杂度 | O(n) 全量重写 |
| 并发 | 需文件锁(flock |
| 事务支持 | 无 |
| 崩溃安全 | 无保障,写入中断可能丢失或损坏数据 |
**否决理由**:核心的 get() 查询场景不可接受 O(n) 性能;在 Agent 运行时频繁读写记忆的场景下,全量扫描的成本会随着数据积累线性增长,不符合可用性要求。
### C. sled 嵌入式 KV
| 维度 | 评估 |
|------|------|
| 新增依赖 | 1 个(纯 Rust |
| 实现量 | ~150 行 |
| prefix scan | 原生支持 |
| 排序 | 需手动实现 |
| 关系模型 | 不如 SQL 匹配当前查询模式 |
| 社区成熟度 | 较新,API 仍在演进 |
**否决理由**:当前查询模式(prefix 过滤 + 按 created_at 排序)在关系模型中用一条 SQL 即可表达,引入 KV 存储反而需要手动处理排序逻辑。非必要不引入新存储范式。
---
## 推荐方案
### 总体方向:方案 Arusqlite + Mutex\<Connection\>
选择理由:
1. **最少依赖,最高匹配**:1 个新增依赖即可完整支持 MemoryFilter 的所有查询维度(prefix LIKE、created_at 范围、offset/limit
2. **生产就绪**rusqlite 是 SQLite 的 Rust 绑定事实标准,bundled 模式免去系统 SQLite 依赖
3. **Schema 演进简单**PRAGMA user_version + 逐版本迁移,零外部迁移工具依赖
4. **与 InMemoryStore 语义一致**Mutex 串行化 + spawn_blocking 适配 async trait,与现有并发模型同构
### 关键设计决策
| 决策 | 选择 | 理由 |
|------|------|------|
| Schema 版本管理 | PRAGMA user_version | 零外部依赖,~20 行,后续 ALTER TABLE 即可 |
| created_at 存储格式 | TEXT(ISO 8601) + UTC 归一化 | 零转换代码;UTC 下输出 `"2026-07-05T12:00:00Z"`,字典序与时间序严格一致 |
| Upsert SQL 策略 | `INSERT ... ON CONFLICT(id) DO UPDATE SET ...` | 保留调用方传入的 `created_at`,避免被 `DEFAULT` 覆盖 |
| 性能索引 | 初始 schema 加 created_at 索引 | 避免大数据量全表排序 |
| 配置参数 | 仅 `path``busy_timeout=5s` | 其余内置默认值;5s 超时避免 `SQLITE_BUSY` 快速失败 |
| Mutex 中毒恢复 | `.lock().unwrap_or_else(\|e\| e.into_inner())` | 不 panic,恢复执行 |
| 批量操作 | 不加 | N+1 clear ~250ms(N=50),可接受,优化后置 |
| spawn_blocking 取消安全性 | 短事务模式(auto-commit) | 每个操作独立事务,取消时后台 task 自然完成/panic,不 Cross 操作持有 Mutex |
**我们放弃了什么**(集中 Trade-off 记录):
- **写入串行化**`Mutex<Connection>` 确保 SQLite 写入安全,代价是同一时刻只能有一个写入者。Agent 场景下写入频率低(每次 LLM 调用触发 1-2 次),串行化不构成瓶颈
- **单进程锁**:无法跨进程共享同一数据库文件。多进程场景需要网络后端(PostgreSQL/Redis
- **无横向扩展**:单文件 SQLite 无分片能力。需扩展时切换到分布式后端
### Schema 定义(初始版本)
```sql
CREATE TABLE IF NOT EXISTS memory_items (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_memory_items_created_at
ON memory_items(created_at);
```
### 数据完整性防御
| 异常场景 | 防御措施 | 错误映射 |
|---------|---------|---------|
| 数据库文件损坏 | `migrate()` 中执行 `PRAGMA quick_check`;失败时 `open()` 返回 `MemoryError::Storage` | `Storage` |
| created_at 解析失败 | `get()`/`list()``OffsetDateTime::parse` 失败不 panic,返回 `MemoryError::Serialization` | `Serialization` |
| content / metadata 为 NULL | `get()` 中检测 SQLite 返回值,NULL 时返回 `MemoryError::Storage` | `Storage` |
| 约束冲突(PRIMARY KEY / NOT NULL | 映射为 `MemoryError::InvalidInput` | `InvalidInput` |
| 序列化/反序列化失败 | `serde_json::to_string`/`from_str` 错误映射为 `MemoryError::Serialization` | `Serialization` |
### 性能预算(目标延迟,单条操作)
| 操作 | 目标延迟 | 说明 |
|------|---------|------|
| `save(1KB item)` | < 5ms | 含 serde_json 序列化 + spawn_blocking + SQLite INSERT |
| `get(1KB item)` | < 3ms | 含 SQLite SELECT + 反序列化 |
| `list(prefix 匹配 100 行)` | < 20ms | 含索引 B-tree 遍历 + ORDER BY + LIMIT |
| 并发 10 writer | p99 < 50ms | Mutex 串行化排队,每 writer 等待 9×5ms 内 |
实施后通过 Step C 测试验证以上预算。未达标时不阻塞发布,但记录为可观测告警阈值。
---
## 实施建议
### 实施计划
#### Step A — 目录重构(纯搬移,零行为变化)
目标:将单体 `store.rs` 拆分为模块目录架构,为新增 SqliteStore 做准备。
```
src/memory/
├── store.rs ← 模块根:MemoryStore trait + EvictionPolicy/EvictionConfig
│ + pub mod in_memory;
│ + pub mod sqlite_store;
│ + pub use in_memory::InMemoryStore;
├── store/
│ ├── in_memory.rs ← InMemoryStore 提取至此(struct + impl + 6 个内联测试)
│ └── sqlite_store.rs ← 新增 SqliteStore
```
模式参考:`llm/provider.rs``llm/provider/{openai,anthropic,ollama}.rs`
**外部消费者的导入路径不变**`crate::memory::store::MemoryStore`),零改动风险。
重构步骤:
1. 创建 `src/memory/store/` 目录
2. 创建 `src/memory/store/in_memory.rs`,从原 `store.rs` 提取 InMemoryStore 全部代码(struct + impl + Default + 6 个测试)
3. 修改 `src/memory/store.rs`:保留 MemoryStore trait + EvictionPolicy/EvictionConfig,加 `pub mod in_memory;` + `pub use in_memory::InMemoryStore;`
4. 验证:`cargo test --all-targets` 全绿,测试数量不变(191 pass)
#### Step B — SqliteStore 实现
1. `Cargo.toml` 添加 `rusqlite = { version = "0.32", features = ["bundled"] }`
2. 创建 `src/memory/store/sqlite_store.rs`,实现:
- `SqliteStore` 结构体:`{ conn: Arc<Mutex<Connection>> }`
- `SqliteStore::open(path)` 构造函数,支持 `":memory:"`
- `lock_conn()` 辅助方法(Mutex 中毒恢复)
- `migrate()` Schema 初始化 + 版本管理
- `MemoryStore` trait 的 4 个方法
- `From<rusqlite::Error> for MemoryError`
3. 修改 `src/memory/store.rs`:加 `pub mod sqlite_store;` + `pub use sqlite_store::SqliteStore;`
4. 修改 `src/memory.rs`:加 `pub use store::SqliteStore;`
5. 编写测试覆盖:
- CRUD 基本操作
- Upsert(同 id 重复 save 覆盖)
- prefix 过滤
- 由于/until 时间范围过滤
- 并发 10 个 writer × 10 次操作
- 持久化恢复(write → drop → reopen → read
6. 验证:`cargo test --all-targets` 全绿 + `cargo clippy --all-targets -- -D warnings` 0 警告
#### Step C — 验证确认
1. 确认现有 memory 模块内测试全部通过
2. 确认 agent/llm/tools/prompt 模块不受影响
3. 确认 SqliteStore 与 InMemoryStore 通过 MemoryStore trait 可互换
4. 确认 clippy 无新增警告
### Commit 安排
| 顺序 | 类型 | Scope | 描述 |
|------|------|-------|------|
| 1 | refactor | memory | 将 store.rs 拆分为模块目录,仅结构搬移 |
| 2 | feat | memory | 实现 SqliteStore 持久化 |
### 风险与缓解
| 风险 | 严重度 | 缓解措施 |
|------|--------|----------|
| Mutex 中毒导致后续操作全部失败 | 中 | `lock_conn()` 使用 `.lock().unwrap_or_else(\|e\| e.into_inner())` 恢复模式,不 panic |
| spawn_blocking 取消后连接状态不一致 | 中 | 每个操作使用短事务(auto-commit),不跨操作持有 Mutex;取消时遗留 task 自然完成或 panicMutex 通过 `.into_inner()` 恢复 |
| WAL 文件无限增长 | 低 | 内置 auto-checkpoint 阈值 + 启动时执行 `PRAGMA wal_checkpoint(TRUNCATE)` |
| list 无索引导致全表扫描 | 中(大数据量) | 初始 schema 即创建 `idx_memory_items_created_at` 索引 |
| 父目录不存在导致 open 失败 | 低 | `open()` 内部调用 `fs::create_dir_all()` 确保目录存在 |
| clear() N+1 删除性能 | 低 | 不走 trait 接口的逐条删除,可后续优化为直接 `DELETE FROM memory_items` |
| 数据库文件损坏 | 低 | `migrate()` 中执行 `PRAGMA quick_check`;失败时返回 `MemoryError::Storage`,调用方可切换 InMemoryStore |
### 可观测性(实施时落实)
- 所有 MemoryStore 方法通过 `tracing::instrument` 记录延迟和结果(`info!` 正常完成,`warn!` 超过性能预算阈值,`error!` 操作失败)
- `list()` 返回行数通过 `tracing::debug` 记录(调优参考)
- WAL 文件大小在 `migrate()` 后检查一次,超过 100MB 时记录 `warn!`
- 操作计数(读写次数、错误率)暂不暴露为独立 metrics,v0.3 按需添加
---
## 已知假设
| 假设 | 验证状态 | Fallback |
|------|---------|----------|
| 单进程独享 SQLite 文件,无跨进程竞争 | ✅ 设计前提(Mutex 为单进程设计) | 多进程场景使用网络后端(PostgreSQL/Redisv0.3+ |
| ISO 8601 TEXT 字典序等价于时间序 | ✅ 条件成立(需 UTC 归一化) | 若时区异常,切换 INTEGER(unix_timestamp) 存储后重建索引 |
| SqliteStore 初始化失败可 fallback 到 InMemoryStore | ✅ 调用方自行控制 | `open()` 返回 `MemoryError`,消费者 catch 后改用 `InMemoryStore::new()` |
| N+1 clear ~250ms(N=50) 可接受 | 🟡 未实测(基于 N×5ms 推算) | 若成为瓶颈,SqliteStore 内部加 `delete_by_prefix()` 方法(不走 trait 接口) |
| rusqlite bundled SQLite 版本足够新 | ✅ 0.32 版内置 SQLite 3.46 | 如需特定版本,切换 `bundled` 为指定版本或使用系统 SQLite |
| busy_timeout=5s 覆盖所有竞争场景 | 🟡 未实测(WAL 下写写冲突概率低) | 若观测到 `SQLITE_BUSY`,增大超时或在重试逻辑中处理 |
| spawn_blocking 线程池不会被耗尽 | ✅ 默认 512 线程,Agent 场景占用 ≤10 | 若观测到阻塞任务排队,启动时 `tokio::task::spawn_blocking` 已有兜底排队机制 |
---
## 参考来源
- [rusqlite crate](https://crates.io/crates/rusqlite) — 官方文档
- [SQLite PRAGMA user_version](https://www.sqlite.org/pragma.html#pragma_user_version) — Schema 版本管理机制
- [SQLite WAL mode](https://www.sqlite.org/wal.html) — 并发读写性能优化
- `docs/6-memory-system.md` — Phase 3 MemoryStore trait 原始设计
- `docs/roadmap.md` — 项目里程碑规划(Phase 7/8/10 依赖关系)
- `src/llm/provider.rs``src/llm/provider/` — 目录重构模式参考
---
## 实施计划
### 任务总览
3 个阶段、8 个任务单元、2 个 Commit。
### 阶段一:目录重构
#### Task A1 — 创建 store/ 目录并提取 InMemoryStore
| 项目 | 内容 |
|------|------|
| 任务描述 | 创建 `src/memory/store/` 目录,新建 `store/in_memory.rs`,从 `store.rs` 完整提取 InMemoryStore 结构体、impl MemoryStore、impl Default、6 个内联测试 |
| 涉及文件 | `src/memory/store.rs` → 分割到 `src/memory/store/in_memory.rs`(新增) |
| 前置依赖 | 无 |
| 预估工作量 | S< 1h |
| 风险等级 | 低 — 纯搬移,编译器可验证 |
| 验收条件 | `cargo build` 通过(此时 store.rs 尚未修改,store/in_memory.rs 应被 crate 忽略) |
注意:需要先在 store.rs 顶部添加 `pub mod in_memory;` 声明,否则子模块不会被编译。或者可以先创建目录和文件,等 Task A2 再统一加声明路径。
实际做法:先创建文件但不声明,A2 统一声明。这样 A1 和 A2 之间可以有一个无编译的中间状态。
#### Task A2 — 修改 store.rs 模块根
| 项目 | 内容 |
|------|------|
| 任务描述 | 修改 `store.rs` 为纯模块根:保留 `MemoryStore` trait、`EvictionPolicy``EvictionConfig`;添加 `pub mod in_memory;` + `pub use in_memory::InMemoryStore;`;删除已提取到 in_memory.rs 中的代码 |
| 涉及文件 | `src/memory/store.rs`(修改) |
| 前置依赖 | Task A1(文件已存在) |
| 预估工作量 | S< 1h |
| 风险等级 | 低 — 保留部分不变,提取部分在子模块中 |
| 验收条件 | `cargo test --all-targets` 全绿,测试数量不变(191 pass),clippy 0 warning |
#### Task A3 — 验证阶段一
| 项目 | 内容 |
|------|------|
| 任务描述 | 运行全量测试链确认目录重构零行为变化 |
| 涉及文件 | 全量 |
| 前置依赖 | Task A2 |
| 预估工作量 | XS(验证) |
| 风险等级 | 低 |
| 验收条件 | `cargo test --all-targets` 191 pass、`cargo clippy --all-targets -- -D warnings` 0 警告、`cargo build` 通过 |
### 阶段二:SqliteStore 实现
#### Task B1 — 添加 rusqlite 及 dev-dependencies
| 项目 | 内容 |
|------|------|
| 任务描述 | 在 `Cargo.toml` 中添加依赖:`[dependencies]``rusqlite = { version = "0.32", features = ["bundled"] }``[dev-dependencies]``tempfile = "3"`(用于测试隔离);运行 `cargo build` 确认编译通过,`cargo test --no-run` 验证 dev-dependencies 可用 |
| 涉及文件 | `Cargo.toml`(修改)、`Cargo.lock`(自动更新) |
| 前置依赖 | 无(可与阶段一并行) |
| 预估工作量 | XS< 15min |
| 风险等级 | 低 — 标准依赖添加 |
| 验收条件 | `cargo build` 成功,`cargo test --no-run` 成功,Cargo.lock 中生成 rusqlite 和 tempfile 条目 |
#### Task B2 — 实现 SqliteStore 核心
| 项目 | 内容 |
|------|------|
| 任务描述 | 创建 `src/memory/store/sqlite_store.rs`,实现以下 8 个子模块: |
| | 1`SqliteStore` 结构体 `{ conn: Arc<Mutex<Connection>> }` |
| | 2`SqliteStore::open(path)` — 支持 `":memory:"`,内部调用 `fs::create_dir_all` 确保父目录存在 |
| | 3`lock_conn()` — 内部辅助方法,`.lock().unwrap_or_else(\|e\| e.into_inner())` 处理 Mutex 中毒 |
| | 4`migrate()` — 按版本递增执行迁移:`PRAGMA user_version` 检查(初始版本号=1)→ 建表 `memory_items` + 索引 `idx_memory_items_created_at` + 设置 WAL 模式 + `busy_timeout=5s` + `PRAGMA synchronous = NORMAL` + `PRAGMA wal_autocheckpoint=1000` + `PRAGMA quick_check`(检测数据库损坏)+ `PRAGMA wal_checkpoint(TRUNCATE)` |
| | 5`MemoryStore` trait 的 4 个方法实现(save/get/delete/list),全部使用 spawn_blocking 包裹;**生命周期注意**:`spawn_blocking` 闭包前先 `.conn.clone()``Arc<Connection>`,参数调 `.clone()` 取 owned 值,再传入 `spawn_blocking(move \|{ ... })` |
| | — `save`: `INSERT INTO memory_items (id, content, metadata, created_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(id) DO UPDATE SET content=excluded.content, metadata=excluded.metadata, created_at=excluded.created_at`(全字段覆盖 upsert,与 InMemoryStore 行为一致) |
| | — `get`: `SELECT content, metadata, created_at FROM memory_items WHERE id = ?1` → Ok(None) 当无结果 |
| | — `delete`: `DELETE FROM memory_items WHERE id = ?1`(幂等,不返回 NotFound |
| | — `list`: 根据 filter 字段(prefix/since/offset/limit)组合动态构造 WHERE 子句 + `ORDER BY created_at ASC` + `LIMIT ? OFFSET ?`,全部使用参数化查询防注入 |
| | 6)序列化转换层:`time::OffsetDateTime` 存为 TEXT(ISO 8601),通过 `.to_string()` 绑定 `String` 参数;`serde_json::Value` 存为 TEXT,通过 `serde_json::to_string()` 绑定 `String` 参数;读取时通过 `OffsetDateTime::parse``serde_json::from_str` 反序列化。在 SqliteStore 内部实现 `to_sql_params()` / `from_sql_row()` 辅助方法集中处理 |
| | 7)错误映射:不在 blanket impl From 中处理所有 rusqlite Error,而是在每个方法内部按数据完整性防御表的映射规则逐类处理: |
| | — 数据库文件损坏 / IO 错误 → `MemoryError::Storage` |
| | — created_at 解析失败 → `MemoryError::Serialization`(不 panic |
| | — content/metadata 为 NULL → `MemoryError::Storage` |
| | — serde_json 序列化/反序列化失败 → `MemoryError::Serialization` |
| | — 约束冲突 → `MemoryError::InvalidInput` |
| | (8)可观测性:在 4 个 trait 方法和 `open()` 上添加 `#[tracing::instrument(skip(self))]`;正常完成记录 `trace!`,超过性能预算阈值记录 `warn!`,操作失败记录 `error!` |
| 涉及文件 | `src/memory/store/sqlite_store.rs`(新增) |
| 前置依赖 | Task B1rusqlite + tempfile 依赖)、Task A1store/ 目录存在) |
| 预估工作量 | M1-4h |
| 风险等级 | 高 — 3 个技术点需留意:`time::OffsetDateTime``rusqlite::ToSql` 实现,需显式处理 String 绑定;`spawn_blocking` + `&self` 生命周期需 clone 后才能传闭包;错误映射需精细匹配 `rusqlite::Error` 嵌套变体(`SqliteFailure` 内含 `ErrorCode` |
| 验收条件 | 单元测试通过(见 Task B4)、`cargo build` 通过 |
#### Task B3 — 注册模块并重导出
| 项目 | 内容 |
|------|------|
| 任务描述 | 在 `store.rs` 添加 `pub mod sqlite_store;` + `pub use sqlite_store::SqliteStore;`;在 `memory.rs` 添加 `pub use store::SqliteStore;` |
| 涉及文件 | `src/memory/store.rs`(修改)、`src/memory.rs`(修改) |
| 前置依赖 | Task B2sqlite_store.rs 文件存在) |
| 预估工作量 | XS< 15min |
| 风险等级 | 低 |
| 验收条件 | `cargo build` 通过,`SqliteStore` 可从 `agcore::memory::SqliteStore` 路径访问 |
#### Task B4 — 编写测试
| 项目 | 内容 |
|------|------|
| 任务描述 | 在 `sqlite_store.rs` 中编写 `#[cfg(test)] mod tests`,覆盖: |
| | 1CRUD 基本操作(save → get → list → delete → get None |
| | 2Upsert 语义(同 id 重复 save 覆盖内容,created_at 保持调用方传入值) |
| | 3prefix 过滤(MemoryFilter.prefix |
| | 4)时间范围过滤(MemoryFilter.since |
| | 5offset/limit 分页 |
| | 6)并发 10 个 writer × 10 次写入,验证无数据丢失 |
| | 7)持久化恢复(write → drop store → reopen 同一文件 → read |
| | 8)错误路径:`open("/nonexistent_dir/ag.db")` 返回 Storage 错误 |
| | 辅助函数:`make_item(id)` 创建 MemoryItem,使用 `tempfile::TempDir`(或自定义 tmp 路径)隔离测试数据库文件 |
| 涉及文件 | `src/memory/store/sqlite_store.rs`(修改追加 test mod |
| 前置依赖 | Task B2(实现完成) |
| 预估工作量 | M1-4h |
| 风险等级 | 中 — 并发测试的时序控制、持久化恢复测试的 TempDir 管理 |
| 验收条件 | 全量测试通过,新增测试 ≥ 8 个 |
#### Task B5 — 验证阶段二
| 项目 | 内容 |
|------|------|
| 任务描述 | 运行全量测试链确认 SqliteStore 实现正确,不影响已有模块 |
| 涉及文件 | 全量 |
| 前置依赖 | Task B3、Task B4 |
| 预估工作量 | S< 1h |
| 风险等级 | 低 |
| 验收条件 | `cargo test --all-targets` 全绿(191 + 新增测试)、`cargo clippy --all-targets -- -D warnings` 0 警告、`cargo build` 通过 |
### 阶段三:验证收尾
#### Task C1 — 跨模块兼容性验证
| 项目 | 内容 |
|------|------|
| 任务描述 | 1)确认 `ConversationMemory` / `KnowledgeStore` / `MemoryRetriever` / `AgentSession` / `SessionMemory``Arc<dyn MemoryStore>` 接受 SqliteStore 时编译通过且测试全绿 |
| | 2)确认 SqliteStore 与 InMemoryStore 可互换——修改一个现有测试将后端从 InMemoryStore 换为 SqliteStore(使用 `":memory:"`),测试全绿 |
| | (3)验证性能预算:在测试环境下测量 save(1KB)/get(1KB)/list(100行) 的单次延迟,确认 < 5ms / < 3ms / < 20ms |
| 涉及文件 | 测试文件(memory/ 模块内各 test mod |
| 前置依赖 | Task B5 |
| 预估工作量 | S< 1h |
| 风险等级 | 低 |
| 验收条件 | 全量测试通过 + 互换测试通过 + 性能预算大致满足(未达标不阻塞发布) |
### 依赖关系图
```
阶段一(目录重构) 阶段二(SqliteStore 实现)
┌──────────────┐ ┌──────────────┐
│ Task A1 │ │ Task B1 │ ← 无依赖,可与 A 并行
│ 创建目录+提取 │ │ Cargo.toml │
└──────┬───────┘ └──────┬───────┘
↓ ↓
┌──────────────┐ ┌──────────────┐
│ Task A2 │ │ Task B2 │
│ 修改store.rs │← A1 ───→│ 核心实现 │← B1 + A1
└──────┬───────┘ └──────┬───────┘
↓ ↓
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Task A3 │ │ Task B3 │← B2 ──→│ Task B4 │
│ 验证阶段一 │ │ 注册+重导出 │ │ 编写测试 │
└──────────────┘ └──────┬───────┘ └──────┬───────┘
↓ ↓
┌──────────────┐────────────────┘
│ Task B5 │← B3 + B4
│ 验证阶段二 │
└──────┬───────┘
阶段三(验证收尾)
┌──────────────┐
│ Task C1 │
│ 跨模块兼容性 │
└──────────────┘
```
### Commit 安排
| 顺序 | Commit 类型 | Scope | 描述 | 包含 Task |
|------|------------|-------|------|-----------|
| 1 | refactor | memory | 将 store.rs 拆分为模块目录,仅结构搬移 | A1 → A2 → A3 |
| 2 | feat | memory | 实现 SqliteStore 持久化(含错误映射、测试、WAL 模式) | B1 → B2 → B3 → B4 → B5 → C1 |
注意:Task B1 与阶段一无依赖,可以在 Commit 1 合并进行或在 Commit 2 开头。建议在 Commit 2 开头,因为 Cargo.toml 变更属于功能变更而非重构。
### 验证全链
实施完毕后整体认证链路:
1. `cargo test --all-targets` — 全量测试通过
2. `cargo clippy --all-targets -- -D warnings` — 0 警告
3. `cargo build --release` — release 构建通过
4. 确认 `cargo doc --no-deps` 无 warning(新增公开类型文档注释)
5. 确认测试数量:191 + (8 个 new sqlite_store tests) = 199+ pass
+543
View File
@@ -0,0 +1,543 @@
# Phase 8 — MVP 集成出口实现方案
- **文档编号**15
- **标题**Phase 8 — MVP 集成出口实现方案
- **日期**2026-07-05
- **状态**:已定稿
- **涉及模块**:全局(llm/types、agent、tools、memory、prompt、examples
- **关联文档**roadmap.md(§Phase 8)、14-phase7-sqlite-store.md
---
## 1. 背景与目标
Phase 5-7 已交付 P0 功能闭环:ProviderConfig `from_env()`Phase 5)、ToolDef IR 正式化(Phase 6)、SqliteStore 持久化(Phase 7)。当前 200 个测试全绿、clippy 0 警告,但缺乏一个"可被人依赖"的集成出口。
Phase 8 的目标是完成 API 稳定性扫尾 + Quick Start 示例 + 端到端示例,产出 **v0.2.0-rc.1** 标签。三个 Step 分别对应三类用户群体:
| Step | 受众 | 交付物 |
|------|------|--------|
| **8.1** | 存量升级者(v0.1 → v0.2 | API 稳定性扫尾 + CHANGELOG |
| **8.2** | 新用户评估者("30 秒决定要不要用" | Quick Start 示例 |
| **8.3** | 技术决策者("这框架能跑真实场景吗") | 端到端集成示例 |
---
## 2. 当前状态
| 度量 | 数值 |
|------|------|
| `cargo test --all-targets` | ✅ 200 passed / 0 failed |
| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 |
| 已存在 `#[non_exhaustive]` 枚举 | 4 个(StopReason / FinishReason / EvictionPolicy / ProviderType |
| 已存在 `#[deprecated]` 项 | 3 个(ChatResponse / ToolDefinition / task_agent_demo 中旧类型使用) |
| 已有示例 | 8 个 |
| `StepStatus::Completed` 使用类型 | `ChatResponse`(已 `#[deprecated]` |
### 2.1 关键技术债
```
// agent/task.rs —— StepStatus 当前使用已废弃类型
#[allow(deprecated)]
pub enum StepStatus {
Completed(ChatResponse), // ← ChatResponse 已在 0.1.0 标记 #[deprecated]
...
}
```
`task_agent_demo.rs` 中同时使用了 `ChatResponse` / `OpenaiChatMessage` / `FinishReason` 三个废弃类型,入口处有 `#![allow(deprecated)]`
---
## 3. 实施方案
### 3.1 Step 8.1 — API 稳定性扫尾
拆为 4 个增量 commit
| Commit | 内容 | 涉及文件 |
|--------|------|---------|
| **commit 1** | 14 个公开枚举追加 `#[non_exhaustive]` | 各枚举定义文件(详见 §3.1.1) |
| **commit 2** | `StepStatus::Completed(ChatResponse)``Completed(MessageResponse)` + `task_agent_demo.rs` 清理全部 3 个废弃类型(`ChatResponse` / `OpenaiChatMessage` / `FinishReason`),移除 `#![allow(deprecated)]` | `src/agent/task.rs``examples/task_agent_demo.rs` |
| **commit 3** | CHANGELOG v0.2 条目 + Cargo.toml version → `0.2.0-rc.1` + README 更新 | `CHANGELOG.md``Cargo.toml``README.md` |
| **commit 4** | 验证:`cargo test + clippy + cargo doc` 零告警 | 无代码改动 |
#### 3.1.1 `#[non_exhaustive]` 追加清单(14 个枚举)
按优先级分级:
| 优先级 | 枚举 | 模块路径 | 理由 |
|--------|------|---------|------|
| **P0 核心** | `Message` | `llm/types/message.rs` | 核心 IR 类型,未来可能新增变体(MultiModal 扩展) |
| | `ContentBlock` | `llm/types/message.rs` | 同上 |
| | `ContentBlockType` | `llm/types/message.rs` | 同上 |
| | `StreamEvent` | `llm/types/response_v2.rs` | 流式事件集,Provider 扩展可能新增事件 |
| | `HookEvent` | `llm/hooks.rs` | 生命周期钩子,框架扩展需要新增事件点 |
| **P0 Error** | `AgentError` | `agent/error.rs` | 顶层错误,下游 match 需保护 |
| | `LlmError` | `llm/error.rs` | LLM 调用错误 |
| | `ToolError` | `tools/error.rs` | 工具系统错误 |
| | `MemoryError` | `memory/error.rs` | 记忆系统错误 |
| | `PromptError` | `prompt/error.rs` | 提示词工程错误 |
| **P1 其他** | `MemoryStrategy` | `memory/conversation.rs` | 对话策略,未来可扩展(如 Summarize) |
| | `StepStatus` | `agent/task.rs` | 步骤状态机,可扩展(如 Cancelled) |
| | `ToolChoice` | `llm/types/request.rs` | Provider 工具选择策略 |
| | `ResponseFormat` | `llm/types/shared.rs` | 响应格式枚举 |
**明确不加的**
| 类别 | 枚举 | 原因 |
|------|------|------|
| 内部 wire-format | `OpenaiChatMessage` / `OpenaiTool` / `OpenaiToolCall` / `ContentField` / `OpenaiContentPart` / `LegacyStreamEvent` | 内部转换层,不构成公共 API 契约 |
| 语义稳定 | `Role` / `ServiceTier` / `Modality` / `ImageDetail` / `AudioFormat` / `StopSequence` | 语义已收敛,协议层无新增变体预期 |
| 使用面窄 | `TemplateValue` / `Permission` / `McpTransport` / `ContentBlockBuilder` / `ExtraError` | 内部实现细节或使用频率极低,下游不直接 match |
> **`#[non_exhaustive]` 的不可逆性**:一旦 v0.2.0-rc.1 发布,以下游代码可能依赖 `_ =>` 通配分支。在 v0.3+ 中移除 `#[non_exhaustive]` 将构成 semver breaking change(新增变体不再触发编译警告,下游 match 可能遗漏新变体),因此当前追加的标记应视为永久 API 契约。
#### 3.1.2 StepStatus 迁移细节
```rust
// 变更前
#[allow(deprecated)]
pub enum StepStatus {
Completed(ChatResponse), // ChatResponse 已 #[deprecated]
...
}
// 变更后
#[non_exhaustive]
pub enum StepStatus {
Completed(MessageResponse),
...
}
```
**字段映射差异**`MessageResponse` 不是 `ChatResponse` 的简单改名——两者结构不同,迁移需要做字段适配:
| ChatResponse 字段 | 类型 | MessageResponse 字段 | 类型 | 映射方式 |
|-------------------|------|---------------------|------|---------|
| `message` | `OpenaiChatMessage` | `message` | `Message` | 类型替换:`OpenaiChatMessage::assistant_text(t)``Message::Assistant { content: vec![ContentBlock::Text { text: t.into() }] }` |
| `usage` | `Usage` | `usage` | `Usage` | ✅ 同类型,直接迁移 |
| `stop_reason` | `Option<FinishReason>` | `stop_reason` | `StopReason` | 类型替换:`Some(FinishReason::Stop)``StopReason::Stop`;无 Option 包裹 |
| — | — | `id` | `String` | 新增必填字段,使用空字符串 `""` 占位 |
| — | — | `model` | `String` | 新增必填字段,使用 `"mock"` 或空字符串占位 |
| — | — | `extra` | `HashMap<String, Value>` | 新增字段,使用 `HashMap::new()` 占位 |
**迁移示例**`task_agent_demo.rs` 的构造代码):
```rust
// 旧代码(3 个废弃类型)
StepStatus::Completed(ChatResponse {
message: OpenaiChatMessage::assistant_text("天气:晴,22°C"),
usage: Usage::from_input_output(10, 5),
stop_reason: Some(FinishReason::Stop),
})
// 新代码(纯 MessageResponse
StepStatus::Completed(MessageResponse {
id: String::new(),
model: "mock".into(),
message: Message::assistant("天气:晴,22°C"),
usage: Usage::from_input_output(10, 5),
stop_reason: StopReason::Stop,
extra: HashMap::new(),
})
```
涉及文件:
- `src/agent/task.rs`:枚举定义 + `#[allow(deprecated)]` 移除 + `#[non_exhaustive]` 追加
- `examples/task_agent_demo.rs``ChatResponse{...}``MessageResponse{...}` 构造替换,同时替换 `OpenaiChatMessage` / `FinishReason` 引用,移除 `#![allow(deprecated)]`
### 3.2 Step 8.2 — Quick Start 示例
| 属性 | 值 |
|------|-----|
| 文件 | `examples/quick_start.rs` |
| 规模 | ~36 行 |
| Provider | `MockProvider`FIFO 单响应队列) |
| 工具 | `EchoTool`(回传 `"收到: {input}"`,完整 JSON Schema 参数声明) |
| 执行 | `submit_turn("你好")` → 验证输出包含 `"收到"` |
| 验证 | `cargo run --example quick_start` exit 0 |
设计要点:
- 展示四层抽象:Agent trait / BaseTool 自定义 / AgentBuilder 装配 / AgentSession 执行
- 无外部依赖、无 API key、零配置
### 3.3 Step 8.3 — 端到端示例
| 属性 | 值 |
|------|-----|
| 文件 | `examples/end_to_end.rs` |
| 规模 | ~160 行(**最小可行边界**:3 工具 + 3 轮 + 持久化验证,防止实施中进一步膨胀) |
| Provider | 自动检测 `AG_LLM_*``from_env()`fallback 到 `MockProvider` |
| 工具组合 | EchoTool(回显)+ CalcTool(四则运算,本地执行)+ NoteTool(笔记,通过 MemoryStore trait 操作 SessionMemory |
| 持久化 | `tempfile::TempDir` + `SqliteStore`,drop 后重建连接验证数据不丢 |
| 对话 | 3 轮:计算 → 记笔记 → 回忆 |
| 验证 | `cargo run --example end_to_end` exit 0(无需任何外部配置) |
**真实 Provider 切换**:示例在文件顶部注释中说明 "设置 `AG_LLM_BASE_URL` / `AG_LLM_API_KEY` / `AG_LLM_MODEL` 环境变量即可使用真实 LLM Provider(支持 OpenAI / Ollama 等);未设置时自动降级为 MockProvider,零配置可运行。"
**`from_env()` 部分环境变量策略**`from_env()` 要求完整的三件套(`{prefix}_BASE_URL` + `{prefix}_API_KEY` + `{prefix}_MODEL`)。当环境变量部分设置时,示例**整体降级到 MockProvider**——不在"半配置"状态下尝试部分初始化。日志输出形如 `"AG_LLM_* 环境变量不完整(检测到: {found_vars}),回退到 MockProvider"`
架构亮点:
```
┌─────────────────────────┐
│ AgentSession │
│ (submit_turn × 3) │
└────┬──────┬──────┬──────┘
│ │ │
┌────┘ │ └──────┐
▼ ▼ ▼
┌──────────┐ ┌────────┐ ┌──────────┐
│ EchoTool │ │CalcTool│ │ NoteTool │
│ (回显) │ │(四则) │ │ (记忆) │
└──────────┘ └────────┘ └────┬─────┘
┌──────▼──────┐
│ SessionMemory│
│ (MemoryStore)│
└──────┬──────┘
┌──────▼──────┐
│ SqliteStore │
│ (temp dir) │
└─────────────┘
```
NoteTool 展示 `MemoryStore` trait 解耦能力:不绑定 SqliteStore,上层 `AgentSession` 通过 `SessionMemory` 操作,底层可互换。
---
## 4. 否决项记录
| 否决方案 | 否决原因 |
|---------|---------|
| `#[non_exhaustive]` 仅加 5 个核心类型 | 全面覆盖 Error enums 为零运行时成本,对下游更友好。Error 枚举是下游 match 最密集的地方,漏标会在 v0.3 引入 breakage |
| StepStatus::Completed 留到 v0.3 再修 | rc.1 前清理 deprecated 类型污染最划算——越晚 migration cost 越高,且当前仅 1 个示例 + 1 个测试引用 |
| Quick Start 纯文本路线(不展示自定义工具) | 含 EchoTool 展示核心差异化,仅多 5 行代码但传递了"可以自定义工具"的关键信息 |
| 端到端仅 Echo + Calc(无 NoteTool | NoteTool 展示 MemoryStore trait 解耦能力是架构亮点,跳过后新用户无法理解 memory 如何集成到 Agent 流程 |
| 持久化仅注释说明不实际运行(方案 Y) | 进程内实操验证(create → drop → reopen → assert)比注释更有说服力,增加约 15 行代码 |
---
## 5. 关键假设
1. **MockProvider FIFO 队列满足 auto-tool-loop 消费顺序**MockProvider 的 `pop()` 按预设顺序弹出。当 LLM 返回多个 tool call 时队列消费顺序与预设一致,无需额外同步
2. **StepStatus 切换需做字段适配**`ChatResponse`(3 字段) 到 `MessageResponse`(6 字段) 存在字段类型差异(`message` 类型不同、`stop_reason` 类型 + Option 有无不同、`id`/`model`/`extra` 为新增必填字段),消费者需按字段映射表提供占位值。但消费者仅 1 个(`task_agent_demo.rs`)+ 1 个内联测试,手动适配工作量极小。`StepStatus``is_terminal()` / `is_pending()` 行为不受影响
3. **所有 10 个示例零外部配置 exit 0**:已有 8 个示例已验证,新增 2 个(quick_start + end_to_end)均使用 MockProvider fallback,无需 API key
4. **`#[non_exhaustive]` × 14 不触发额外 clippy warning**:当前无代码对以上枚举做 exhaustive match(不含 `_`),追加 `#[non_exhaustive]` 是纯安全标记
---
## 6. 实施顺序与验证标准
### 6.1 提交顺序
```
Step 8.1 (4 commits)
→ commit 1: #[non_exhaustive] × 14
→ commit 2: StepStatus 修复(Completed(ChatResponse) → Completed(MessageResponse)
→ commit 3: CHANGELOG v0.2 + Cargo.toml version 0.2.0-rc.1 + README 更新
→ commit 4: 验证(test / clippy / doc 零告警)
Step 8.2
→ commit 5: examples/quick_start.rs~36 行)
Step 8.3
→ commit 6: examples/end_to_end.rs~160 行)
最终验证
→ cargo test --all-targets
→ cargo clippy --all-targets -- -D warnings
→ cargo doc --no-deps
→ git tag v0.2.0-rc.1
```
### 6.2 验收标准
| 指标 | 要求 |
|------|------|
| `cargo test --all-targets` | 全绿 |
| `cargo clippy --all-targets -- -D warnings` | 0 警告 |
| `cargo doc --no-deps` | 0 warning |
| 所有 10 个示例 | `cargo run --example <name>` exit 0 |
| Cargo.toml version | `0.2.0-rc.1` |
| CHANGELOG | v0.2 条目完整(Added / Changed / Deprecated / Fixed / Removed 各节) |
| README | 示例列表 + 版本号更新 |
| git tag | `v0.2.0-rc.1` |
---
## 7. 参考来源
- **roadmap.md** — Phase 8 原始定义(Step 8.1/8.2/8.3)、依赖关系(Phase 5/6/7 → Phase 8
- **`src/agent/task.rs`** — `StepStatus` 当前实现,`Completed(ChatResponse)` 类型
- **`src/llm/types/message.rs`** — `Message` / `ContentBlock` / `ContentBlockType` 枚举定义
- **`src/llm/types/response_v2.rs`** — `StreamEvent` / `StopReason` 枚举定义(StopReason 已有 `#[non_exhaustive]`
- **`src/llm/types/shared.rs`** — `ResponseFormat` / `Role` / `FinishReason` 等枚举(FinishReason 已有 `#[non_exhaustive]`
- **`src/llm/types/request.rs`** — `ToolChoice` 枚举定义
- **`src/llm/hooks.rs`** — `HookEvent` 枚举定义
- **`src/llm/error.rs`** — `LlmError` 枚举定义
- **`src/agent/error.rs`** — `AgentError` 枚举定义
- **`src/tools/error.rs`** — `ToolError` 枚举定义
- **`src/memory/error.rs`** — `MemoryError` 枚举定义
- **`src/memory/conversation.rs`** — `MemoryStrategy` 枚举定义
- **`src/prompt/error.rs`** — `PromptError` 枚举定义
- **`examples/task_agent_demo.rs`** — 当前使用 `#[allow(deprecated)]` + `ChatResponse` 的示例
---
## 8. 实施计划
### 8.1 实施步骤
#### Step 8.1 — API 稳定性扫尾
拆为 4 个增量 commit,依次提交。
##### commit 1: #[non_exhaustive] × 14
| 属性 | 值 |
|------|-----|
| 涉及文件 | 14 个枚举定义所在文件(见下方清单) |
| 前置依赖 | 无 |
| 预估工作量 | S<1h |
| 风险等级 | 低 |
在每个目标枚举定义处的 `pub enum` 之前加一行 `#[non_exhaustive]`,纯文本属性追加,无逻辑变更。
| 目标枚举 | 文件路径 | 行号附近 |
|---------|---------|---------|
| `Message` | `src/llm/types/message.rs` | `pub enum Message` (L22) |
| `ContentBlock` | `src/llm/types/message.rs` | `pub enum ContentBlock` (L99) |
| `ContentBlockType` | `src/llm/types/message.rs` | `pub enum ContentBlockType` (L134) |
| `StreamEvent` | `src/llm/types/response_v2.rs` | `pub enum StreamEvent` (L167) |
| `HookEvent` | `src/llm/hooks.rs` | `pub enum HookEvent` (L9) |
| `AgentError` | `src/agent/error.rs` | `pub enum AgentError` (L20) |
| `LlmError` | `src/llm/error.rs` | `pub enum LlmError` (L10) |
| `ToolError` | `src/tools/error.rs` | `pub enum ToolError` (L6) |
| `MemoryError` | `src/memory/error.rs` | `pub enum MemoryError` (L8) |
| `PromptError` | `src/prompt/error.rs` | `pub enum PromptError` (L3) |
| `MemoryStrategy` | `src/memory/conversation.rs` | `pub enum MemoryStrategy` (L14) |
| `StepStatus` | `src/agent/task.rs` | `pub enum StepStatus` (L59) |
| `ToolChoice` | `src/llm/types/request.rs` | `pub enum ToolChoice` (L14) |
| `ResponseFormat` | `src/llm/types/shared.rs` | `pub enum ResponseFormat` (L70) |
> **注意**`StepStatus` 在 commit 2 中会同时被修改(variant 类型替换 + 移除 `#[allow(deprecated)]`)。commit 1 仅追加 `#[non_exhaustive]` 属性,commit 2 再处理变体变更和清理。
**验收条件**`cargo build --all-targets` 通过
##### commit 2: StepStatus 修复 + 废弃类型清理
| 属性 | 值 |
|------|-----|
| 涉及文件 | `src/agent/task.rs``examples/task_agent_demo.rs` |
| 前置依赖 | commit 1StepStatus 先标记 `#[non_exhaustive]`,此处改 variant 时一并保留,无实际冲突) |
| 预估工作量 | S<1h,约 20 行改动) |
| 风险等级 | 低 |
两步操作:
1. **`src/agent/task.rs`**L59-L71):
- `StepStatus::Completed(ChatResponse)``Completed(MessageResponse)`
- 移除 `#[allow(deprecated)]`(第 13、59 行两处)
2. **`examples/task_agent_demo.rs`**
- 替换 3 个废弃类型:`ChatResponse``MessageResponse``OpenaiChatMessage::assistant_text(t)``Message::assistant(t)``FinishReason::Stop``StopReason::Stop`
- 补充 `id: String::new()``model: "mock".into()``extra: HashMap::new()` 占位字段
- 移除 `#![allow(deprecated)]`(第 26 行)
- 移除 `use` 中的 `ChatResponse``OpenaiChatMessage``FinishReason`
- 添加 `use std::collections::HashMap``use agcore::llm::types::{Message, MessageResponse, StopReason}`(注意:`Message::assistant_text(t)` 不存在,需使用 `Message::assistant(t)`)
字段映射参见 §3.1.2 的字段映射表和迁移示例。
**验收条件**`cargo build --all-targets` 通过,零 deprecated warning
##### commit 3: CHANGELOG + 版本号 + README
| 属性 | 值 |
|------|-----|
| 涉及文件 | `CHANGELOG.md``Cargo.toml``README.md` |
| 前置依赖 | commit 1+2CHANGELOG 需记录实际变更) |
| 预估工作量 | S<1h |
| 风险等级 | 低 |
1. **`CHANGELOG.md`**:新增 `[0.2.0-rc.1]` 条目,包含:
- **Added**SqliteStore 持久化 / OllamaProvider / ProviderConfig::from_env / ToolDef IR / Quick Start 和 end_to_end 示例
- **Changed**MessageRequest.tools 切换 ToolDef / StepStatus::Completed 类型替换
- **Deprecated**ChatResponse / with_system_prompt() / with_client()
- **Non-exhaustive**14 个枚举标记清单
2. **`Cargo.toml`**:第 3 行 `version = "0.1.0"``version = "0.2.0-rc.1"`
3. **`README.md`**:更新示例列表从 7 个改为 10 个(含新增 2 个),版本号同步
**验收条件**:人工 review CHANGELOG + `git diff` 确认版本号
##### commit 4: 验证
| 属性 | 值 |
|------|-----|
| 涉及文件 | 无代码改动 |
| 前置依赖 | commit 3 |
| 预估工作量 | S(<1h,主要等待编译) |
| 风险等级 | 低 |
运行三条命令:
```bash
cargo test --all-targets
cargo clippy --all-targets -- -D warnings
cargo doc --no-deps 2>&1 | grep "^warning:" && echo "WARNINGS FOUND" || echo "0 warnings"
```
**验收条件**:前两条 0 错误,第三条输出 `0 warnings`
#### Step 8.2 — Quick Start 示例
##### commit 5: examples/quick_start.rs
| 属性 | 值 |
|------|-----|
| 涉及文件 | `examples/quick_start.rs` |
| 前置依赖 | 无(可从 Phase 7 独立创建) |
| 预估工作量 | S<1h |
| 风险等级 | 低 |
新文件 `examples/quick_start.rs`~36 行,结构如下:
```
1- 6 use 块(agcore 类型 + Arrow/std 类型)
7- 8 struct Greeter + impl Agentname / system_prompt
9-14 struct EchoTool + #[async_trait] impl BaseTool(完整 JSON Schema 带 text 参数)
15-20 fn mock_response() -> MessageResponse 辅助函数(构造纯文本响应)
21-33 #[tokio::main] async fn main():
- ToolRegistry::new() + register EchoTool
- MockProvider 预设 1 条 mock_response
- AgentBuilder::new() + provider + tool_registry + hook_executor → build
- AgentSession::new + submit_turn("你好")
- println!("{}", response.text())
```
**设计约束**
- EchoTool 的 `parameters()` 返回完整 JSON Schema`{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}`
- 无外部依赖、无 API key、零配置
- 展示四层抽象:Agent trait / BaseTool 自定义 / AgentBuilder 装配 / AgentSession 执行
**验收条件**`cargo run --example quick_start` exit 0,输出包含 `"收到"`
#### Step 8.3 — 端到端示例
##### commit 6: examples/end_to_end.rs
| 属性 | 值 |
|------|-----|
| 涉及文件 | `examples/end_to_end.rs` |
| 前置依赖 | commit 5(示例编写模式已建立);SqliteStorePhase 7 已完成) |
| 预估工作量 | M1-4h |
| 风险等级 | 中 |
新文件 `examples/end_to_end.rs`,~160 行,最小可行边界(3 工具 + 3 轮 + 持久化验证)。
**Provider 初始化策略**
```
if env::var("AG_LLM_BASE_URL").is_ok() && env::var("AG_LLM_API_KEY").is_ok() {
// 使用真实 ProviderAG_LLM_MODEL 非必填,from_env 内部会处理默认值)
let provider: Arc<dyn LlmProvider> = Arc::from(create_provider(
ProviderType::OpenaiChat, ProviderConfig::from_env("AG_LLM").unwrap()
)?);
} else {
// MockProvider fallback,预设 4 条响应序列
let found = ["AG_LLM_BASE_URL", "AG_LLM_API_KEY"].iter()
.filter(|k| env::var(k).is_ok()).collect::<Vec<_>>();
eprintln!("AG_LLM_* 环境变量不完整(检测到: {:?}),回退到 MockProvider", found);
}
```
**工具定义**
| 工具 | 功能 | 关键技术点 |
|------|------|-----------|
| `EchoTool` | 回显输入 | 基础工具注册模式 |
| `CalcTool` | 本地执行四则运算 | 手动解析算术表达式(ponytail:基础 +-*/ 运算无需引入 `rhai` 依赖) |
| `NoteTool` | 通过 MemoryStore trait 读写笔记 | 直接持有 `Arc<dyn MemoryStore>`key 前缀 `"note:"`save 用 `MemoryStore::save(MemoryItem { id: "note:{key}", content, .. })`query 用 `MemoryStore::list(MemoryFilter { prefix: Some("note:"), .. })` |
**持久化验证**
```rust
let dir = tempfile::TempDir::new()?;
let db_path = dir.path().join("agcore.db");
let backend = Arc::new(SqliteStore::open(&db_path)?);
// ... 构建 RuntimeBundle + AgentSession,写入数据 ...
drop(bundle); // 释放所有对 backend 的 Arc 引用
drop(session);
// 此时 backend 无活跃引用,SQLite 连接自动关闭
let backend2 = Arc::new(SqliteStore::open(&db_path)?); // 重建连接
// assert 数据仍在
```
**输出示范**
```
=== agcore 端到端演示 ===
🔄 Provider: MockProvider (离线回退模式)
💾 SqliteStore: /tmp/agcore_XXXXX/agcore.db
🔧 注册工具: echo, calc, note
第 1 轮 用户: 帮我算 25 * 4
→ 调用 calc(...) → 100
→ 回答: 25 * 4 = 100
第 2 轮 用户: 记下来:结果是 100
→ 调用 note(save, ...)
→ 回答: 已记录
第 3 轮 用户: 我刚才算了什么?
→ 调用 note(query)
→ 回答: 您刚才的计算结果是 100
📊 用量: prompt=XX, completion=XX
=== 持久化验证 ===
✓ 跨连接数据存活验证通过
✓ 端到端演示完成
```
**设计约束**
- 文件顶部注释说明 `AG_LLM_*` 环境变量切换真实 Provider
- 零外部配置可运行(Mock fallback
- 最小可行边界:3 工具 + 3 轮 + 持久化验证,不膨胀
**验收条件**`cargo run --example end_to_end` exit 0(零外部配置)
### 8.2 并行机会
commit 1 和 commit 5 可以并行执行(零文件重叠)。commit 5 也可与 commit 2 并行。commit 6 实质上也仅依赖「代码库状态稳定」而非某个具体 commit。
| 并行组 | commit A | commit B | 前提 |
|--------|---------|---------|------|
| 1 | commit 1#[non_exhaustive] | commit 5Quick Start | 零文件重叠 |
| 2 | commit 2StepStatus 修复) | commit 5Quick Start | 零文件重叠 |
| 3 | commit 5Quick Start | commit 6(端到端) | 零文件重叠,但存在知识依赖——commit 6 需参考 commit 5 的 `MessageResponse` 构造、`MockProvider` 用法、`AgentBuilder` 装配模式。推荐 commit 5 先行或实施前同步这些模式 |
### 8.3 风险与应对
| 风险 | 影响 | 可能性 | 应对 |
|------|------|--------|------|
| MockProvider 响应序列与 tool-loop 消费顺序不匹配 | commit 6 端到端示例不通过 | 中 | 按 §5 假设 1:设计响应队列时确保每条 Mock 响应的 `stop_reason` 与 ToolUse/Stop 匹配。出现不匹配时改用完整 `MessageResponse` 构造显式控制 |
| NoteTool 与 AgentSession 的数据传递路径需要扩展现有 API | commit 6 需要修改 `session.rs` | 低 | ponytail 方案:NoteTool 直接持有 `Arc<dyn MemoryStore>` 引用,在 execute 时直接操作 `MemoryStore::save/get`,绕过 AgentSession 的 session_memory 封装 |
| `#[non_exhaustive]` 在某个 enum 上导致 crate 内 match 编译失败 | commit 1 不通过 | 低 | 实施前先运行 `rg "match.*(Message|ContentBlock|ContentBlockType|StreamEvent|HookEvent|AgentError|LlmError|ToolError|MemoryError|PromptError|MemoryStrategy|StepStatus|ToolChoice|ResponseFormat)" src/ --include="*.rs"` 快速扫描 exhaustive match。若某 enum 编译失败,回退该 enum 上的 `#[non_exhaustive]` 属性,标注原因 |
### 8.4 测试策略
| commit | 测试 | 方式 |
|--------|------|------|
| commit 1 | 编译测试 | `cargo build --all-targets` |
| commit 2 | 编译 + 单测 + 无 deprecated warning | `cargo build --all-targets && cargo test` |
| commit 3 | 人工 review | `git diff` |
| commit 4 | 全量自动化 | `cargo test + clippy + doc` |
| commit 5 | 示例运行 | `cargo run --example quick_start` |
| commit 6 | 示例运行 | `cargo run --example end_to_end` |
| 最终 | 全量回归 | 全部三项 + 所有 10 个示例 |
@@ -0,0 +1,827 @@
# Phase 9 — 流式体验增强实施方案
- **文档编号**16
- **标题**:Phase 9 — 流式体验增强实施方案
- **日期**2026-07-05
- **状态**:已定稿
- **涉及模块**llm/cycle、llm/types/response_v2、agent/session
- **关联文档**roadmap.md(§Phase 9)、15-phase8-mvp-integration.md
---
## 1. 背景与目标
agcore 已发布 v0.2.0-rc.1Phase 0-8 全部完成。当前 Agent 会话只有非流式 API(`submit_turn`),开发者无法看到实时 token 输出和工具执行过程。Phase 9 的目标是为 `AgentSession` 新增流式方法 `submit_turn_stream`,让开发者能实时看到 LLM token 生成和工具执行状态。
### 1.1 现有能力
| 能力 | 方法 | 流式 | 自动工具循环 | 状态 |
|------|------|------|-------------|------|
| LLM 流式请求 | `LlmCycle::submit_stream` | ✅ | ❌ | 已就绪 |
| LLM 工具循环 | `LlmCycle::submit_with_tools` | ❌ | ✅ | 已就绪 |
| Agent 会话 | `AgentSession::submit_turn` | ❌ | ✅ | 已就绪 |
| 流事件枚举 | `StreamEvent`(11 变体) | — | — | 缺工具执行事件 |
| Mock 流 | `MockProvider::chat_stream` | ✅ | — | 可模拟流事件序列 |
### 1.2 核心矛盾
流式能力和工具循环能力分别存在于两个方法中,从未被组合。`submit_stream` 只管将 LLM 流事件原样转发,不理解工具调用;`submit_with_tools` 自动执行工具循环但全程阻塞。Phase 9 就是要组合它们:**在工具循环中,每一轮 LLM 调用都是流式的,并在工具执行前后插入语义事件**。
---
## 2. 需求分析
### 2.1 功能需求
1. **`AgentSession::submit_turn_stream(user_input)`** — 返回 `StreamEvent` 流,开发者通过 `while let Some(event) = stream.next().await` 逐事件消费
2. **流式工具循环** — 多轮工具调用过程中流不卡死,每轮工具执行前后插入 `ToolExecutionStarted` / `ToolExecutionCompleted` 事件
3. **`finalize_turn(response)`** — 流消费完成后同步 session 状态(cost 累计 + `OnTurnEnd` hook 触发)
4. **新增 `StreamEvent` 变体**`ToolExecutionStarted` + `ToolExecutionCompleted`,携带工具名称、调用 ID、参数/结果摘要
### 2.2 非功能需求
- **零影响**:现有 `submit_turn``submit_with_tools` 行为不变,存量测试 0 回归
- **异步流**:消费者通过 `futures_util::StreamExt::next()` 逐事件消费
- **错误事件化**:错误通过 `StreamEvent::Error` 事件表达,不通过 `Result` 通道终止流
- **最少代码**:复用现有 `submit_with_tools` 的工具循环逻辑模式和 `submit_stream` 的流管道模式
### 2.3 不做事项
| 事项 | 理由 |
|------|------|
| 新增示例(Phase 9.2 再加) | 缩窄 Phase 9 范围至核心能力 |
| `OnTurnEnd` 自动触发 | Rust 所有权约束:流是延迟求值,`&mut self` 无法进入闭包;由消费者收到 `MessageComplete` 后手动调用 `finalize_turn` |
| 修复 cost 统计 | 中间轮 cost 丢失是已知限制,与 `submit_turn` 行为一致 |
| 跨 turn 消息历史保留 | Phase 10 `ContextSlot` 的职责 |
| 并行 tool 调用的事件细化 | 当前工具调用是顺序 `for` 循环,并行化留待后续优化 |
| `run_tool_loop` 内消息压缩 | `run_tool_loop` 不接收 `compact_config` 参数,不执行上下文压缩。长工具循环中消息增长可能导致 context window 溢出,这是流式实现的已知限制。后续可通过传递 `compact_config``run_tool_loop` 支持 |
| LLM 请求自动 retry | 流式版本不在 `run_tool_loop` 内部实现 retry(详见 §3.6 说明)。调用方可自行包装 `RetryProvider` 或在 `LlmProvider` 实现层处理 |
---
## 3. 方案设计
### 3.1 架构总览
```
┌──────────────────────────────────────────────────────────────┐
│ AgentSession │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ submit_turn_stream() │ │
│ │ ├─ OnTurnStart hook(同步触发,返回流之前) │ │
│ │ ├─ 组装 LlmCyclesystem_prompt / compact_config │ │
│ │ ├─ 调用 submit_with_tools_stream() │ │
│ │ ├─ turn_index += 1 │ │
│ │ └─ 返回流 │ │
│ │ │ │
│ │ finalize_turn(response) │ │
│ │ ├─ cost_so_far.add(&response.usage) │ │
│ │ └─ OnTurnEnd hookturn_index - 1 │ │
│ └──────────────────────────────────────────────────────┘ │
│ submit_with_tools_stream(prompt, Arc<ToolRegistry>)
┌──────────────────────────────────────────────────────────────┐
│ LlmCycle (tokio::spawn task — run_tool_loop 状态机) │
│ │
│ max_turns = max_tool_turns.unwrap_or(10) │
│ for round in 1..=max_turns { │
│ ① build_request(messages, tools) │
│ ② provider.chat_stream(request).await │
│ 匹配 Err → tx.send(Error{..}) + return(不 panic
│ ③ 消费 LLM 流,所有事件 → mpsc unbounded tx(全量转发) │
│ ④ partial.finalize() → MessageResponse │
│ ⑤ if has_tool_use: │
│ ├─ tx → ToolExecutionStarted { tool_name, id, args } │
│ ├─ registry.invoke_all(calls, timeout).await │
│ ├─ for result: tx → ToolExecutionCompleted { ... } │
│ ├─ push tool results → messages │
│ └─ continue(新一轮) │
│ else: break(最终轮,已发出 MessageComplete
│ } │
│ │
│ 产出事件序列(通过 mpsc::unbounded_channel): │
│ MessageStart → ... → ToolCallEnd → ToolExecutionStarted → │
│ ToolExecutionCompleted → MessageStart → TextDelta → ... → │
│ CostUpdate → MessageComplete │
└──────────────────────────────────────────────────────────────┘
```
> **`max_tool_turns` 语义**:与非流式 `submit_with_tools` 一致——`None` 退化为 `10``unwrap_or(10)`)。默认值 `Some(10)` 已提供安全上限;如需增大限制,手动设置为 `Some(N)`。⚠️ 生产环境建议始终设有限值防止无限循环。
### 3.2 事件序列约定
**纯文本流**(无 tool_use):
```
MessageStart → ContentBlockStart → TextDelta* → ContentBlockEnd → CostUpdate → MessageComplete
```
**单轮工具调用**
```
MessageStart → ContentBlockStart → TextDelta* → ContentBlockEnd
→ ContentBlockStart → ToolCallArgumentsDelta* → ToolCallEnd
→ CostUpdate → MessageComplete { stop_reason: ToolUse }
→ ToolExecutionStarted → [工具执行] → ToolExecutionCompleted
→ ContentBlockStart → TextDelta* → ContentBlockEnd
→ CostUpdate → MessageComplete { stop_reason: Stop }
```
**多轮工具调用**
```
... → ToolExecutionCompleted(第 1 轮)
→ ToolCallArgumentsDelta* → ToolCallEnd
→ ToolExecutionStarted → ToolExecutionCompleted(第 2 轮)
→ ... → CostUpdate → MessageComplete(最终轮)
```
**工具不可恢复错误**
```
... → ToolCallEnd → ToolExecutionStarted
→ Error { "tool 'search' 不可恢复错误: ..." } → MessageComplete
```
> **`MessageComplete.full_response` 内容范围**:每轮 LLM 调用独立产生一个 `MessageComplete`,其中 `full_response` 仅包含**该轮 LLM 的单个响应**(不累积前面工具轮次的结果)。中间轮(`stop_reason: ToolUse`)的 `full_response` 通常只包含 `ToolUse` block,无文本。最终轮(`stop_reason: Stop`)的 `full_response` 包含 LLM 的最终输出。消费者如需追踪完整对话历史,应自行累加所有轮次的 `Message`。
### 3.3 StreamEvent 新增变体
`src/llm/types/response_v2.rs``StreamEvent` 枚举中追加两个变体:
```rust
/// 工具开始执行 —— 在 ToolCallEnd 之后、registry.invoke 之前发出。
/// 让 UI 层可以显示 "正在执行工具:add(1, 2)"。
ToolExecutionStarted {
tool_name: String,
tool_call_id: String,
/// 工具参数(JSON 字符串形式)
arguments: String,
},
/// 工具执行完成 —— 在工具返回后、新一轮 LLM 流开始之前发出。
ToolExecutionCompleted {
tool_name: String,
tool_call_id: String,
/// 结果摘要(前 200 字符)
result_summary: String,
/// 是否出错
is_error: bool,
},
```
`PartialMessageResponse::apply_to` 中追加:
```rust
StreamEvent::ToolExecutionStarted { .. } | StreamEvent::ToolExecutionCompleted { .. } => true,
```
这两个是**元事件**,不参与内容块累积,`apply_to` 直接返回 `true`
### 3.4 新增方法签名
**`LlmCycle` 层**`src/llm/cycle.rs`):
```rust
/// 提交消息并自动处理工具调用循环,流式产出所有事件。
///
/// 与 `submit_with_tools` 的区别:
/// - LLM 响应是流式的(全程 `chat_stream` 而非 `chat`
/// - 工具执行前后插入 `ToolExecutionStarted` / `ToolExecutionCompleted` 事件
/// - 错误以 `StreamEvent::Error` 形式出现在流中,而非终止 `Result`
/// - 消费方需手动 `push_message()` 同步消息历史
///
/// **运行时要求**:内部使用 `tokio::spawn`,需要 tokio 多线程运行时。
pub async fn submit_with_tools_stream(
&mut self,
prompt: String,
tool_registry: Arc<ToolRegistry>,
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError>
```
```rust
/// 运行工具循环的核心异步状态机。
///
/// 接收 owned 字段,通过 mpsc::unbounded_channel 产出事件序列。
/// 由 `submit_with_tools_stream` 在 tokio::spawn 中调用。
///
/// **运行时要求**:此函数内部使用 `tokio::spawn`,要求调用方运行在
/// tokio 多线程运行时中(`#[tokio::main]` 或 `#[tokio::test(flavor = "multi_thread")]`)。
/// 不在 WASM 目标下可用。
async fn run_tool_loop(
messages: Vec<Message>,
provider: Arc<dyn LlmProvider>,
config: CycleConfig,
tool_registry: Arc<ToolRegistry>,
tools: Vec<ToolDef>,
tx: mpsc::UnboundedSender<StreamEvent>,
hook_executor: Option<Arc<HookExecutor>>,
)
```
**`AgentSession` 层**`src/agent/session.rs`):
```rust
/// 提交一轮对话(流式,含自动 tool 循环),返回 `StreamEvent` 流。
///
/// 与 `submit_turn` 的区别:
/// - 以流事件序列而非 `MessageResponse` 返回
/// - 工具执行期间插入 `ToolExecutionStarted` / `ToolExecutionCompleted` 事件
/// - 消费方在收到 `MessageComplete` 后需手动调用 `finalize_turn` 同步状态
///
/// **运行时要求**:内部委托 `submit_with_tools_stream`,需要 tokio 多线程运行时。
pub async fn submit_turn_stream(
&mut self,
user_input: impl Into<String>,
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, AgentError>
/// 完成一轮 turn:累计 cost + 触发 OnTurnEnd hook。
///
/// 由消费者在收到 `MessageComplete.full_response` 后调用。
pub async fn finalize_turn(&mut self, response: &MessageResponse)
```
> **实施偏差(Phase 10 适配)**:实际签名扩展为
> `pub async fn finalize_turn(&mut self, response: &MessageResponse, new_messages_from_cycle: Vec<Message>) -> Result<(), AgentError>`。
> - `new_messages_from_cycle`:本轮新增消息(`[user_input, ...tool_results, final_response]`),由消费者在流消费完毕后从 `cycle.messages()[input_len..]` 提取并传入;`finalize_turn` 增量追加到当前 slot(不覆盖已有消息)。
> - 返回 `Result<(), AgentError>`:错误传播更清晰,与 `submit_turn` 的 slot 边界错误(`SlotReadonly` / `SlotNotFound`)对齐。
> - Phase 10 ContextSlot 实施时扩展。Phase 9 消费者若不接入 slot 持久化,可传 `vec![response.message.clone()]` 兜底。
### 3.5 消费者使用模式
```rust
use futures_util::StreamExt;
let mut stream = session.submit_turn_stream("计算 1+2").await?;
let mut final_response = None;
while let Some(event) = stream.next().await {
match &event {
StreamEvent::TextDelta { text } => print!("{}", text),
StreamEvent::ToolExecutionStarted { tool_name, arguments, .. } => {
println!("\n🔧 [{}({})]", tool_name, arguments);
}
StreamEvent::ToolExecutionCompleted { result_summary, .. } => {
println!("{}", result_summary);
}
StreamEvent::MessageComplete { full_response } => {
final_response = Some(full_response.clone());
}
_ => {}
}
}
std::io::stdout().flush().ok();
if let Some(response) = final_response {
session.finalize_turn(&response).await;
}
```
> **⚠️ 消费者注意**`finalize_turn` 是开发者责任 —— 遗漏调用会导致 `cost_so_far` 不累计、`OnTurnEnd` hook 不触发。session 状态仍然可用,后续 `submit_turn` 也能正常执行,但 cost 信息不完整。`finalize_turn` 无自动补偿机制,建议使用 `Drop` guard 或在 `while` 循环的 `finally` 块中确保调用。
### 3.6 run_tool_loop 核心逻辑
`run_tool_loop` 是此方案的核心状态机(约 90 行),其伪代码逻辑如下:
```
1. 接收 owned 字段:messages, provider, config, tool_registry, tools, tx, hook_executor
2. max_turns = config.max_tool_turns.unwrap_or(10)
// None → 10(退化为默认值),Some(n) → n
// 与非流式 submit_with_tools 行为一致
3. 工具循环(for round in 1..=max_turns):
a. build_request(messages, tools)
// 空 tool_registry 时 tools 为空列表,流退化为纯文本流(可安全运行)
b. PreRequest hook(如果有 hook_executor
c. 发起流式 LLM 调用:
let stream = match provider.chat_stream(request).await {
Ok(s) => s,
Err(e) => {
// 第一层错误:chat_stream 自身失败(网络/认证/限流)
// 这里不做 retryretry 逻辑留给上层循环的 submit_request 模式,
// 流式场景中 retry 需重新建立 mpsc 通道,复杂度与收益不匹配
tx.send(StreamEvent::Error { message: e.to_string() }).ok();
return; // 直接结束 task
}
};
d. 消费 LLM 流:
- PartialMessageResponse::new()
- while let Some(result) = stream.next().await
- match result:
Ok(event) → apply_to + tx.send(event)
Err(e) → tx.send(Error { message }) + break
// 第二层错误:stream 内部事件错误(如 chunk 解析失败)
e. partial.finalize()? → response
f. push response.message → messages
g. 检查 has_tool_calls_in_response(&response)
h. 如果没有 tool_use: break(最终轮,流已自然结束)
i. 如果有 tool_use:
- extract_tool_calls_from_response(&response)
- tx.send(ToolExecutionStarted { tool_name, tool_call_id, arguments })
- registry.invoke_all(calls, tool_timeout).await
- for result in results:
tx.send(ToolExecutionCompleted { tool_name, tool_call_id, result_summary, is_error })
- push tool results → messages
- continue(新一轮 LLM 流)
4. 流结束(tokio::spawn 自然退出)
```
> **关于 LLM 请求 retry**:非流式 `submit_with_tools` 内部通过 `submit_request` 的 retry 循环处理临时错误。流式版本 `run_tool_loop` **不在内部实现 retry**。原因:(1)retry 需要重新建立 mpsc 通道和事件流上下文,复杂度与收益不匹配;(2)`unbounded_channel` 已发出的事件无法撤回。如果需要 retry 语义,调用方应在上层做 fallback 策略,或在 `llm provider` 实现层完成 retry(如 `RetryProvider` 包装器)。
**错误处理**
| 场景 | 行为 |
|------|------|
| LLM 请求失败(`chat_stream` 返回 `Err` | `tx.send(Error { message })` + `return` 结束 task。**不做 retry**(见上方说明) |
| LLM 流内事件错误(stream Item 的 `Err` | `tx.send(Error { message })` + `break` 结束当轮流,终止循环 |
| 可恢复工具错误(`is_recoverable() == true` | 作为 tool result 回传 LLM,流继续,不出 Error 事件 |
| 不可恢复工具错误(`is_recoverable() == false` | `tx.send(Error { message })` + 终止循环 |
| 工具超时(`tokio::time::timeout` | 视为不可恢复,`tx.send(Error)` + 终止 |
| 最大工具循环轮次超限 | `tx.send(Error { "达到最大工具循环轮次" })` + 终止 |
| spawn task 内部 panic | 由于 `JoinHandle` 不保存(detached),panic 由 tokio 运行时静默捕获;消费者看到 stream 直接结束(返回 `None`),无 `Error` 事件。建议在 `run_tool_loop` 内部避免 `unwrap()`,所有可失败路径通过 `Result` + `?` 传播 |
### 3.7 修改文件清单
| # | 文件 | 改动 | 估算行数 |
|---|------|------|---------|
| 1 | `llm/types/response_v2.rs` | +2 `StreamEvent` 变体 +2 `apply_to` arm | ~20 |
| 2 | `llm/cycle.rs` | +`submit_with_tools_stream` 方法 + `run_tool_loop` 模块函数 | ~140 |
| 3 | `llm/cycle.rs` | `CycleConfig``#[derive(Clone)]` | ~1 |
| 4 | `agent/session.rs` | +`submit_turn_stream` + `finalize_turn` | ~70 |
| — | **测试**(内联) | 4 个场景测试(纯度本、单轮、多轮、超限) | ~150 |
| | **合计** | | **~380** |
> 注:`RetryConfig` 已标注 `#[derive(Debug, Clone)]`,无需额外修改。
---
## 4. 实现计划
按 5 个 Step 增量实施,每步可独立编译和测试。
### Step 1 — 基础设施准备
**目标**:数据层就绪,为流事件新增变体和配置 Clone 奠基。
**改动**
- `llm/types/response_v2.rs`
- `StreamEvent` 枚举追加 `ToolExecutionStarted` / `ToolExecutionCompleted` 变体
- `PartialMessageResponse::apply_to` 追加两个新变体的 arm(均返回 `true`
- `llm/cycle.rs`
- `CycleConfig``#[derive(Clone)]`(所有字段为基础类型 + `RetryConfig`
**验证**`cargo build` 通过
### Step 2 — `LlmCycle::submit_with_tools_stream` 核心
**目标**:实现流式工具循环的核心状态机,这是整个 Phase 9 的技术关键。
**改动**
- `llm/cycle.rs`
- 新增 `run_tool_loop()` 模块函数(约 90 行),基于 `mpsc::unbounded_channel` 通信
- 新增 `submit_with_tools_stream()` 公开方法,入口参数为 `prompt` + `Arc<ToolRegistry>`
- 内部 `tokio::spawn` 启动 `run_tool_loop`,返回 `rx` 端作为 `dyn Stream`
**验证**`cargo build` 通过
### Step 3 — 单元测试(LlmCycle 层)
**目标**:验证 `submit_with_tools_stream` 在 8 个核心场景下的行为和事件序列正确性(含 §8 Step 3 扩展的工具错误路径)。
**新增**`llm/cycle.rs` 内联测试 `#[cfg(test)]`):
| 场景 | Mock 响应序列 | 验证点 |
|------|---------------|--------|
| 1 — 纯文本流 | 1 个 text 响应 | 事件序列与 `submit_stream` 一致;无 `ToolExecutionStarted`/`ToolExecutionCompleted` |
| 2 — 单轮工具调用 | 2 个响应:tool_use → text | 包含 `ToolExecutionStarted` + `ToolExecutionCompleted`;最终 `stop_reason``Stop` |
| 3 — 多轮工具调用 | 4 个响应:3 × tool_use → 1 × text | 3 对 `ToolExecutionStarted`/`ToolExecutionCompleted`;消息历史长度正确 |
| 4 — 最大轮次超限 | 3 个 tool_use 响应,`max_tool_turns: Some(2)` | 流中出现 `Error` 事件;消息历史停在第 2 轮 |
**验证**`cargo test` 全部通过
### Step 4 — `AgentSession` 层包装
**目标**:为 `AgentSession` 新增流式会话接口,保持与 `submit_turn` 一致的行为语义。
**改动**
- `agent/session.rs`
- `submit_turn_stream(user_input)` — 触发 `OnTurnStart` hook → 组装 `LlmCycle` → 调用 `submit_with_tools_stream``turn_index += 1` → 返回流
- `finalize_turn(response)``cost_so_far.add(&response.usage)` → 触发 `OnTurnEnd` hook
**验证**`cargo build` 通过
### Step 5 — 集成测试 + 扫尾
**目标**:端到端验证 `submit_turn_stream` + `finalize_turn` 的完整链路,确保零回归。
**新增**`agent/session.rs` 内联测试,2026-07-08 实施审查补全):
- `submit_turn_stream_end_to_end``submit_turn_stream` 跑通 mock provider → 消费流(验证收到 TextDelta + MessageComplete`finalize_turn``cost_so_far` 正确更新(`prompt_tokens=10, completion_tokens=5` + `turn_index=1` + default slot 包含 user/assistant 消息
- `submit_turn_stream_triggers_turn_hooks` — 验证 `OnTurnStart``submit_turn_stream` 返回流之前已触发(计数=1+ `OnTurnEnd``finalize_turn` 之前**不**触发(计数=0+ `finalize_turn``OnTurnEnd` 触发(计数=1
**验证**
```bash
cargo test --all-targets # 全绿,存量测试 0 回归
cargo clippy --all-targets -- -D warnings # 0 警告
```
---
## 5. 运行细节
### 5.1 `run_tool_loop` 的 spawn 生命周期
#### 执行模型:立即执行 vs 惰性流
`submit_with_tools_stream` 采用 **立即执行** 模型(`tokio::spawn` + `mpsc`),这与 `submit_stream`**惰性执行**`async_stream::stream!` 宏,消费者首次 `next()` 时才触发 LLM 调用)不同。
**选择理由**:工具循环是 **不确定轮次的** —— 每个工具执行的结果可能影响后续 LLM 调用。惰性流无法表达这种"边消费边控制"的语义。通过 `tokio::spawn` 将工具循环移到独立 task 中运行,使得:
- 消费者可以随时开始消费(不丢失事件)
- 工具循环在后台独立运行,不受消费者消费节奏影响
- `mpsc::unbounded_channel` 作为事件缓冲区,解耦生产者与消费者
**对消费者的影响**`submit_with_tools_stream().await?` 返回时,工具循环可能已经开始执行(事件已开始写入 channel)。消费者应尽快开始 `while let Some(event) = stream.next().await`,避免 channel 缓冲过多事件。如果在返回流后长时间不消费,事件会堆积在 mpsc buffer 中(内存开销,无阻塞风险 —— 见 §6 风险表)。
#### 生命周期
```
submit_with_tools_stream()
├─ mpsc::unbounded_channel() → (tx, rx)
├─ messages.push(user_text(prompt))
├─ compact check
├─ tokio::spawn(run_tool_loop(messages, provider, config, ..., tx))
└─ return Box::pin(rx) as dyn Stream
[用户消费 stream]
└─ while let Some(event) = rx.recv().await { yield event }
[用户 drop rx / 结束循环]
└─ rx 被 drop → tx.send() 返回 Err
→ run_tool_loop 检测到 tx.closed()
→ break → task 自然终止
```
#### JoinHandle 与 panic 处理
`run_tool_loop``JoinHandle` 在 spawn 后**不保存**detached pattern)。panic 由 tokio 运行时捕获并通过 `tracing::error` 记录:
```rust
// submit_with_tools_stream 内部
tokio::spawn(async move {
run_tool_loop(..., tx).await;
});
```
如果 `run_tool_loop` 内部发生 panic(如 `unwrap()`),tokio 的 `spawn` 会静默吞掉 panic 并终止 task。消费者此时看到 stream 直接返回 `None`,不会收到 `StreamEvent::Error`。实际编码中应避免 `unwrap()`,所有 `Result` 使用 `?``match` 处理。
Rx 侧实现 `Stream` trait:使用 `tokio_stream::wrappers::UnboundedReceiverStream` 包装 `mpsc::UnboundedReceiver`,因为 `mpsc::UnboundedReceiver` 本身不实现 `Stream``tokio-stream = "0.1"` 已在 `Cargo.toml` 中存在)。
### 5.2 消息历史同步
`submit_with_tools_stream` 内部由 `run_tool_loop` 管理 `messages` 的拷贝,不会写入 `self.messages`。消费方在收到 `MessageComplete` 后需手动:
```rust
let response = full_response.clone();
cycle.push_message(response.message.clone());
```
`AgentSession::submit_turn_stream` 中,由于流是延迟求值且 `&mut self` 无法进入 spawn 闭包,消息历史同步交由消费方在 `finalize_turn` 前自行决定。当前方案中 `submit_turn_stream` **不自动同步消息历史**,这与 `submit_stream` 的已有行为一致(ponytail: Phase 2 FIX-E 注释)。
---
## 6. 风险评估
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| `&mut self` 约束导致流内无法访问 session 状态 | 中 | 复用 `submit_stream` 已有模式:方法体内读取 `self` 后构建 owned 数据,spawn 闭包不捕获 `&mut self` |
| spawn task 生命周期管理 | 低 | 用户 drop rx → `tx.send` 返回 `Err``run_tool_loop` 自然终止 |
| spawn task panic 静默丢失 | 中 | `run_tool_loop` 内部使用 `match`/`?` 避免 `unwrap()``JoinHandle` 不做 `await`detached),panic 由 tokio 运行时记录日志。消费者看到 stream 提前结束(收到 `None`)但无 Error 事件 |
| 中间轮 cost 不累加到 `cost_so_far` | 低 | 与现有 `submit_turn` 行为一致(仅最终轮计入),标记为已知限制,不在此 Phase 修复 |
| 工具循环中 hook 可用性 | 低 | `PreRequest`/`PostRequest` hook 通过 `hook_executor.clone()` 进入 spawn taskhook 在 `run_tool_loop` 循环内触发 |
| `run_tool_loop` 不支持消息压缩 | 中 | 长工具循环中消息不断增长,可能超出 context window。当前不传递 `compact_config`,后续可扩展 `run_tool_loop` 签名增添此参数 |
| `unbounded_channel` 在消费慢于生产时内存增长 | 低 | LLM 流式输出天然有节流(token 生成速度远慢于 CPU 处理速度),消费者通常快于生产者。后续如需背压可切换为 `mpsc::channel(N)` + backpressure |
| 流式版本不做 LLM retry | 低 | 非流式 `submit_with_tools` 通过 `submit_request` 的 retry 循环处理临时错误。流式版本中 retry 需重建 mpsc 通道,复杂度不匹配。调用方可使用 `RetryProvider` 包装器或在 Provider 层实现 retry |
| 执行模式与 `submit_stream` 不一致(立即 vs 惰性) | 低 | `submit_stream` 的惰性语义不适配需要后台执行的工具循环。消费者应在 `submit_turn_stream` 返回后尽快消费流事件 |
| `tokio::spawn` 要求 tokio 多线程运行时 | 低 | agcore 已依赖 tokio,涉及 IO 的 API 均使用 async。`#[tokio::test]` 单线程运行时不支持 `spawn`,测试中将 `run_tool_loop` 提取为可独立调用的函数,测试不走 spawn 直接调用 |
| `CycleConfig``Clone` 影响现有代码 | 无 | 纯配置 struct,所有字段是基础类型或已 `Clone``RetryConfig` |
---
## 7. 验收标准
| # | 验收项 | 验证方式 |
|---|--------|---------|
| 1 | `cargo build --all-targets` 通过 | ✅ 编译器无错误 |
| 2 | `submit_with_tools_stream` 纯文本流事件序列正确 | 单元测试验证:事件类型、顺序与 `submit_stream` 一致 |
| 3 | `submit_with_tools_stream` 单轮工具调用事件序列正确 | 单元测试验证:含 `ToolExecutionStarted` / `ToolExecutionCompleted` |
| 4 | `submit_with_tools_stream` 多轮工具调用事件序列正确 | 单元测试验证:多对 `ToolExecutionStarted`/`ToolExecutionCompleted` |
| 5 | `submit_with_tools_stream` 最大轮次超限产生 Error 事件 | 单元测试验证:流中出现 `StreamEvent::Error` |
| 6 | `submit_turn_stream` + `finalize_turn` 端到端链路 | 集成测试验证:cost 更新 + hook 触发 |
| 7 | `cargo test --all-targets` 全绿,存量测试 0 回归 | ✅ 无回归 |
| 8 | `cargo clippy --all-targets -- -D warnings` 0 警告 | ✅ 无警告 |
| 9 | 现有 `submit_turn` / `submit_with_tools` / `submit_stream` 行为零影响 | ✅ 存量测试通过 |
---
---
## 8. 实施计划
按 5 个 Step 分阶段实施,每步产出独立 commit,可验证后退。
### 依赖关系
```mermaid
graph LR
S1["Step 1: 基础设施"]:::s1
S2["Step 2: 核心状态机"]:::s2
S3["Step 3: LlmCycle 单元测试"]:::s3
S4["Step 4: AgentSession 包装"]:::s4
S5["Step 5: 集成测试 + 扫尾"]:::s5
S1 --> S2
S1 --> S4
S2 --> S3
S2 --> S4
S3 --> S5
S4 --> S5
classDef s1 fill:#e2e8f0,stroke:#94a3b8
classDef s2 fill:#fbbf24,stroke:#d97706
classDef s3 fill:#93c5fd,stroke:#2563eb
classDef s4 fill:#93c5fd,stroke:#2563eb
classDef s5 fill:#4ade80,stroke:#16a34a
```
| Step | 依赖 | 并行机会 |
|------|------|---------|
| S1 | 无 | — |
| S2 | S1 | 可与 S4 并行 |
| S3 | S2 | 阻塞,需 S2 完成 |
| S4 | S1, S2 | 功能依赖 S2(调用 `submit_with_tools_stream`);文件级无重叠但需先编译过 S2 |
| S5 | S3 + S4 | 需 S3 和 S4 都完成 |
### Step 1 — 基础设施准备
**工作量**S< 1h
**风险**:低(纯新增,不影响现有代码逻辑)
| # | 任务 | 涉及文件 | 前置依赖 | 风险 |
|---|------|---------|---------|------|
| 1.1 | `StreamEvent` 枚举追加 `ToolExecutionStarted` 变体 | `llm/types/response_v2.rs` | 无 | 低 |
| 1.2 | `StreamEvent` 枚举追加 `ToolExecutionCompleted` 变体 | `llm/types/response_v2.rs` | 1.1 | 低 |
| 1.3 | `PartialMessageResponse::apply_to` 追加两个元事件 arm(均返回 `true` | `llm/types/response_v2.rs` | 1.2 | 低 |
| 1.4 | `CycleConfig``#[derive(Clone)]` | `llm/cycle.rs` | 无 | 低 |
**验收条件**
- `cargo build` 通过,编译器无 warning
- 新增的 `StreamEvent` 变体可通过 `serde` roundtrip 序列化/反序列化
- `CycleConfig` 可正常 clone
### Step 2 — `LlmCycle::submit_with_tools_stream` 核心
**工作量**M1-4h
**风险**:中(核心实现,需正确设计 spawn + mpsc 生命周期)
**前置依赖**S1
| # | 任务 | 涉及文件 | 前置依赖 | 风险 |
|---|------|---------|---------|------|
| 2.1 | 实现 `run_tool_loop()` 模块函数:消息循环构建请求 → `chat_stream` → 消费流 → 检测 tool_use → 工具执行 → 新一轮 | `llm/cycle.rs` | S1 | 中 |
| 2.2 | 实现 `submit_with_tools_stream()` 公开方法:提取字段 → spawn `run_tool_loop` → 返回 `UnboundedReceiverStream` | `llm/cycle.rs` | 2.1 | 中 |
| 2.3 | 新增导入:`tokio::sync::mpsc``tokio_stream::wrappers::UnboundedReceiverStream` | `llm/cycle.rs` | 2.2 | 低 |
**关键实现细节**
```rust
// run_tool_loop 函数签名
async fn run_tool_loop(
mut messages: Vec<Message>,
provider: Arc<dyn LlmProvider>,
config: CycleConfig,
tool_registry: Arc<ToolRegistry>,
tools: Vec<ToolDef>,
tx: mpsc::UnboundedSender<StreamEvent>,
hook_executor: Option<Arc<HookExecutor>>,
) {
let max_turns = config.max_tool_turns.unwrap_or(10);
let tool_timeout = config.tool_timeout_secs;
let max_bytes = config.max_tool_result_bytes;
let mut round = 0u32;
loop {
round += 1;
if round > max_turns {
// §3.6 错误表:最大轮次超限 → Error 事件 + 终止
let _ = tx.send(StreamEvent::Error { message: "达到最大工具循环轮次".to_string() });
break;
}
// ① 构建请求
let request = MessageRequest {
model: config.model.clone(),
messages: messages.clone(),
tools: tools.clone(),
tool_choice: ToolChoice::Auto,
max_tokens: config.max_tokens,
temperature: config.temperature,
..Default::default()
};
// ② PreRequest hook
// ...
// ③ chat_stream
let stream = match provider.chat_stream(request).await {
Ok(s) => s,
Err(e) => {
let _ = tx.send(StreamEvent::Error { message: e.to_string() });
return;
}
};
// ④ 消费流
let mut partial = PartialMessageResponse::new();
let mut stream = stream;
while let Some(result) = stream.next().await {
match result {
Ok(event) => {
partial.apply_to(&event);
if tx.send(event).is_err() { return; }
}
Err(e) => {
// ponytail: 流内事件错误后 partial 处于损坏状态,
// 不能继续执行 finalize/finalize —— 直接 return 结束 task
let _ = tx.send(StreamEvent::Error { message: e.to_string() });
return;
}
}
}
// ⑤ finalize
let response = match partial.finalize() {
Ok(r) => r,
Err(e) => { let _ = tx.send(StreamEvent::Error { .. }); return; }
};
messages.push(response.message.clone());
// ⑥ 检测 tool_use
if !has_tool_calls_in_response(&response) {
break; // 最终轮
}
// ⑦ 执行工具
let tool_calls = extract_tool_calls_from_response(&response);
let calls: Vec<_> = tool_calls.into_iter()
.map(|(id, name, args)| {
let value = serde_json::from_str(&args).unwrap_or(Value::Null);
(id, name, value)
}).collect();
for (tool_call_id, tool_name, args_value) in &calls {
let args_json = serde_json::to_string(&args_value).unwrap_or_default();
if tx.send(StreamEvent::ToolExecutionStarted {
tool_name: tool_name.clone(),
tool_call_id: tool_call_id.clone(),
arguments: args_json,
}).is_err() { return; }
}
let results = tool_registry.invoke_all(calls, tool_timeout).await;
for result in &results {
let summary = match &result.output {
Ok(v) => serde_json::to_string(v).unwrap_or_default(),
Err(e) => e.to_string(),
};
// ponytail: 复用现有 truncate_tool_result 函数(cycle.rs 末尾),
// 确保多字节 UTF-8 字符不被截断破坏。上限 200 字符。
let truncated = truncate_tool_result(&summary, 200);
if tx.send(StreamEvent::ToolExecutionCompleted {
tool_name: result.tool_name.clone(),
tool_call_id: result.tool_call_id.clone(),
result_summary: truncated,
is_error: result.output.is_err(),
}).is_err() { return; }
}
for result in results {
let is_error = result.output.is_err();
let content = match &result.output {
Ok(v) => serde_json::to_string(v).unwrap_or_default(),
Err(e) if e.is_recoverable() => format!("错误: {}", e),
Err(e) => {
let _ = tx.send(StreamEvent::Error { .. });
return;
}
};
messages.push(Message::tool_result(result.tool_call_id, content, is_error));
}
}
}
```
**验收条件**
- `cargo build` 通过
- 新增方法签名与方案设计一致
- 未修改现有 `submit_with_tools`/`submit_stream` 的行为
### Step 3 — 单元测试(LlmCycle 层)
**工作量**M1-4h
**风险**:低(与现有测试模式一致,使用已有 MockProvider
**前置依赖**S2
测试策略:直接使用公开的 `crate::llm::mock::MockProvider`(已完整实现 `chat_stream` + 预设响应队列),避免改造 `cycle.rs` 测试模块内的内联 Stub。测试中调用 `submit_with_tools_stream` 时通过 `#[tokio::test(flavor = "multi_thread")]` 满足 spawn 运行时要求,或在单元级将 `run_tool_loop` 作为独立函数直接测试(不走 spawn)。
| # | 测试场景 | Mock 响应序列 | 验证点 | 覆盖路径 |
|---|---------|---------------|--------|---------|
| 3.1 | 纯文本流 | 1 个 text 响应 | 事件序列与 `submit_stream` 一致;无 `ToolExecutionStarted`/`ToolExecutionCompleted` | 正常路径:单轮 LLM → 文本返回 |
| 3.2 | 单轮工具调用 | 2 个响应:tool_use + text | 包含一对 `ToolExecutionStarted`/`ToolExecutionCompleted`;最终 `stop_reason``Stop` | 正常路径:LLM → 工具 → LLM |
| 3.3 | 多轮工具调用 | 4 个响应:3×tool_use + 1×text | 3 对 `ToolExecutionStarted`/`ToolExecutionCompleted`;消息历史长度为 8user + 3×(assistant+tool) + final assistant | 正常路径:LLM → 工具 → LLM → 工具 → LLM |
| 3.4 | 最大轮次超限 | 3 个 tool_use 响应,`max_tool_turns: Some(2)` | 流中出现 `StreamEvent::Error`;消息历史停在第 2 轮 | 边界条件:超出上限 |
| 3.5 | `chat_stream` 返回 Err | Mock `chat_stream` 返回 `Err(LlmError::Other(...))` | 流中第一个事件为 `StreamEvent::Error`;随后流结束 | 异常路径:LLM 不可用 |
| 3.6 | 空 tool_registry | 1 个 text 响应,registry 中无工具 | 流退化为纯文本流,事件序列与 3.1 一致 | 退化场景:无工具可用 |
| 3.7 | 不可恢复工具错误 | 2 个响应:tool_use → text,工具返回 `ToolError::ExecutionFailed`(不可恢复) | 流中出现 `StreamEvent::Error`;消息历史中不含该工具结果(循环终止前未 push) | 异常路径:工具执行失败 |
| 3.8 | 可恢复工具错误 | 2 个响应:tool_use → text,工具返回 `ToolError::ExecutionFailed`(可恢复) | 工具结果作为 `ToolResult { is_error: true }` 回传 LLM;流正常结束,无 `Error` 事件 | 异常路径:工具出错但可恢复 |
| 3.9 | 工具超时 | 2 个响应:tool_use → text`tool_timeout_secs: 1`,模拟工具耗时 10 秒 | 流中出现 `StreamEvent::Error`;循环终止前未 push 工具结果 | 异常路径:工具执行超时 |
**验收条件**
- `cargo test` 新增 8 个测试全部通过
- `cargo test` 存量测试 0 回归
### Step 4 — `AgentSession` 层包装
**工作量**S< 1h
**风险**:低(薄包装层,逻辑简单)
**前置依赖**S1
| # | 任务 | 涉及文件 | 前置依赖 | 风险 |
|---|------|---------|---------|------|
| 4.1 | 实现 `submit_turn_stream()`:触发 `OnTurnStart` → 组装 `LlmCycle` → 调用 `submit_with_tools_stream``turn_index += 1` → 返回流 | `agent/session.rs` | S1 | 低 |
| 4.2 | 实现 `finalize_turn()``cost_so_far.add()` → 触发 `OnTurnEnd` hook | `agent/session.rs` | S1 | 低 |
**验收条件**
- `cargo build` 通过
- 新增方法签名与方案设计一致
-`submit_turn` 的 system_prompt / compact_config / bundle 使用方式一致
### Step 5 — 集成测试 + 扫尾
**工作量**S< 1h
**风险**:低(基于现有测试框架)
**前置依赖**S3 + S4
| # | 任务 | 涉及文件 | 前置依赖 | 风险 |
|---|------|---------|---------|------|
| 5.1 | `submit_turn_stream` 端到端测试:跑通 mock provider → 消费流验证各事件到达 → `finalize_turn` 后 cost 更新正确 | `agent/session.rs`(内联测试) | S4 | 低 |
| 5.2 | Hook 触发验证:`OnTurnStart``submit_turn_stream` 返回流之前触发;`finalize_turn` 调用后 `OnTurnEnd` 正确触发 | `agent/session.rs`(内联测试) | S4 | 低 |
| 5.3 | `cargo test --all-targets` 全绿验证 | 全仓 | S5.1+S5.2 | 低 |
| 5.4 | `cargo clippy --all-targets -- -D warnings` 0 警告 | 全仓 | S5.3 | 低 |
| 5.5 | `cargo build --all-targets` 发布模式验证 | 全仓 | S5.4 | 低 |
**验收条件**
- 全量测试通过,存量 0 回归
- clippy 0 警告
- 发布模式零 warning
### 实施总览
| | Step 1 | Step 2 | Step 3 | Step 4 | Step 5 | **合计** |
|--|--------|--------|--------|--------|--------|---------|
| **工作量** | S | M | M | S | S | **M-L** |
| **文件数** | 2 | 1 | 1(内联) | 1 | 1(内联) | **~4** |
| **代码行** | ~20 | ~140 | ~150 含测试 | ~70 | ~80 含测试 | **~380** |
| **风险** | 低 | 中 | 低 | 低 | 低 | 中 |
| **并行** | — | 阻塞(S4 依赖 S2) | 阻塞 | 阻塞(依赖 S2) | 阻塞 | — |
---
## 附录 A:新增 StreamEvent 变体的 apply_to 语义
```rust
// 在 PartialMessageResponse::apply_to 中追加:
StreamEvent::ToolExecutionStarted { .. } | StreamEvent::ToolExecutionCompleted { .. } => {
// 元事件:不参与内容块累积,不修改 partial response 状态
true
}
```
## 附录 BCycleConfig 的 Clone 推导
```rust
/// LLM 调用周期配置。
#[derive(Debug, Clone)] // ← 追加 Clone
pub struct CycleConfig {
pub model: String,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub max_turns: Option<u32>,
pub retry: RetryConfig, // 已 #[derive(Clone)]
pub max_tool_turns: Option<u32>,
pub tool_timeout_secs: u64,
pub max_tool_result_bytes: usize,
}
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,647 @@
# Phase 11: 测试与检索补强
## 背景与目标
AG Core 当前(v0.2.0-rc.1)已完成 Phase 0-10,全量测试 254 个,clippy 0 警告,11 个离线示例全部 exit 0。功能性交付物覆盖了 LLM Cycle、Prompt、Tool、Memory、Agent Runtime、流式事件、ContextSlot 上下文管理。但有两个系统性的短板尚未补齐:
1. **检索抽象缺失**`memory` 模块只有 `MemoryRetriever`(基于 TextOverlap Dice 系数的关键词检索),缺少语义向量的检索抽象。`docs/roadmap.md` P1 中「VectorRetriever trait」一直未实现。
2. **测试覆盖缺口**Provider 的 roundtrip 测试停留在"基本响应 + 普通 401/500"层面,缺少结构化错误体解析、请求头验证、流式边界、工具调用端到端等关键场景的回归覆盖。多线程并发的 MemoryStore 测试只在 SqliteStore 有一个 10×10 场景,InMemoryStore 完全没有并发压力测试。
Phase 11 是 v0.2.0 正式版发布前的最后一个功能 Phase,三个 Step 的目标:
| Step | 内容 | 定位 |
|------|------|------|
| **11.1** | `VectorRetriever` trait + `InMemoryVectorRetriever` 引用实现 | P1 功能补全 |
| **11.2** | wiremock Provider roundtrip 测试(12 个场景) | 测试质量补强 |
| **11.3** | 并发测试补强(InMemoryStore + SqliteStore | 并发安全验证 |
最终目标:全量测试从 254 → 275+,为 v0.2.0 正式版建立更高的质量基线。
## 需求推演概要
### Step 11.1 — VectorRetriever trait
**核心需求**:定义一个与后端无关的语义检索抽象接口,包含 `index(id, embeddings)` 索引和 `search(query, k)` 检索两个方法。附带一个基于 `HashMap` 全量余弦相似度扫描的参考实现。
**边界识别**
- 只定义 trait,不绑定任何具体后端(pgvector / qdrant / lancedb 留给社区或下游)
- 不引入第三方向量数据库依赖
- 引用实现的 `search()` 不做索引加速(O(n) 全量扫描已足够验证 trait 契约)
- 不与 `MemoryStore` 耦合——`VectorRetriever` 是独立维度
- 不嵌入到 `AgentSession``ContextSlot`(Phase 11 不承担集成消费端)
**关键假设**
- `Vec<f32>` 作为 embedding 类型已足够(大部分 embedding 模型输出 f32 向量)
- 余弦相似度作为默认评分函数可覆盖主流场景
- InMemoryVectorRetriever 的 `Mutex<HashMap>` 在 ~10K 向量内性能可接受
### Step 11.2 — wiremock Provider roundtrip 测试
**核心需求**:补充 12 个 wiremock 测试,覆盖目前缺失的关键回归场景——结构化 JSON 错误体解析、请求头验证、429 限流头解析、流式边界、工具调用端到端。
**边界识别**
- 只做 HTTP mock 层验证,不做端到端 LLM 模型调用
- 每个测试自包含(启动自己的 MockServer),不抽共享 helper
- 测试集中在 OpenAI`GenericOpenaiProvider`)和 Anthropic(独立实现)两个核心 Provider 上
- DeepSeek/Qwen/Ollama 同属 OpenAI Compat,继承 `GenericOpenaiProvider` 的测试覆盖
**关键假设**
- wiremock 的 `body_partial_json` matcher 可用且稳定(当前 dev-dependencies 中已有 wiremock
- OpenAI 和 Anthropic 的结构化错误体格式在当前 SDK 版本中未变化
### Step 11.3 — 并发测试补强
**核心需求**:验证 `MemoryStore` 两种实现(InMemoryStore + SqliteStore)在多线程并发写和混合读写场景下的正确性。
**边界识别**
- 不测试 `KnowledgeStore` / `ConversationMemory` 的并发——它们的行为完全由 `MemoryStore` 决定,不引入新 race 条件
- 不测试 TTL 淘汰的并发正确性(TTL 淘汰使用 wall clock,非原子,不保证精确)
- 混合读写测试只验证"无 panic + 数量正确",不验证"读到的结果恰好与写顺序一致"(后者需要强一致快照,当前 Mutex 模型不提供)
**关键假设**
- `tokio::spawn` 100 个 task 同时写入 `Mutex<HashMap>`InMemoryStore)不会死锁
- SqliteStore 的 WAL 模式 + `busy_timeout=5000` 足够容忍 100 并发写
## 当前状态分析
### 测试覆盖率现状
| 维度 | 当前值 | Phase 11 目标 |
|------|--------|-------------|
| 全量测试 | 254 passed | 275+ passed |
| InMemoryStore 测试 | 6 个(save/get/list/upsert/eviction/TTL | +4 个并发 |
| SqliteStore 测试 | 9 个(含 1 个 10×10 并发) | +1 个 100 并发 |
| OpenAI wiremock 测试 | 4 个(basic/401/500/stream | +8 个 |
| Anthropic wiremock 测试 | 4 个(basic/401/529/stream | +4 个 |
| 请求头验证测试 | 0 个 | +2 个 |
| ToolUse 端到端 mock 测试 | 0 个 | +2 个(OpenAI + Anthropic |
### Provider 测试缺口
现有 wiremock 测试仅覆盖最基础的响应路径,以下关键场景缺失回归保护:
| 场景 | 缺失风险 |
|------|---------|
| OpenAI 请求体格式验证 | `body_partial_json` 未匹配,请求体结构变化无声 |
| Authorization header 验证 | header 注入被修改时不告警 |
| 结构化 401 JSON 错误体 | `error.message`/`error.code` 未消费,错误消息丢失 |
| 429 + `retry-after` 头 | `RateLimit.retry_after` 字段不准确 |
| ToolUse 端到端 mock | tool_flow 解析路径无回归 |
| 流式 last chunk usage-only | `{choices:[], usage:{...}}` 可能 panic |
### MemoryStore 并发测试缺口
| Store | 当前并发测试 | 覆盖度 | 风险 |
|-------|-------------|--------|------|
| InMemoryStore | 0 个 | 无 | `Mutex` 锁竞争、deadlock、写入丢失 |
| SqliteStore | 1 个(10 写者 × 10 次 = 100 条) | 中等 | `spawn_blocking` 线程池耗尽、WAL 锁等待超时 |
### 向量检索现状
`memory` 模块已有 `MemoryRetriever`(关键词检索)和 `retriever.rs` 中的 `RetrievalResult`/`ScoredItem` 类型。但语义向量检索维度完全空缺——无 trait、无引用实现、无测试。`docs/roadmap.md` 将 VectorRetriever 列为 P1,与 ContextSlotP1Phase 10)同级。
## 架构决策记录
| 决策 | 选择 | 放弃 | 理由 |
|------|------|------|------|
| 1. VectorRetriever trait 参数类型 | `Vec<f32>` 裸向量 | `Embedding` newtype | 包装类型增加可见复杂度但未提供运行时保护;大部分 embedding 模型输出 f32 向量;下游可自行包装 |
| 2. `search()` 返回类型 | `Vec<(String, f32)>` | `ScoredItem`/`RetrievalResult` 命名 struct | `(String, f32)` 是 (id, score) 的最小表达;Phase 3 的 `RetrievalResult` 绑定了 `KnowledgePage` 引用,不适合向量检索场景;tuple 在 consumer 侧模式匹配更简洁 |
| 3. 文件归属 | 新文件 `memory/vector.rs` | 合入 `memory/retriever.rs` | `retriever.rs` 已承载 302 行关键词检索代码,语义维度独立不应耦合;`vector.rs` 作为独立模块便于后期扩展(pgvector adapter 等) |
| 4. 是否附带引用实现 | `InMemoryVectorRetriever` | trait-only | trait-only 是纯推测代码,无 consumer 验证引用实现作为"编译期测试"验证 trait 方法签名可用 |
| 5. InMemoryVectorRetriever 余弦相似度实现方式 | 手动三行点积/范数 | `ndarray`/`approx` 等第三方依赖 | 余弦相似度数学固定,不需要外部依赖;1e-10 防零除;零新依赖原则 |
| 5a | 引用实现不做向量维度校验 | 运行时维度检查 | 维度校验是具体后端(pgvector等)的职责;引用实现面向测试/验证场景;调用方负责传入等长向量 |
| 6. 错误类型 | 复用 `MemoryError` 现有变体 | 新增 `VecRetrieval` 变体 | 向量检索与关键词检索语义等价于"检索";`RetrievalError` 变体已覆盖索引/评分异常场景 |
| 7. wiremock 测试组织 | 自包含(每个测试启动自己的 MockServer | 共享 helper 函数 | 沿用现有测试模式(openai.rs line 825+、anthropic.rs line 900+);自包含测试可独立运行、定位更直接 |
| 8. 请求头验证 | 做(`body_partial_json` + `header` matcher) | 跳过 | 回归防御价值高——Provider 请求体结构变化会直接导致请求被拒绝,头验证是低成本高收益的回归保护 |
| 9. 并发测试模式 | 100 并发写 + 混合读写(5 读 + 5 写)双模式 | 只做 100 并发写 | 两种模式互补:纯写入验证数据完整性和无 id 重复;混合读写验证读操作在并发写期间不 panic 且返回有效数据 |
| 10. 实施顺序 | 11.1 → 11.2 → 11.3 | 任意顺序 | 与 roadmap 原定的 Step 顺序一致;11.1 是纯新增可独立交付;11.2/11.3 是对既有代码的测试追加,可并行但不优先于 11.1 |
## 设计方案
### Step 11.1 — VectorRetriever trait + InMemoryVectorRetriever
#### 文件位置
- 新增:`src/memory/vector.rs`
- 修改:`src/memory.rs`+2 行:module 声明 + re-export
#### Trait 定义
```rust
/// 语义向量检索器抽象接口。
///
/// 下游可实现此 trait 以对接向量数据库(pgvector / qdrant / lancedb 等)。
/// 默认引用实现 [`InMemoryVectorRetriever`] 基于进程内 HashMap + 余弦相似度。
///
/// **稳定性**:实验性 APIv0.2.x),方法签名可能在 v0.3 中调整。
/// 若未来需要 `remove()` / `clear()` 等方法,将在此 trait 中追加(带默认实现)。
#[async_trait]
pub trait VectorRetriever: Send + Sync {
/// 将 `id` 对应的文本向量 `embeddings` 加入索引。
async fn index(&self, id: String, embeddings: Vec<f32>) -> Result<(), MemoryError>;
/// 检索与 `query` 向量最相似的 `k` 条记录。
/// 返回 `Vec<(id, score)>`,按 score 降序排列,score ∈ [0.0, 1.0]。
async fn search(&self, query: Vec<f32>, k: usize) -> Result<Vec<(String, f32)>, MemoryError>;
}
```
#### InMemoryVectorRetriever 实现要点
```rust
pub struct InMemoryVectorRetriever {
vectors: Mutex<HashMap<String, Vec<f32>>>,
}
impl InMemoryVectorRetriever {
pub fn new() -> Self {
Self {
vectors: Mutex::new(HashMap::new()),
}
}
}
#[async_trait]
impl VectorRetriever for InMemoryVectorRetriever {
async fn index(&self, id: String, embeddings: Vec<f32>) -> Result<(), MemoryError> {
let mut vectors = self.vectors.lock().map_err(|e| {
MemoryError::RetrievalError(format!("lock poisoned: {e}"))
})?;
vectors.insert(id, embeddings);
Ok(())
}
async fn search(&self, query: Vec<f32>, k: usize) -> Result<Vec<(String, f32)>, MemoryError> {
let vectors = self.vectors.lock().map_err(|e| {
MemoryError::RetrievalError(format!("lock poisoned: {e}"))
})?;
if vectors.is_empty() || k == 0 {
return Ok(Vec::new());
}
let query_norm = dot(&query, &query).sqrt();
if query_norm == 0.0 {
return Ok(Vec::new());
}
let mut scored: Vec<(String, f32)> = vectors
.iter()
.map(|(id, vec)| {
let dot_product = dot(&query, vec);
let vec_norm = dot(vec, vec).sqrt();
let similarity = dot_product / (query_norm * vec_norm + 1e-10);
(id.clone(), similarity)
})
.collect();
// 降序排列
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(k);
Ok(scored)
}
}
/// 点积(手动循环,零依赖)。
///
/// 注意:`zip` 对不等长向量静默截断到较短者。引用实现不做维度校验,
/// 调用方应确保 `a` 和 `b` 等长——不等长时结果无意义但不 panic。
fn dot(a: &[f32], b: &[f32]) -> f32 {
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}
```
#### 边界与约束
- **无维度校验**:不同维度向量传入 `search()` 时点积不报错,但余弦相似度结果无意义。维度校验是具体后端(pgvector等)的职责,引用实现不做运行时检查。
- **零向量处理**:query 为零向量时直接返回空结果(`query_norm == 0.0`)。
- **1e-10 防零除**:避免空库或全零向量导致除零 panic。
#### 测试(4 个)
| # | 测试名 | 验证点 |
|---|--------|--------|
| 1 | `basic_index_and_search` | index 两条("rust" + "python"),用 "rustacean" 查询应排在首位 |
| 2 | `search_empty_store` | 空库返回空 Vec |
| 3 | `concurrent_index` | 10 个 task 各 index 1 条,总量 10、id 无重复 |
| 4 | `concurrent_index_and_search` | 10 个 writer + 5 个 searcher 并发 2 秒,`search()` 遍历期间 `index()` 写入锁竞争不 panic |
#### 修改 `src/memory.rs`
```rust
pub mod vector;
// 在高频 re-export 区追加
pub use vector::{InMemoryVectorRetriever, VectorRetriever};
```
### Step 11.2 — wiremock Provider roundtrip 测试
#### 测试清单
全部 12 个测试均遵循现有自包含模式:`MockServer::start()``Mock::given(...).and(...).respond_with(...)``provider.chat_blocking(...)` / `provider.chat_stream_inner(...)` → assert。
##### P07 个)
| # | 测试名 | 所属文件 | Mock 关键点 | 断言 |
|---|--------|---------|------------|------|
| 1 | `openai_request_body_format` | `openai.rs` | `body_partial_json` 匹配 `{"model": "gpt-4o", "messages": [{"role": "user"}]}` | 请求体结构正确,响应解析正常 |
| 2 | `openai_authorization_header` | `openai.rs` | `header("authorization", "Bearer sk-test")` | header 精确匹配,响应解析正常 |
| 3 | `openai_401_structured_error` | `openai.rs` | 返回 401 + `{"error": {"message": "Incorrect API key", "code": "invalid_api_key"}}` | `LlmError::Authentication(msg)` 且 message 包含 "Incorrect API key" |
| 4 | `anthropic_401_structured_error` | `anthropic.rs` | 返回 401 + `{"error": {"type": "authentication_error", "message": "Invalid API key provided"}}` | `LlmError::Authentication(msg)` 且 message 包含 "Invalid API key" |
| 5 | `openai_429_with_retry_after` | `openai.rs` | 返回 429 + `{"error": {"message": "Rate limit exceeded"}}` + `retry-after: 30` 头 | `LlmError::RateLimit { retry_after: Some(30s) }` |
| 6 | `openai_tool_use_response` | `openai.rs` | 返回包含 `tool_calls` 的响应(choices[0].message.tool_calls ≠ null | `StopReason::ToolUse` + `ContentBlock::ToolUse` 正确解析 |
| 7 | `anthropic_tool_use_response` | `anthropic.rs` | 返回含 `type: "tool_use"` content block + `stop_reason: "tool_use"`Anthropic 独立 wire 格式) | `StopReason::ToolUse` + `ContentBlock::ToolUse` 正确解析 |
##### P15 个)
| # | 测试名 | 所属文件 | Mock 关键点 | 断言 |
|---|--------|---------|------------|------|
| 8 | `anthropic_version_header` | `anthropic.rs` | `header("anthropic-version", "2023-06-01")` | header 精确匹配 |
| 9 | `openai_stream_usage_only_last_chunk` | `openai.rs` | 流式最后 chunk `{"choices":[],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}` | 不 panic`MessageComplete` 包含正确 usage |
| 10 | `anthropic_529_overloaded_structured` | `anthropic.rs` | 返回 529 + `{"error": {"type": "overloaded_error", "message": "Overloaded"}}` | `LlmError::RateLimit { retry_after: None }` |
| 11 | `openai_500_structured_error` | `openai.rs` | 返回 500 + `{"error": {"message": "Internal server error", "type": "server_error"}}` | `LlmError::Request { status: 500, body }` 且 body 包含 "Internal server error" |
| 12 | `openai_stream_mid_stream_error` | `openai.rs` | 流式前几个 chunk 正常,中途服务端断开连接(模拟网络中断/限流断开) | `LlmError::Request(_)` — 流式中断映射为请求错误 |
#### 测试模式说明
```rust
// 每个测试自包含,不抽共享 helper(沿用现有模式)
#[tokio::test]
async fn openai_authorization_header() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.and(header("authorization", "Bearer sk-test"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "chatcmpl-hdr",
"object": "chat.completion",
"created": 1,
"model": "gpt-4o",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "OK"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
})))
.mount(&server)
.await;
let provider = GenericOpenaiProvider::new_with_name(
server.uri(), "sk-test".into(), "gpt-4o".into(), "openai", 30,
);
let response = provider.chat_blocking(MessageRequest {
model: "gpt-4o".into(),
messages: vec![Message::user_text("hi")],
..Default::default()
}).await.unwrap();
assert_eq!(response.text(), "OK");
}
```
#### 新增依赖
dev-dependencies 中 wiremock 已就绪(当前 openai.rs / anthropic.rs 已在测试中使用),无需新增。
### Step 11.3 — 并发测试补强
#### 测试清单
| # | 测试名 | Store | 模式 | 验证标准 |
|---|--------|-------|------|---------|
| 1 | `concurrent_writers_max_pressure` | InMemoryStore | 100 task × 1 write | 总量 100, id 无重复 |
| 2 | `concurrent_writers_max_pressure` | SqliteStore | 100 task × 1 write | 总量 100, id 无重复 |
| 3 | `concurrent_mixed_read_write` | InMemoryStore | 预热 20 条, 5 读 + 5 写并发 2 秒 | 无 panic |
| 4 | `concurrent_mixed_read_write` | SqliteStore | 预热 20 条, 5 读 + 5 写并发 2 秒 | 无 panic |
| 5 | `concurrent_capacity_eviction` | InMemoryStore | 15 写者, max_items=10 | 最终 ≤ 10 |
#### 关键实现要点
**100 并发写模式**InMemoryStore + SqliteStore 各一):
```rust
#[tokio::test]
async fn concurrent_writers_max_pressure() {
let store = Arc::new(InMemoryStore::new());
let mut handles = Vec::new();
for i in 0..100 {
let s = Arc::clone(&store);
handles.push(tokio::spawn(async move {
let id = format!("concurrent_{i}");
s.save(make_item(&id)).await.unwrap();
}));
}
for h in handles {
h.await.unwrap();
}
let list = store.list(&MemoryFilter::default()).await.unwrap();
assert_eq!(list.len(), 100);
let mut ids: Vec<String> = list.iter().map(|v| v.id.clone()).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), 100);
}
```
**混合读写模式**InMemoryStore + SqliteStore 各一):
```rust
#[tokio::test]
async fn concurrent_mixed_read_write() {
let store = Arc::new(InMemoryStore::new());
// 预热
for i in 0..20 {
store.save(make_item(&format!("seed_{i}"))).await.unwrap();
}
let mut handles = Vec::new();
// 5 个写者
for w in 0..5 {
let s = Arc::clone(&store);
handles.push(tokio::spawn(async move {
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
let mut i = 0;
while tokio::time::Instant::now() < deadline {
let id = format!("writer{w}_item{i}");
s.save(make_item(&id)).await.unwrap();
i += 1;
}
}));
}
// 5 个读者
for r in 0..5 {
let s = Arc::clone(&store);
handles.push(tokio::spawn(async move {
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
while tokio::time::Instant::now() < deadline {
let _ = s.list(&MemoryFilter::default()).await.unwrap();
}
}));
}
for h in handles {
h.await.unwrap();
}
// 不 panic 即算通过
}
```
**容量淘汰并发模式**InMemoryStore):
```rust
#[tokio::test]
async fn concurrent_capacity_eviction() {
let eviction = EvictionConfig {
policy: EvictionPolicy::Capacity { max_items: 10 },
check_interval: 1,
};
let store = Arc::new(InMemoryStore::with_eviction(eviction));
let mut handles = Vec::new();
for i in 0..15 {
let s = Arc::clone(&store);
handles.push(tokio::spawn(async move {
s.save(make_item(&format!("item_{i}"))).await.unwrap();
}));
}
for h in handles {
h.await.unwrap();
}
let list = store.list(&MemoryFilter::default()).await.unwrap();
assert!(list.len() <= 10);
}
```
**测试归属**
- InMemoryStore 并发测试 → `src/memory/store/in_memory.rs``mod tests`
- SqliteStore 并发测试 → `src/memory/store/sqlite_store.rs``mod tests`
- 混合读写测试中的 `make_item` 辅助函数:直接复用各文件现有 `fn make_item`
## 已否决的方案
### 1. 砍掉 11.1PM 建议)
**内容**PM 在讨论中提出 VectorRetriever trait 无消费者,建议整体砍掉,等 Phase 12 或 v0.3 有人用时再做。
**否决理由**:引用实现作为 trait 契约的编译期验证手段——没有 consumer 不意味着 trait 签名不需要测试。同时社区贡献(pgvector adapter 等)需要稳定的 trait 边界。附带引用实现还可作为"如何在 agcore 中实现一个 VectorRetriever"的示例,降低社区参与门槛。代码量仅 ~80 行,维护成本可忽略。
### 2. trait-only VectorRetriever(无引用实现)
**内容**:只定义 `VectorRetriever` trait,不做 `InMemoryVectorRetriever`
**否决理由**trait-only 是纯推测代码——没有运行时验证,无法确认 trait 方法签名在实际调用链中是否可编译。理想情况下每个 trait 至少有一个引用实现来验证"这个 trait 确实可以被实现"。
### 3. 跳过请求头验证
**内容**:请求体格式和 Authorization header 验证是"过度保护"。
**否决理由**`body_partial_json` + `header` matcher 的回归防御价值高。Provider 适配层的最大风险是请求体结构无声变更(如 `ToolDef` IR 切换时漏改了序列化字段),头验证是低成本(每测试 ~5 行)高收益的回归保护。
### 4. 先发 v0.2.0 正式版再迭代
**内容**:当前 rc.1 已经包含所有 P0 功能,建议直接发正式版,Phase 11 推迟到 v0.2.1。
**否决理由**:测试补强是正式版的信号而非负担。Phase 11 的三个 Step 都是"如果现在不做,以后更不会做"的类型。在正式版前补齐测试基线,避免「发布了再补测试」的经典陷阱。
### 5. 只做 100 并发写,不做混合读写
**内容**:并发写验证数据完整性已足够。
**否决理由**:纯写入和混合读写暴露不同类型的 bug。纯写入验证"数据不丢、id 无重复";混合读写验证"读操作在并发写期间不 panic、返回有效数据"。两种模式互补缺失。
### 6. (实施后补充)openai_stream_mid_stream_error 的 mock 模式偏差
**实际实施**:返回 `200 + SSE content-type + 畸形 JSON payload``data: {not-valid-json}\n\ndata: [DONE]\n\n`),断言 `ChunkToEventStream` 产出 `StreamEvent::Error`
**方案原文**:返回"前几个 chunk 正常,中途服务端断开连接",断言 `LlmError::Request(_)`
**偏差原因**wiremock 0.6 标准 responder 的 `set_delay` / `set_body_string` 行为是「延迟响应 + 发完 body 后关闭连接」,无法精确模拟「send partial body then hang 保持连接」。`read_timeout` 配合 `set_delay` 触发的超时属于 send 阶段(`LlmError::Timeout`),不属于流式中断。
**采纳方案**:用畸形 JSON payload 替代——同样验证"流中途产生错误事件而不 panic"的回归保护意图,且在 wiremock 0.6 上 100% 可重现。断言改为 `StreamEvent::Error{message}` + "stream 最终结束",保持核心回归价值。
**影响**:测试意图(流阶段错误检测)完全保留;mock 行为从「TCP 断开」变为「畸形应用层数据」;断言从 `LlmError::Request` 改为 `StreamEvent::Error`(语义等价:客户端发现流异常)。
### 7. (实施后补充)openai.rs 的 429 retry-after 解析修复
**实际实施**`handle_error_response` 新增 `retry-after` header 解析逻辑(与 anthropic.rs 完全对齐)。
**方案原文**:方案测试 11.2.4 要求 `RateLimit { retry_after: Some(30s) }`,但 ADC 表中未显式列出此修复作为生产代码变更。
**修复原因**:原 `openai.rs:186-191` 的 429 分支固定 `retry_after: None`,与 `anthropic.rs:339-351` 已有的解析逻辑不一致。原代码注释甚至已写"仅读取 retry-after",但实际未实现——这是隐藏 bug。修复让 OpenAI 兼容层(DeepSeek/Qwen 等)的限流重试信息可用,与 Anthropic 行为统一。
**影响**:方案测试 11.2.4 从「不可通过的回归保护」变为「可验证的实际行为」。变更 5 行,与 anthropic 实现完全镜像。
## 实施计划与顺序
**实施顺序**11.1 → 11.2 → 11.3(与 roadmap 一致,每步可单独交付验证)。
| Step | 文件变更 | 测试增量 | 预估代码量 | 验证标准 |
|------|---------|---------|-----------|---------|
| 11.1 | +`src/memory/vector.rs`~80 行),~`src/memory.rs`+2 行) | +4 | ~85 行实现 + 70 行测试 | `cargo build --all-targets` 编译通过,4 个测试通过 |
| 11.2 | ~`src/llm/provider/openai.rs`+6 个测试),~`src/llm/provider/anthropic.rs`+2 个测试) | +127 P0 + 5 P1 | ~290 行(含 test mod 和 mock 数据) | `cargo test --all-targets` 全绿,wiremock 12 场景均绿 |
| 11.3 | ~`src/memory/store/in_memory.rs`+3 个测试),~`src/memory/store/sqlite_store.rs`+2 个测试) | +5 | ~120 行 | `cargo test --all-targets` 全绿 |
| **总计** | 6 个文件(1 新增 + 5 修改) | +21 | ~500 行 | 全量 254 → 275+`cargo clippy --all-targets -- -D warnings` 0 警告 |
### 验证通过标准
1. `cargo build --all-targets` —— 编译通过,无 warning
2. `cargo test --all-targets` —— 全部通过(254 + 21 = 275+
3. `cargo clippy --all-targets -- -D warnings` —— 0 警告
4. `cargo test --all-targets 2>&1 | grep -E "test result:"` —— 确认新增测试全部出现在执行列表中
5. 新增 wiremock 测试单独验证网络隔离(无需 API key,纯本地 mock
## 参考来源
- **讨论收口结论**Phase 11 讨论,含 PM/SA 双视角输入(2026-07-07
- **现有代码模式**
- `src/llm/provider/openai.rs` line 824-1034——wiremock 测试模式(`MockServer::start``Mock::given(...).and(...).respond_with(...)``provider.chat_blocking` → assert
- `src/llm/provider/anthropic.rs` line 899-1079——Anthropic provider wiremock 测试
- `src/memory/store/sqlite_store.rs` line 458-483——`concurrent_writers_no_data_loss` 10×10 并发模式
- `src/memory/store.rs`——`MemoryStore` trait 定义(`#[async_trait]` 风格)
- `src/memory/error.rs`——`MemoryError` 枚举(`#[non_exhaustive]` + `RetrievalError` 变体)
- `src/memory/retriever.rs`——现有检索模块(`RetrievalResult` / `ScoredItem`
- `src/memory.rs`——模块根与 re-export 模式
- **方案文档**`docs/roadmap.md` Phase 11 章节(line 516-528
- **编译器 pragma**`#[non_exhaustive]` —— 新增枚举变体需要此标记,公开结构体字段未来变化预留兼容空间
## 关键假设与风险
### 关键假设清单
| # | 假设 | 影响 | 推翻后的应对 |
|---|------|------|------------|
| 1 | wiremock `body_partial_json` matcher 在 wiremock 0.6+ 中可用 | Step 11.2 测试 #1 的实现方式 | 改用 `body_json`(精确匹配)或 `body_string`(部分串匹配) |
| 2 | `tokio::spawn` 100 task 并发写入 `Mutex<HashMap>` 无死锁 | Step 11.3 #1 InMemoryStore 并发 | 降低并发数(50)继续验证,或换 `tokio::sync::Mutex` |
| 3 | SqliteStore 的 `busy_timeout=5000` 能容忍 100 并发写 | Step 11.3 #2 SqliteStore 并发 | 增加 `busy_timeout`10s),或限制最大并发数 |
| 4 | 新增测试不使用 wiremock 以外的未列在 dev-dependencies 中的依赖 | Phase 11 零新增外部依赖 | 若需要额外 matcher,评估后加入 dev-dependencies |
| 5 | InMemoryVectorRetriever 的 O(n) 全量扫描在测试规模下 (<1000 向量) 性能可接受 | Step 11.1 测试通过 | 如果有竞态问题,改为 read/write lock`RwLock<HashMap>` |
| 6 | `MemoryError::RetrievalError` 变体足够覆盖向量检索的索引/评分失败场景 | Step 11.1 错误映射 | 如果不够,可增加新的 `MemoryError` 变体 |
| 7 | OpenAI 和 Anthropic 的结构化错误体格式在当前 SDK 版本中未变化 | Step 11.2 测试 #3/#4/#7/#10/#11(结构化错误解析断言) | 若 SDK 变更错误体格式,更新 mock body 和断言匹配新格式 |
| 8 | 调用方传入 `search()` 的向量与已索引向量维度一致(引用实现不做维度校验,`dot()` 对不等长向量静默截断) | Step 11.1 InMemoryVectorRetriever 正确性 | 若须维度校验,在 `index()` 时记录维度并在 `search()` 时断言;引用实现维持零校验 |
### 已识别的风险
| 风险 | 等级 | 缓解措施 |
|------|------|---------|
| wiremock `body_partial_json` matcher 行为在版本升级后变化 | 低 | 限定 wiremock 版本范围(当前已在 Cargo.lock 中锁定);P0 测试不依赖该 matcher |
| 100 并发写暴露 SqliteStore 的 `spawn_blocking` 线程池瓶颈 | 中 | 观察 CI 执行时间;如果超时,降低并发到 50 或增加 `max_blocking_threads` |
| InMemoryVectorRetriever 的 `Mutex` 锁争用导致测试 flaky | 低 | Mutex 不会死锁(单线程持有不 await),测试不依赖精确时序 |
| 新增 wiremock 测试与现有测试冲突(端口占用) | 低 | `MockServer::start()` 自动选择随机端口,不冲突 |
| `cargo test --all-targets` 执行时间增加 >30% | 低 | 预估 +21 个测试,增量约 8%254→275),其中 wiremock 测试有网络 IO 但延迟 <10ms/个 |
### 非阻塞已知项
- **Ollama Provider** 是 OpenAI Compatwiremock 测试继承 `GenericOpenaiProvider`,不单独新增
- **DeepSeek / Qwen Provider** 同样通过 `GenericOpenaiProvider` 实现,继承测试
- **Step 11.1 不消费到 AgentSession / ContextSlot**,留待 Phase 12 或 v0.3 做消费端集成
- **Phase 11 完成后**,全量测试预计 275+,`cargo test --all-targets` 执行时间预计 < 60s
---
## 实施计划(附录)
**实施顺序**11.1 → 11.2 ‖ 11.311.2 与 11.3 无文件冲突,可并行交付;11.2 优先因回归保护价值更高)。每步完成后运行 `cargo test --all-targets` + `cargo clippy --all-targets -- -D warnings` 验证无回归。
---
### Step 11.1 — VectorRetriever trait + InMemoryVectorRetriever~160 行,4 测试)
**前置**:无。与 Step 11.2/11.3 可并行开发但优先交付。
| 任务 | 描述 | 文件 | 前置 | 工作量 | 风险 | 验收条件 |
|------|------|------|------|--------|------|---------|
| **11.1.1** | 创建 `memory/vector.rs`:定义 `VectorRetriever` trait + `InMemoryVectorRetriever` struct + `dot()` 辅助函数 | `src/memory/vector.rs` | 无 | S | 低 | `cargo build --all-targets` 编译通过 |
| **11.1.2** | 实现 `InMemoryVectorRetriever``index()` — Mutex insert`search()` — 全量余弦扫描 + 降序排列 + k 截断 | `src/memory/vector.rs` | 11.1.1 | S | 低 | trait 实现编译通过;`Mutex::lock()` 使用 `map_err` 处理 poison,不 panic |
| **11.1.3** | 添加 4 个内联测试:`basic_index_and_search``search_empty_store``concurrent_index``concurrent_index_and_search` | `src/memory/vector.rs` (mod tests) | 11.1.2 | S | 低 | 4 测试全部通过 |
| **11.1.4** | 修改 `src/memory.rs`:加 `pub mod vector;` + `pub use vector::{VectorRetriever, InMemoryVectorRetriever};` | `src/memory.rs` | 11.1.1 | S | 低 | `cargo build --all-targets` 无 warning |
**Step 验证**
```
cargo test --all-targets # 254 + 4 = 258+ passed
cargo clippy --all-targets -- -D warnings # 0 warning
```
---
### Step 11.2 — wiremock Provider roundtrip 测试(12 测试,P0=7 + P1=5
**前置**:无。与 Step 11.1 无文件冲突,可并行。
**`openai.rs` 新增测试(8 个:P0=5 + P1=3**
| 任务 | 测试名 | 优先级 | 前置 | 工作量 | 风险 | Mock 模式 | 验收条件 |
|------|--------|--------|------|--------|------|----------|---------|
| **11.2.1** | `openai_request_body_format` | P0 | 无 | S | 低 | `body_partial_json` 匹配 model/messages | 请求体结构正确,响应解析正常 |
| **11.2.2** | `openai_authorization_header` | P0 | 无 | S | 低 | `header("authorization", "Bearer sk-test")` | header 精确匹配 |
| **11.2.3** | `openai_401_structured_error` | P0 | 无 | S | 低 | 401 + `{"error":{"message":"...","code":"invalid_api_key"}}` | `LlmError::Authentication` 含 "Incorrect API key" |
| **11.2.4** | `openai_429_with_retry_after` | P0 | 无 | S | 低 | 429 + `retry-after: 30` | `RateLimit { retry_after: Some(30s) }` |
| **11.2.5** | `openai_tool_use_response` | P0 | 无 | S | 低 | 响应含 `tool_calls` | `StopReason::ToolUse` + `ContentBlock::ToolUse` 正确解析 |
| **11.2.6** | `openai_stream_usage_only_last_chunk` | P1 | 无 | S | 低 | 流式最后 chunk `{choices:[], usage:{...}}` | 不 panic`MessageComplete` 含正确 usage |
| **11.2.7** | `openai_500_structured_error` | P1 | 无 | S | 低 | 500 + `{"error":{"message":"server error"}}` | `Request { status: 500 }` 含 body |
| **11.2.8** | `openai_stream_mid_stream_error` | P1 | 无 | M | 中 | 前几个 chunk 正常后连接断开 | `LlmError::Request(_)` 流中断映射 |
**`anthropic.rs` 新增测试(4 个:P0=2 + P1=2**
| 任务 | 测试名 | 优先级 | 前置 | 工作量 | 风险 | Mock 模式 | 验收条件 |
|------|--------|--------|------|--------|------|----------|---------|
| **11.2.9** | `anthropic_401_structured_error` | P0 | 无 | S | 低 | 401 + `{"error":{"type":"authentication_error","message":"..."}}` | `LlmError::Authentication` 消息透传 |
| **11.2.10** | `anthropic_tool_use_response` | P0 | 无 | S | 低 | 响应含 `type:"tool_use"` content block + `stop_reason:"tool_use"` | `StopReason::ToolUse` + `ContentBlock::ToolUse` 正确解析 |
| **11.2.11** | `anthropic_version_header` | P1 | 无 | S | 低 | `header("anthropic-version", "2023-06-01")` | header 精确匹配 |
| **11.2.12** | `anthropic_529_overloaded_structured` | P1 | 无 | S | 低 | 529 + `{"error":{"type":"overloaded_error","message":"Overloaded"}}` | `RateLimit { retry_after: None }` |
**Step 验证**
```
cargo test --all-targets # 258 + 12 = 270+ passed
cargo clippy --all-targets -- -D warnings # 0 warning
```
每个测试自包含(`MockServer::start()``Mock::given(...)``provider.chat_blocking()/chat_stream_inner()` → assert),无需共享 helper。
---
### Step 11.3 — 并发测试补强(5 测试)
**前置**:无。与 Step 11.1/11.2 无文件冲突。
| 任务 | 测试名 | 文件 | 前置 | 工作量 | 风险 | 模式 | 验收条件 |
|------|--------|------|------|--------|------|------|---------|
| **11.3.1** | `concurrent_writers_max_pressure` | `in_memory.rs` | 无 | S | 中 | 100 task × 1 write | 总量 100id 无重复 |
| **11.3.2** | `concurrent_writers_max_pressure` | `sqlite_store.rs` | 无 | S | 中 | 100 task × 1 write | 总量 100id 无重复 |
| **11.3.3** | `concurrent_mixed_read_write` | `in_memory.rs` | 无 | S | 中 | 预热 20 条,5 写 + 5 读并发 2 秒 | 无 panic |
| **11.3.4** | `concurrent_mixed_read_write` | `sqlite_store.rs` | 无 | S | 中 | 预热 20 条,5 写 + 5 读并发 2 秒 | 无 panic |
| **11.3.5** | `concurrent_capacity_eviction` | `in_memory.rs` | 无 | S | 中 | 15 写者,max_items=10 | 最终 ≤ 10(竞争激烈时可能过渡态 >10,主断言 ≤ 10,宽松备选 ≤ 15) |
**实现要点**
- 沿用现有 `Arc<Store> + tokio::spawn + h.await.unwrap()` 模式(参考 `sqlite_store.rs:458-483`
- `make_item` 辅助函数直接复用各文件现有实现
- SqliteStore 测试使用 `:memory:` 数据库(与现有并发测试一致)
- 混合读写测试使用 `tokio::time::Instant::now() + Duration` 做时限
- 100 并发写是一次性 spawn 100 task(非分批),暴露最大锁竞争压力
**Step 验证**
```
cargo test --all-targets # 270 + 5 = 275+ passed
cargo clippy --all-targets -- -D warnings # 0 warning
```
---
### 整体发布核查清单
| # | 检查项 | 验证命令 | 预期结果 |
|---|--------|---------|---------|
| 1 | 编译 | `cargo build --all-targets` | 通过,0 warning |
| 2 | 全量测试 | `cargo test --all-targets` | 275+ passed0 failed |
| 3 | Lint | `cargo clippy --all-targets -- -D warnings` | 0 warning |
| 4 | 文档 | `cargo doc --no-deps` | 0 warningVectorRetriever trait 公共 API doc 完整) |
| 5 | 确认新增测试 | `cargo test --all-targets 2>&1 \| grep -E "test result:"` | 所有新增测试名出现在执行列表中 |
| 6 | wiremock 隔离 | 新增 wiremock 测试不依赖网络 | 纯本地 mock,无需 API key |
| 7 | 并行安全 | 并发测试独立运行时无 flaky | 连续 3 次 `cargo test` 结果一致 |
| 8 | 存量零回归 | 已有 254 个测试全部通过 | 与 Phase 10 基线对比无 fail |
| 9 | 公共 API doc comment | `grep -r "pub trait VectorRetriever" src/ && rg "^///" -c src/memory/vector.rs` | trait 和方法都有 `///` 注释 |
**若核查项失败的回退策略**
- **测试失败(P0)**:阻断发布。定位到具体测试名 → 检查 Mock JSON 格式与 Provider 解析逻辑是否匹配(结构化错误体格式变化 → 更新 mock body;流式状态机变化 → 更新 `chat_stream_inner` 路径测试)
- **测试失败(P1)**:不阻断发布。标记 `#[ignore]` + file issue,确认无 P0 失败后即可发布
- **clippy warning**:修复 lint 后重跑;若为 `#[allow(...)]` 可抑制,在 code review 中申明理由
- **flaky 并发测试**:检查 `tokio::spawn` 是否跨 `.await` 持锁;若 SqliteStore 超时,增加 `busy_timeout` 或降低并发数
- **11.1 模块发布阻塞**:若 `InMemoryVectorRetriever` 无法按时交付,可临时注释 `src/memory.rs` 中的 `pub mod vector;` 行,跳过整个模块(零消费者,不影响发布)。回退后再补交
@@ -0,0 +1,640 @@
# Phase 13 — 热身清理 + ContextSlot fork/merge 实施方案
- **文档编号**19
- **标题**Phase 13 — 热身清理 + ContextSlot fork/merge 实施方案
- **日期**2026-07-08
- **状态**:待实施
- **涉及模块**agent/context、agent/session、llm/types、llm/provider/openai、llm/stream
- **关联文档**roadmap.md(§Phase 13)、17-phase10-contextslot.md
- **对应**Roadmap §Phase 13v0.3.0 第一阶段)
---
## 1. 背景与目标
v0.3.0 是 agcore 从"LLM 调用工具箱"升级为"多 Agent 基础系统"的关键版本。Phase 13 是 v0.3.0 的第一阶段,定位为"热身",包含两大部分:
- **技术债清理**:删除 Phase 0 遗留的旧 types 文件(`request.rs``response.rs``old_stream.rs`),以及已标记 `#[deprecated]``ChatResponse` 结构体
- **ContextSlot fork/merge**:为 ContextSlot 增加分叉和合并能力,为后续 Phase 17 Checkpointer 和 Phase 18 SubAgent Dispatch 打基础
**依赖关系**:无(独立交付)
**优先级**P0
**预估规模**:净减 ~200 行代码(新增 ~505 行,删除 ~704 行)
---
## 2. 需求分析
### 2.1 功能需求
1. **技术债清理**:删除 `src/llm/types/request.rs`187 行)、`response.rs`177 行)、`old_stream.rs`45 行),将其中的 OpenAI wire-format 类型移入 `src/llm/provider/openai.rs`;删除 `types/mod.rs` 中的 `ChatResponse` 废弃结构体
2. **`ContextSlot::fork`**:从现有 context slot 分支出独立的子 slot
3. **`ContextSlot::merge`**:将子 slot 的消息合并回父 slot
4. **`MergeStrategy`** 枚举:Append(追加)/ Replace(替换),`#[non_exhaustive]` 预留 Phase 16 Summarize 扩展
### 2.2 非功能需求
- **每步可编译**:5 个 Step 按物理文件切割,每步 `cargo build --all-targets + cargo test` 验证
- **指定公共 API 路径保持向后兼容**:`agcore::llm::types::ToolChoice`re-export 不变)、`crate::llm::stream::StreamEvent`(重导出保留);其余 wire-format 类型(`OpenaiChatRequest``OpenaiChatResponse/Chunk``StreamOptions` 等)移入 `provider/openai.rs` 后属 Breaking Change,详见 §4.3 CHANGELOG
- **向后兼容的 StreamEvent 路径**`crate::llm::stream::StreamEvent` 重导出保留,不修改 `cycle.rs``session.rs` 的 import
---
## 3. 方案设计
### 3.1 整体架构
Phase 13 分为 5 个 Step,按执行顺序排列:
```
Step 13.5 (fork/merge) → Step 13.4 (ToolChoice) → Step 13.1 (request types) → Step 13.2 (response types) → Step 13.3 (cleanup)
```
这种顺序的好处:
- **先交付价值**:13.5 是唯一有用户功能交付的 Step,先做建立节奏
- **排序约束**13.4 必须先于 13.1ToolChoice 不搬走,request.rs 不能删)
- **13.3 收尾**:删除旧文件和 `ChatResponse` 是 breaking change,放在最后
### 3.2 Step 13.5 — ContextSlot fork/merge
#### MergeStrategy 枚举
定义在 `src/agent/context.rs`
```rust
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum MergeStrategy {
/// 子 slot 消息追加到父 slot 末尾。
Append,
/// 用子 slot 消息替换父 slot 内容。
Replace,
}
```
- `#[non_exhaustive]` 保证 Phase 16 加入 `Summarize` 变体时不破坏现有代码
- 不预埋 `Summarize` 占位变体(YAGNI 原则)
#### ContextSlot::fork
```rust
impl ContextSlot {
pub fn fork(&self, child_id: String, strategy: DeriveStrategy) -> ContextSlot {
let messages = match &strategy {
DeriveStrategy::Full => self.messages.clone(),
DeriveStrategy::Focused(cfg) => Self::filter_focused(&self.messages, cfg),
};
tracing::debug!(
parent_id = %self.id,
child_id = %child_id,
?strategy,
"ContextSlot::fork"
);
ContextSlot {
id: child_id,
session_id: self.session_id.clone(),
config: SlotConfig {
mode: match &strategy {
DeriveStrategy::Full => SlotMode::Full,
DeriveStrategy::Focused(cfg) => SlotMode::Focused(cfg.clone()),
},
source: SlotSource::Derived {
parent_id: self.id.clone(),
strategy,
},
budget: self.config.budget.clone(),
compact: self.config.compact,
},
messages,
meta: SlotMeta::new(),
}
}
}
```
设计要点:
- 纯数据层操作,不持久化
- 子 slot 的 `meta` 全新创建(`SlotMeta::new()`),不继承父 slot 的 message_count
- 子 slot 的 source 记录 `parent_id`,血缘可追溯
- 添加 `tracing::debug!` 日志,支持多 slot 交互场景的审计追踪
#### ContextSlot::merge
```rust
impl ContextSlot {
/// 将子 slot 的消息合并到当前 slot。
///
/// **注意**:本方法仅操作内存数据,不自动持久化。
/// 调用方需在 merge 后自行调用 `self.save(&store)` 将结果写入后端存储。
pub fn merge(&mut self, child: ContextSlot, strategy: MergeStrategy) -> Result<(), AgentError> {
// 防御性检查
if self.id == child.id {
return Err(AgentError::Config("不能将 slot 合并到自身".into()));
}
if self.session_id != child.session_id {
return Err(AgentError::Config("不能合并不同 session 的 slot".into()));
}
if matches!(self.config.mode, SlotMode::Readonly) {
return Err(AgentError::SlotReadonly("Readonly slot 不允许合并".into()));
}
tracing::debug!(
self_id = %self.id,
child_id = %child.id,
?strategy,
"ContextSlot::merge"
);
match strategy {
MergeStrategy::Append => {
let count = child.messages.len();
self.messages.extend(child.messages);
self.meta.message_count += count;
}
MergeStrategy::Replace => {
self.messages = child.messages;
self.meta.message_count = self.messages.len();
}
}
Ok(())
}
}
```
#### AgentSession::derive_slot 重构
现有 `derive_slot`session.rs:213-260)的手工复制代码改为调用 `parent.fork()`
```rust
pub async fn derive_slot(
&mut self,
id: impl Into<String>,
parent_id: &str,
strategy: DeriveStrategy,
) -> Result<(), AgentError> {
let slot_id = id.into();
if self.slots.contains_key(&slot_id) {
return Err(AgentError::SlotAlreadyExists(slot_id));
}
let parent = self
.slots
.get(parent_id)
.ok_or_else(|| AgentError::SlotNotFound(parent_id.to_string()))?;
let child = parent.fork(slot_id.clone(), strategy); // ← 用 fork
child.save(&*self.resolve_store()).await?;
self.slots.insert(slot_id, child);
Ok(())
}
```
重复检查、查找父 slot 的代码不变;消息复制逻辑委托给 `fork()`
#### 测试计划(新增 9 个)
| 测试名 | 验证点 |
|--------|--------|
| `fork_full_copies_messages` | fork Full 策略复制父 slot 全部消息 |
| `fork_focused_filters_messages` | fork Focused 策略按 config 过滤 |
| `fork_preserves_independence` | 父 slot 追加消息不影响子 slot |
| `fork_sets_derived_source` | 子 slot source 正确记录 parent_id |
| `merge_append_appends_messages` | Append 追加到父 slot 末尾,message_count 正确 |
| `merge_replace_replaces_messages` | Replace 替换父 slot 消息,message_count 正确 |
| `merge_self_rejected` | self-merge 返回 `Err` |
| `merge_readonly_rejected` | 合并到 Readonly slot 返回 `Err` |
| `merge_cross_session_rejected` | 跨 session 合并返回 `Err` |
### 3.3 Step 13.4 — ToolChoice 移入 tool.rs
#### 变更文件
| 文件 | 变更 |
|------|------|
| `src/llm/types/request.rs` | 删除 `ToolChoice` 枚举 + serde impl~28-99 行) |
| `src/llm/types/tool.rs` | 新增 `ToolChoice` 枚举 + serde impl(原样搬入) |
| `src/llm/types/mod.rs` | `pub use request::{..., ToolChoice}``pub use tool::ToolChoice` |
| `src/llm/types/request_v2.rs` | import 路径 `request::ToolChoice``tool::ToolChoice` |
**import 路径变化**
| 当前 | 移动后 |
|------|--------|
| `crate::llm::types::request::ToolChoice` | `crate::llm::types::tool::ToolChoice` |
| `crate::llm::types::ToolChoice`(通过 re-export | `crate::llm::types::ToolChoice`(通过 tool.rs re-export,保持不变) |
**验证**`cargo build --all-targets` + `cargo test` + `cargo clippy`
### 3.4 Step 13.1 — request.rs 类型移入 openai.rs
#### 变更文件
| 文件 | 变更 |
|------|------|
| `src/llm/types/request.rs` | **整文件删除**187 行) |
| `src/llm/provider/openai.rs` | 新增 `StreamOptions``OpenaiTool``AudioParam``PredictionContent``UserLocation``Approximate``WebSearchOptions``OpenaiChatRequest` 等类型定义 |
| `src/llm/types/mod.rs` | 删除 `pub use request::{OpenaiChatRequest, OpenaiTool, StreamOptions}`;删除 `pub mod request;` |
| `src/llm/provider/openai.rs` import 调整 | 原 `use crate::llm::types::request::{...}` 改为从同级 `use super::super::types::...` 或直接使用本文件内类型 |
**注意**`OpenaiTool` 引用 `OpenaiToolDefinition`(定义在 `tool.rs`),移入 `openai.rs` 后需通过 `crate::llm::types::tool::OpenaiToolDefinition` 引用。`OpenaiChatRequest.messages` 字段引用 `OpenaiChatMessage`(定义在 `openai_message.rs`),路径不变。
**设计决策**:搬入 `openai.rs` 后的类型可见性可降级为 `pub(crate)`。它们是与 OpenAI wire-format 绑定的内部序列化类型,公共 API 消费者不应直接接触。
**验证**`cargo build --all-targets` + `cargo test` + `cargo clippy`
### 3.5 Step 13.2 — response.rs 类型移入 openai.rs
#### 变更文件
| 文件 | 变更 |
|------|------|
| `src/llm/types/response.rs` | **整文件删除**177 行) |
| `src/llm/provider/openai.rs` | 新增 `TokenLogprob``TopLogprob``Logprobs``URLCitation``Annotation``OpenaiAudio``Choice``OpenaiChatResponse``Delta``ChunkChoice``OpenaiChatChunk` + `From<OpenaiChatMessage> for Delta` + `From<OpenaiChatResponse> for OpenaiChatChunk` |
| `src/llm/types/mod.rs` | 删除 `pub use response::{...}`;删除 `pub mod response;` |
| `src/llm/stream.rs:26` | 将 `use crate::llm::types::{OpenaiChatChunk, OpenaiToolCall}` 中的 `OpenaiChatChunk` 路径改为 `crate::llm::provider::openai::OpenaiChatChunk``OpenaiToolCall` 保持从 `tool.rs` |
**验证**`cargo build --all-targets` + `cargo test` + `cargo clippy`
### 3.6 Step 13.3 — 旧文件清理 + ChatResponse 删除
#### 13.3a — 删除 `old_stream.rs`
> **前置验证**:实施前执行 `grep -rn 'parse_chunk_stream\|map_legacy_to_ir\|LegacyToIrEventStream\|ChunkToLegacyEventStream' src/` 确认零外部调用方,记录结果到实施 commit。
| 文件 | 变更 |
|------|------|
| `src/llm/types/old_stream.rs` | **整文件删除**45 行,`LegacyStreamEvent` |
| `src/llm/types/mod.rs` | 删除 `pub mod old_stream;` |
| `src/llm/stream.rs` | 删除 `use crate::llm::types::old_stream::LegacyStreamEvent`;删除 `parse_chunk_stream``parse_chunk_stream_legacy``ChunkToLegacyEventStream``LegacyToIrEventStream``map_legacy_to_ir``empty_message_response`~160 行死代码) |
**stream.rs 最终形态**
```rust
//! 流式事件系统 —— 重导出 StreamEvent 供向后兼容。
pub use crate::llm::types::response_v2::StreamEvent;
```
**为什么不全删 stream.rs**`cycle.rs``session.rs``use crate::llm::stream::StreamEvent` 路径保持不变。全删 + 改所有 import 路径的改动量 > 收益。保留 1 行重导出就够。
#### 13.3b — 删除 `ChatResponse`
| 文件 | 变更 |
|------|------|
| `src/llm/types/mod.rs` | 删除 `ChatResponse` 结构体定义 + 两个 `#[allow(deprecated)]` `From` impl`From<OpenaiChatResponse> for ChatResponse``From<ChatResponse> for OpenaiChatChunk` |
`ChatResponse` 自 v0.1.0 起标记 `#[deprecated]`v0.2.0-rc.1 阶段直接删除即可。删除前运行 `cargo doc --no-deps 2>&1 | grep -i 'ChatResponse'` 确认零文档引用。
**验证**`cargo build --all-targets` + `cargo test` + `cargo clippy` + `cargo doc --no-deps`
---
## 4. 实现计划
### 4.1 实施顺序总览
```
Step 13.5 ──→ Step 13.4 ──→ Step 13.1 ──→ Step 13.2 ──→ Step 13.3
(fork/merge) (ToolChoice) (request) (response) (cleanup)
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
+60 行净增 -0 净增 -0 净增 -0 净增 -260 删除
+9 个测试 import 路径 纯类型搬移 纯类型搬移 +1 行重导出
变更
```
### 4.2 各 Step 文件变更清单
#### Step 13.5 — ContextSlot fork/merge
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 新增 | `src/agent/context.rs` | `MergeStrategy` 枚举 + `ContextSlot::fork()` + `ContextSlot::merge()` |
| 重构 | `src/agent/session.rs` | `derive_slot` 改为调用 `parent.fork()` |
| 新增 | 内联测试 | 9 个新测试(fork, merge, 边界) |
#### Step 13.4 — ToolChoice 移动
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 删除 | `src/llm/types/request.rs` | 移除 `ToolChoice` 枚举 + serde impl |
| 新增 | `src/llm/types/tool.rs` | 增加 `ToolChoice` 枚举 + serde impl |
| 修改 | `src/llm/types/mod.rs` | 更新 re-export 路径 |
| 修改 | `src/llm/types/request_v2.rs` | 更新 import 路径 |
#### Step 13.1 — request 类型搬移
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 删除 | `src/llm/types/request.rs` | 整文件删除(187 行) |
| 新增 | `src/llm/provider/openai.rs` | 增加所有 OpenAI wire-format 类型 |
| 修改 | `src/llm/types/mod.rs` | 删除 re-export + mod 声明 |
#### Step 13.2 — response 类型搬移
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 删除 | `src/llm/types/response.rs` | 整文件删除(177 行) |
| 新增 | `src/llm/provider/openai.rs` | 增加所有 OpenAI wire-format 类型 + From impl |
| 修改 | `src/llm/types/mod.rs` | 删除 re-export + mod 声明 |
| 修改 | `src/llm/stream.rs` | 更新 `OpenaiChatChunk` import 路径 |
#### Step 13.3 — 旧文件清理
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 删除 | `src/llm/types/old_stream.rs` | 整文件删除(45 行) |
| 修改 | `src/llm/types/mod.rs` | 删除 `pub mod old_stream;` + 删除 `ChatResponse` 结构体 + `From` impl |
| 修改 | `src/llm/stream.rs` | 删除所有死代码,仅保留 `pub use` 重导出 |
### 4.3 回滚策略
所有 Step 通过 git commit 管理,回退时 `git revert <commit>` 即可。每个 Step 独立编译,回滚不会级联依赖。若 Step 13.3(`ChatResponse` 删除)导致外部编译失败,单独 revert 该 commit 即可恢复 `ChatResponse` + `old_stream.rs`
### 4.4 CHANGELOG 条目
```markdown
## [0.3.0] - 未发布
### Breaking Changes
**类型路径变更(0.3.0):**
- `agcore::llm::types::request::ToolChoice``agcore::llm::types::tool::ToolChoice`(公共 re-export 路径 `agcore::llm::types::ToolChoice` 保持不变)
- `agcore::llm::types::request::StreamOptions``agcore::llm::provider::openai::StreamOptions`
- `agcore::llm::types::request::OpenaiChatRequest``agcore::llm::provider::openai::OpenaiChatRequest`
- `agcore::llm::types::response::OpenaiChatResponse``agcore::llm::provider::openai::OpenaiChatResponse`
- `agcore::llm::types::response::OpenaiChatChunk``agcore::llm::provider::openai::OpenaiChatChunk`
- 其余 `request.rs`/`response.rs` 中的 wire-format 类型(`OpenaiTool``AudioParam``Choice``Delta` 等)同步移入 `agcore::llm::provider::openai` 模块
**类型删除:**
- `agcore::llm::types::ChatResponse` 已删除(自 v0.1.0 标记 `#[deprecated]`,请改用 `MessageResponse`
- `agcore::llm::types::old_stream::LegacyStreamEvent` 已删除(内部死代码)
### Features
- `ContextSlot::fork(child_id, strategy)` — 从父槽派生独立的子槽(数据层操作)
- `ContextSlot::merge(child, strategy)` — 将子槽消息合并回父槽(支持 Append/Replace
- `MergeStrategy` 枚举(`#[non_exhaustive]`Phase 16 可扩展 Summarize
```
---
## 5. 风险评估
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| `ChatResponse` 被外部 crate 引用 | 编译 break | 中 — `#[deprecated]` 仅产生编译警告,外部 crate 可能通过 `#[allow(deprecated)]` 静默依赖 | CHANGELOG 明确标注语义版本(0.3.0)和迁移指引;Step 13.3 验收加入 `cargo doc --no-deps \| grep ChatResponse` 确认零引用 |
| `StreamOptions` 等 wire-format 类型路径变更影响直接引用消费者 | 编译 break | 低(v0.2.0-rc.1,极少外部消费者使用内部类型) | CHANGELOG 完整列出所有路径变更;编译错误立即可发现 |
| `parse_chunk_stream` 有隐藏调用方 | 编译 break | 极低(实施前执行 `grep -rn 'parse_chunk_stream\|map_legacy_to_ir\|LegacyToIrEventStream' src/` 前置验证) | Step 13.3 前运行 grep 验证并记录结果;`cargo build --all-targets` 可 100% 捕获 |
| `#[allow(deprecated)]` 遗漏 | clippy 警告 | 低 | `cargo clippy --all-targets -- -D warnings` 验证 |
| Step 顺序错误导致编译中间态 | 开发者体验差 | 中 | 严格按 13.5→13.4→13.1→13.2→13.3 执行;每步 `cargo build` 验证 |
| `stream.rs` 简化后 import 断链 | 编译 break | 极低 | 保留 `pub use` 重导出路径,`cycle.rs`/`session.rs` import 不变 |
---
## 6. 验收标准
### M9 里程碑(Phase 13 完成条件)
| # | 条件 | 验证方法 |
|---|------|---------|
| 1 | `request.rs``response.rs``old_stream.rs` 三个旧文件不存在 | `ls src/llm/types/` 确认 |
| 2 | `ChatResponse` 结构体不存在 | 全局搜索 `ChatResponse` 仅保留 `openai.rs``OpenaiChatResponse` 引用 |
| 3 | `ToolChoice``tool.rs` 中定义,公共路径 `agcore::llm::types::ToolChoice` 保持不变 | `cargo doc --no-deps` 确认类型文档 |
| 4 | `OpenaiChatRequest`/`Response`/`Chunk``provider/openai.rs` 中定义 | 编译通过 |
| 5 | `ContextSlot::fork()` 单元测试通过(P0 条件全部满足) | `cargo test` |
| 6 | `ContextSlot::merge()` 单元测试通过(P0 条件全部满足) | `cargo test` |
| 7 | `stream.rs` 只保留 `pub use` 重导出 | 文件内容确认 |
| 8 | `cargo build --all-targets` 编译通过 | 编译验证 |
| 9 | `cargo test --all-targets` 全绿(预期 283~285 测试) | 测试验证 |
| 10 | `cargo clippy --all-targets -- -D warnings` 0 警告 | clippy 验证 |
| 11 | CHANGELOG 包含 Phase 13 的 Breaking Changes 和 Features 条目 | 文件确认 |
### fork/merge 详细验收 P0 项
**fork 的 5 项 P0 条件:**
| # | 条件 | 优先级 |
|---|------|--------|
| 1 | `fork("child", Full)` 创建新 slot,消息在 fork 时刻 == 父 slot | P0 |
| 2 | 子 slot 获得独立消息列表——父 slot 后续追加不影响子 slot | P0 |
| 3 | 子 slot 的 source 标记为 `Derived { parent_id, strategy }` | P0 |
| 4 | 子 slot 可独立持久化(fork + save + load roundtrip | P0 |
| 5 | fork 不允许重复 id(返回 `SlotAlreadyExists`)(由 `derive_slot` 编排层保证) | P0 |
**merge 的 5 项 P0 条件:**
| # | 条件 | 优先级 |
|---|------|--------|
| 1 | `parent.merge(child, Append)` 子消息追加到父末尾 | P0 |
| 2 | `parent.merge(child, Replace)` 子消息替换父全量消息 | P0 |
| 3 | merge 后父 slot 的 `meta.message_count` 正确更新 | P0 |
| 4 | merge 不允许合并到 Readonly 目标 slot | P0 |
| 5 | merge 不允许 self-mergechild.id == parent.id | P0 |
---
## 参考来源
- Roadmap`docs/roadmap.md` §Phase 13
- ContextSlot 设计:`docs/17-phase10-contextslot.md`
- 旧 StreamEvent 设计:`src/llm/stream.rs` 文件注释
- 当前代码库:`src/llm/types/request.rs``src/llm/types/response.rs``src/llm/types/old_stream.rs``src/llm/types/mod.rs``src/llm/provider/openai.rs``src/agent/context.rs``src/agent/session.rs`
---
## 7. 实施计划
### 全局说明
**commit 策略**:每个 Step 一个独立 commit。commit message 格式:
```
<type>(<scope>): <中文描述>
```
- Step 13.5 → `feat(agent): 实现 ContextSlot fork/merge`
- Step 13.4 → `refactor(types): ToolChoice 移入 tool.rs`
- Step 13.1 → `refactor(types): request.rs 类型移入 provider/openai.rs`
- Step 13.2 → `refactor(types): response.rs 类型移入 provider/openai.rs`
- Step 13.3 → `refactor(types): 删除旧类型文件和 ChatResponse`
**验证命令(每步通用)**
```bash
cargo build --all-targets && cargo test && cargo clippy --all-targets -- -D warnings
```
**预计测试数量变化**
- 当前基线:277 测试(每个 Step 开始时 `cargo test` 确认)
- Step 13.5 后:286+9
- Step 13.4-13.2 后:286(无变化)
- Step 13.3 后:285-1`ChatResponse``From` impl 无测试直接引用,删除后仅 `types/mod.rs` 中的 `deprecated` 注释行减少,不影响测试计数。实施前执行 `grep -rn 'ChatResponse' src/ --include='*test*' --include='*tests*'` 确认零测试引用)
- 最终范围:285 测试
### Step 13.5 — ContextSlot fork/merge
**前置依赖**:无(纯新增,不依赖前序 Step
**任务描述**:在 `agent/context.rs` 中新增 `MergeStrategy` 枚举、`ContextSlot::fork()` 方法和 `ContextSlot::merge()` 方法;重构 `agent/session.rs` 中的 `derive_slot` 改为调用 `parent.fork()`;新增 9 个内联测试覆盖 fork/merge 的 happy path 和 error path。
**涉及文件**
- `src/agent/context.rs` — 新增枚举和方法
- `src/agent/session.rs` — 重构 derive_slot
- `src/agent.rs` — 追加 `MergeStrategy` re-export
**具体操作**
1.`context.rs` 中新增 `MergeStrategy` 枚举(Append / Replace`#[non_exhaustive]`
2.`context.rs``impl ContextSlot` 块内新增 `fork(&self, child_id: String, strategy: DeriveStrategy) -> ContextSlot` 方法
3.`context.rs``impl ContextSlot` 块内新增 `merge(&mut self, child: ContextSlot, strategy: MergeStrategy) -> Result<(), AgentError>` 方法(含 self-merge/cross-session/Readonly 三项防御检查 + `tracing::debug!` 日志)
4.`session.rs``derive_slot` 方法中将手工消息复制代码替换为 `parent.fork(slot_id, strategy)`
5.`agent.rs``pub use context::{...}` 列表中追加 `MergeStrategy`
6.`context.rs``#[cfg(test)] mod tests` 中新增 9 个测试用例
**注意**:重构后 `derive_slot` 的子 slot `budget``ContextBudget::default()` 变为继承父 slot`compact``true` 变为继承父 slot。由于 `ContextBudget` 在 v0.2 无消费逻辑且父 slot 的 `compact` 默认也为 `true`,此变化无实际影响。验收条件中"行为不变"指对外功能行为不变(slot 消息内容、血缘关系不变)。
**预估工作量**M1-4h
**风险等级**:低(纯新增,不修改已有逻辑路径)
**验收条件**
- `MergeStrategy` 枚举存在,`Append``Replace` 两个变体可用,且通过 `agcore::agent::MergeStrategy` 路径可访问
- `ContextSlot::fork` 返回的 child 在 fork 时刻消息等于父 slot
- fork Focused 策略按 `FocusedConfig` 过滤消息
- 父 slot 后续追加消息不影响子 slot
- 子 slot 的 source 正确记录 `Derived { parent_id, strategy }`
- `parent.merge(child, Append)` 追加到父末尾,message_count 正确
- `parent.merge(child, Replace)` 替换父全量消息,message_count 正确
- self-merge 返回 `Err(AgentError::Config)`
- merge 到 Readonly slot 返回 `Err(AgentError::SlotReadonly)`
- 跨 session merge 返回 `Err(AgentError::Config)`
- `derive_slot` 对外行为不变(slot 消息内容、血缘关系、持久化行为均不变;内部 budget/compact 继承差异无实际影响),测试全绿
- `cargo doc --no-deps` 无 warning(验证新增公开 API 的文档注释完整)
**回退方式**`git revert` 该 commit
### Step 13.4 — ToolChoice 移入 tool.rs
**前置依赖**:Step 13.5(顺序约束:必须早于 Step 13.1——若 Step 13.1 先执行会将 `ToolChoice``request.rs` 一同删除,导致本 Step 无可搬移的源)
**任务描述**:将 `ToolChoice` 枚举及其 serde 实现从 `types/request.rs` 搬移到 `types/tool.rs`,更新所有 import/path 引用。公共 re-export 路径 `agcore::llm::types::ToolChoice` 保持不变。
**涉及文件**
- `src/llm/types/request.rs` — 删除 ToolChoice~28-99 行)
- `src/llm/types/tool.rs` — 新增 ToolChoice 枚举 + serde impl
- `src/llm/types/mod.rs` — re-export 路径从 `request` 改为 `tool`
- `src/llm/types/request_v2.rs` — import 路径从 `request::` 改为 `tool::`
**具体操作**
1.`request.rs` 复制 `ToolChoice` 枚举 + `Serialize`/`Deserialize` impl 到 `tool.rs`
2.`request.rs` 中删除 `ToolChoice` 定义
3.`mod.rs` 中将 `pub use request::{..., ToolChoice}` 改为 `pub use tool::ToolChoice`
4.`request_v2.rs` 中将 `use crate::llm::types::request::ToolChoice` 改为 `use crate::llm::types::tool::ToolChoice`
5. 验证 `cycle.rs``use crate::llm::types::ToolChoice`(通过 re-export)路径不变
**预估工作量**S<1h
**风险等级**:低(有限的 import 路径变更,编译立即可发现)
**验收条件**
- `ToolChoice``tool.rs` 中定义
- `pub use tool::ToolChoice``mod.rs`
- `request_v2.rs` 编译通过
- `cycle.rs` 路径不变
- `cargo build --all-targets` + `cargo test` + `cargo clippy` 全绿
**回退方式**`git revert` 该 commit
### Step 13.1 — request.rs 类型移入 openai.rs
**前置依赖**Step 13.4ToolChoice 已移走,request.rs 剩余内容全是 OpenAI wire-format 专有类型)
**任务描述**:删除 `types/request.rs` 整文件,将所有剩余类型(`OpenaiChatRequest``StreamOptions``OpenaiTool``AudioParam``PredictionContent``UserLocation``Approximate``WebSearchOptions`)搬入 `provider/openai.rs`,更新 `mod.rs` re-export。
**涉及文件**
- `src/llm/types/request.rs` — 整文件删除
- `src/llm/provider/openai.rs` — 新增所有类型定义
- `src/llm/types/mod.rs` — 删除 re-export + mod 声明
**具体操作**
1.`request.rs` 复制所有剩余类型定义到 `openai.rs`,可见性设为 `pub(crate)`
2. `OpenaiTool` 内引用 `OpenaiToolDefinition`(定义在 `tool.rs`),路径改为 `crate::llm::types::tool::OpenaiToolDefinition`
3. 删除 `openai.rs` 中原 `use crate::llm::types::request::{...}` import
4.`mod.rs` 删除 `pub use request::{OpenaiChatRequest, OpenaiTool, StreamOptions}``pub mod request;`
5. 删除 `types/request.rs` 文件
**预估工作量**M1-4h
**风险等级**:低(纯搬移 + 删除,文件内无逻辑变更)
**验收条件**
- `request.rs` 文件不存在
- `OpenaiChatRequest` 等类型在 `openai.rs` 中定义,编译通过
- `OpenaiTool` 通过 `crate::llm::types::tool::OpenaiToolDefinition` 正确引用
- `cargo build --all-targets` + `cargo test` + `cargo clippy` 全绿
**回退方式**`git revert` 该 commit。若 Step 13.2 也已提交,单独 revert 本 Step 可能因 `provider/openai.rs` 并发修改产生合并冲突。安全回退顺序为逆序:先 revert 13.2,再 revert 13.1。
### Step 13.2 — response.rs 类型移入 openai.rs
**前置依赖**:无(与 Step 13.1 共享 `provider/openai.rs``types/mod.rs`,但本 Step 仅追加类型定义,无覆盖操作;建议在 13.1 之后顺序执行以避免并行时的合并冲突)
**任务描述**:删除 `types/response.rs` 整文件,将所有类型(`OpenaiChatResponse``OpenaiChatChunk``Choice``Delta``ChunkChoice` 等 + 两个 `From` impl)搬入 `provider/openai.rs`,更新 `mod.rs``stream.rs` 的 import 路径。
**涉及文件**
- `src/llm/types/response.rs` — 整文件删除
- `src/llm/provider/openai.rs` — 新增所有类型定义 + From impl
- `src/llm/types/mod.rs` — 删除 re-export + mod 声明
- `src/llm/stream.rs``OpenaiChatChunk` import 路径改为 `provider::openai`
**具体操作**
1.`response.rs` 复制所有类型定义(含 `From` impl)到 `openai.rs`,可见性设为 `pub(crate)`
2. 删除 `openai.rs` 中原 `use crate::llm::types::response::{...}` import
3.`mod.rs` 删除 `pub use response::{...}``pub mod response;`
4.`stream.rs:26``OpenaiChatChunk` 的 import 路径改为 `crate::llm::provider::openai::OpenaiChatChunk``OpenaiToolCall` 路径不变)
5. 删除 `types/response.rs` 文件
**预估工作量**M1-4h
**风险等级**:低(与 Step 13.1 模式完全相同)
**验收条件**
- `response.rs` 文件不存在
- `OpenaiChatResponse`/`Chunk` 等类型在 `openai.rs` 中定义,编译通过
- `stream.rs` import 路径正确
- `cargo build --all-targets` + `cargo test` + `cargo clippy` 全绿
**回退方式**`git revert` 该 commit。若 Step 13.1 和本 Step 均已提交,安全回退顺序为逆序:先 revert 本 Step,再 revert 13.1。
### Step 13.3 — 旧文件清理 + ChatResponse 删除
**前置依赖**Step 13.1`request.rs` 已删)、Step 13.2`response.rs` 已删)
**任务描述**:删除 `old_stream.rs``ChatResponse`,简化 `stream.rs` 为仅保留 `pub use` 重导出。这是 Phase 13 技术风险最高的 Step。
**涉及文件**
- `src/llm/types/old_stream.rs` — 整文件删除
- `src/llm/types/mod.rs` — 删除 `pub mod old_stream;` + 删除 `ChatResponse` 结构体和两个 `From` impl
- `src/llm/stream.rs` — 删除死代码(约 160 行),仅保留 `pub use` 重导出
**具体操作**
1. **前置验证 A**:执行 `grep -rn 'parse_chunk_stream\|map_legacy_to_ir\|LegacyToIrEventStream\|ChunkToLegacyEventStream' src/` 确认零外部调用方,记录结果到 commit message
2. **前置验证 B**:执行 `cargo doc --no-deps 2>&1 | grep -i 'ChatResponse'` 确认零文档引用,记录结果
3.`mod.rs` 删除 `pub mod old_stream;`
4.`mod.rs` 删除 `ChatResponse` 结构体定义 + `#[allow(deprecated)]` `From<OpenaiChatResponse> for ChatResponse` + `From<ChatResponse> for OpenaiChatChunk`
5. 删除 `old_stream.rs` 文件
6.`stream.rs` 删除:`use crate::llm::types::old_stream::LegacyStreamEvent``parse_chunk_stream``parse_chunk_stream_legacy``ChunkToLegacyEventStream``LegacyToIrEventStream``map_legacy_to_ir``empty_message_response`
7. `stream.rs` 最终只保留 module doc comment + `pub use crate::llm::types::response_v2::StreamEvent;`
8. 检查 `cycle.rs:88``#[allow(deprecated)]` 属性是否仍与 `ChatResponse` 相关——若不相关则无需改动;若因 `ChatResponse` 删除而变脏,清理该属性
**预估工作量**S<1hcleanup+ M(需验证过程)
**风险等级**:中(`ChatResponse` 删除是 Breaking Change,外部可能静默依赖)
**验收条件**
- `old_stream.rs` 文件不存在
- `ChatResponse` 结构体不存在(全局搜索仅保留 `OpenaiChatResponse` 引用)
- `stream.rs` 只保留 `pub use` 重导出
- `cargo build --all-targets` 编译通过
- `cargo test --all-targets` 全绿(预期 285 测试)
- `cargo clippy --all-targets -- -D warnings` 0 警告
- `cargo doc --no-deps` 无 warning
**回退方式**`git revert` 该 commit(单独 revert 即可恢复 `ChatResponse` + `old_stream.rs`
+278
View File
@@ -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 blockImage / Audio / File 等)统一标记为 `[{kind}]`
- 每条消息一行,空行分隔
---
## 实现计划
### Step 16.1 — `SummaryConfig` 结构体
**文件**:新增 `src/agent/summary.rs`
**内容**
- `SummaryConfig` 结构体定义(6 个字段 + doc comments
- `DEFAULT_SUMMARY_PROMPT` 常量(约 100 字中文 prompt,含 `{messages}` 占位符)
- `impl Default for SummaryConfig`
- `format_messages_as_text(messages: &[Message]) -> String` 辅助函数
**验证**`cargo build`
### Step 16.2 — `AgentConfig` 扩展 + `AgentBuilder` 方法
**文件**`src/agent/runtime.rs` + `src/agent/builder.rs`
**改动**
- `AgentConfig` 新增字段:`pub summary_config: Option<SummaryConfig>`
- `AgentBuilder` 新增方法:
```rust
pub fn summary_config(mut self, cfg: SummaryConfig) -> Self {
let mut config = self.config.take().unwrap_or_default();
config.summary_config = Some(cfg);
self.config = Some(config);
self
}
```
**验证**`AgentBuilder` 单元测试 + `cargo test`
### Step 16.3 — `AgentSession` 新字段 + 检查点
**文件**`src/agent/session.rs`
**改动**
1. `AgentSession` 新增字段:`last_summary_turn: u32`(初始化 0
2. `submit_turn` 中 OnTurnEnd 之后、turn_index 之前插入检查点
3. `finalize_turn` 中 OnTurnEnd 之后插入对称检查点。注意:流式路径中 `turn_index` 已在 `submit_turn_stream` 中递增,检查点赋值使用 `self.turn_index.saturating_sub(1)`(与 `OnTurnEnd` hook 保持一致)。
4. 关联函数 `generate_summary`
- 接收 `provider``messages``prompt_template``summary_model``max_tool_result_chars`
- 入口守卫:`messages.is_empty()` 时直接返回 `Ok(String::new())`
- 构造 `LlmCycle``max_tokens = Some(1024)`
- 调 `cycle.submit_messages(vec![Message::user_text(prompt)], vec![])`
- 提取 text 返回
5. 公开 API`get_conversation_summary()``self.session_memory.get("conversation_summary")`
**验证**`cargo build --all-targets`
### Step 16.4 — re-export
**文件**`src/agent.rs`
**改动**
```rust
pub mod summary;
pub use summary::SummaryConfig;
```
**验证**`cargo test --all-targets`
### Step 16.5 — 测试
| 测试 | 验证点 | 方式 |
|------|--------|------|
| `summary_config_defaults` | 默认值正确 | 单元测试 |
| `summary_not_generated_below_threshold` | token < 阈值时不触发 | `MockProvider` + `Usage::from_input_output(10, 5)` |
| `summary_generated_above_threshold` | token ≥ 阈值时触发 | 设置 `max_context_tokens=20` + `trigger_token_ratio=0.5` |
| `summary_debounce_works` | debounce 内不重复 | 强行触发摘要后验证 3 轮内不触发 |
| `summary_injected_into_focused` | Focused 模式 `load_messages()``[上下文摘要]` | 检查 Message 内容 |
| `summary_written_to_session_memory` | `get_session_data("conversation_summary")` 有值 | 集成测试 |
| `summary_not_injected_in_full_mode` | Full 模式不改 slot config | 验证 `summary_override` 为 None |
| `summary_failure_does_not_block` | LLM error 不阻断 `submit_turn` | MockProvider 返回错误 |
| `summary_stream_path` | 流式路径 `finalize_turn` 正确触发 | `submit_turn_stream` 端到端 |
| `summary_format_messages` | 格式化输出结构正确 | 单元测试验证格式 |
| `summary_skipped_for_empty_messages` | 空消息不调用 LLM | `generate_summary` 直接返回 `""` |
| `summary_not_generated_if_max_context_unreachable` | `max_context_tokens` 过大时不触发 | 验证条件不满足 |
**验证**`cargo test --all-targets` 全绿
---
## 规模估算
| 组件 | 纯实现 | 测试 | 合计 |
|------|--------|------|------|
| `agent/summary.rs`SummaryConfig + format_messages + 默认 prompt + 截断守卫) | 60 | 10 | 70 |
| `agent/runtime.rs`(1 个字段) | 3 | — | 3 |
| `agent/builder.rs`1 个方法) | 8 | 3 | 11 |
| `agent/session.rs`(检查点 + generate_summary + get_conversation_summary | 40 | 100 | 140 |
| `agent.rs`module 声明 + re-export | 3 | — | 3 |
| **合计** | **109** | **113** | **~222** |
---
## 风险评估
### 已知风险
| 风险 | 概率 | 影响 | 缓解措施 |
|------|------|------|---------|
| **同步阻塞**:摘要 LLM 调用延长 submit_turn 延迟 | 高 | 长对话用户多等 1-3s | 对于已达 75% 水位的长对话,用户感知可接受;所有错误静默处理 |
| **默认模型不兼容**:非 OpenAI 用户未设置 `summary_model` 但默认 `None` 沿用主模型 | 低 | 无影响 | `summary_model` 默认 `None`,沿用主 provider 默认模型,零兼容问题 |
| **无限循环**:摘要不减少 cost_so_far,每轮都超阈值 | 中 | 频繁 LLM 调用浪费 token | `debounce_turns=3` 强制隔断;`last_summary_turn` 记录确保了间隔。注意:摘要 token 不计入 `cost_so_far`(独立 LlmCycle),阈值不会因摘要本身加速膨胀 |
| **Focusd 模式摘要位置**:注入为 `system` 消息排在列表末尾 | 低 | LLM 近因效应,摘要可能过度受关注 | 这是 v0.2 消费端的设计选择,Phase 16 不改变 |
| **SessionMemory key 冲突**:用户手动写入 `"conversation_summary"` 会被覆盖 | 低 | 数据被摘要覆盖 | 文档建议用户自定义 key;或未来使用 namespaced key |
| **可观测性盲区**`tracing::warn!` 依赖用户配置了 tracing subscriber | 中 | 失败静默不可见 | 提升到 `tracing::error!` 级别,或加 `eprintln!` fallback |
### 不做的事
- ❌ 不扩展 `HookContext`
- ❌ 不引入 `tokio::spawn` 后台摘要
- ❌ 不做增量摘要(`SummaryStrategy::Incremental` 留待 v0.4
- ❌ 不改 `filter_focused()` 的摘要注入位置
- ❌ 不追踪摘要 token 消耗(`summary_cost_so_far`
- ❌ 不添加运行时 prompt 校验(不检查 `{messages}` 是否存在)
- ❌ 不添加 `MergeStrategy::Summarize` 变体(`context.rs:108` 预占注释将在实施时同步移除或更新)
---
## 验收标准
### 编译与测试
| 检查项 | 指标 |
|--------|------|
| `cargo build --all-targets` | ✅ 通过 |
| `cargo test --all-targets` | ✅ 全量通过(预计 335 → ~345,新增 ~10 测试) |
| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 |
| 测试覆盖范围 | F1-F8、N1-N4 |
### 功能验收场景
**场景 1:启用摘要后的长对话**
```rust
let session = AgentSession::new(agent, "session-1", Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(registry)
.hook_executor(executor)
.summary_config(SummaryConfig {
max_context_tokens: 100,
trigger_token_ratio: 0.5,
debounce_turns: 2,
..Default::default()
})
.build()?
));
session.submit_turn("msg 1").await?;
// ... submit_turn 多次直到 token 超 50 ...
// 第 N 轮:摘要自动生成
let summary = session.get_session_data("conversation_summary").await?;
assert!(summary.is_some());
// Focused 模式下 load_messages 包含摘要
```
**场景 2:不启用时零影响**
```rust
let session = AgentSession::new(agent, "session-2", bundle); // 无 summary_config
for i in 0..50 {
session.submit_turn(&format!("msg {}", i)).await?;
}
// 没有摘要产生,没有额外的 LLM 调用
```
---
## 参考来源
- Phase 10 方案文档:`docs/17-phase10-contextslot.md`(§5 FocusedConfig 消费端设计)
- Phase 14 方案文档:`docs/20-phase14-document-and-embedding.md`Provider 复用模式)
- 当前代码:`src/agent/session.rs`submit_turn 流程,OnTurnEnd 位置)
- 当前代码:`src/llm/cycle.rs`submit_messages 签名)
- 当前代码:`src/agent/context.rs`FocusedConfig.summary_override + filter_focused 消费逻辑)
- 当前代码:`src/agent/runtime.rs`AgentConfig 结构)
- 当前代码:`src/agent/builder.rs`Builder 链式模式)
- 当前代码:`src/agent/session_memory.rs`set/get API
@@ -0,0 +1,775 @@
# Phase 17 — Agent 执行引擎
- **文档编号**23
- **标题**Phase 17 — Agent 执行引擎(Engine
- **日期**2026-07-15
- **状态****审查修复完成,待第二轮复审**
- **涉及模块**`engine/`(新建,含 `session_manager` / `checkpointer` / `snapshot` / `error`)、`agent/session``agent/context``llm/types/usage`
- **关联文档**`docs/17-phase10-contextslot.md``docs/22-phase16-summary-auto-generation.md``docs/roadmap.md`
- **审查记录**:第 1 轮 PM Director + SA Director 审查 → 6 🔴 阻塞问题,全部修复。详见 §变更记录。
---
## 背景与目标
### 问题
agcore v0.3.0 开发中,已完成 Phase 13-16Phase 0-12 全部完成)。当前测试 353 个,全部通过,clippy 0 警告。
当前 `AgentSession` 存在以下空白:
1. **Session 在变量中**`AgentSession` 实例仅在内存中存在,无法通过 session ID 从存储恢复
2. **无父子关系**:session 之间相互独立,无法表达"子会话继承父会话"的树形关系
3. **无检查点**:无法在任意时刻给 session 拍快照,出错后无法回滚到历史状态
4. **不可序列化**`AgentSession` 持有 `Arc<dyn Agent>``Arc<RuntimeBundle>`,无法直接序列化持久化
### 目标
建立 `engine/` 模块,补齐 session 生命周期的管理能力。具体包括:
1. **Session 工厂 + 按 ID 恢复**`SessionManager::create()` / `get()`session 创建后可通过 ID 从存储重建
2. **父子 session 树形关系**`create_child()` / `children()` / `parent()`,支持树形会话拓扑
3. **生命周期管理**`destroy()` 清理 session 及其存储记录
4. **Time-travel Checkpointer**`checkpoint()` / `rollback()` / `list_checkpoints()`,支持任意时刻状态快照与回滚
5. **序列化支持**:通过 `SessionSnapshot` 独立 struct 间接实现 `AgentSession` 的快照持久化
### 成功标准
1. Session 创建后可通 ID 从存储恢复(`get()` 返回完整状态的 `AgentSession`
2. 父子 session 关系可查询(`children()` / `parent()`),数据正确隔离
3. Checkpoint 拍快照后可完全恢复到该时刻状态(turn_index、cost_so_far、slots 一致)
4. 零新外部依赖,全量测试 353 → ~385-390
5. `cargo test --all-targets` 全绿,`cargo clippy` 0 警告
---
## 当前状态分析
### 模块现状
| 模块 | 文件 | 状态 | 与 Phase 17 的关系 |
|------|------|------|-------------------|
| `AgentSession` | `agent/session.rs` | ✅ 已实现 | 需扩展 `to_snapshot()` / `from_snapshot()` |
| `ContextSlot` | `agent/context.rs` | ✅ 已实现(持久化、fork/merge/save/load | 需加 `Serialize` / `Deserialize` derive |
| `CostTracker` | `llm/types/usage.rs` | ✅ 已实现 | 需加 `Clone` + `Serialize` / `Deserialize` derive |
| `MergeStrategy` | `agent/context.rs` | ✅ 已实现 | 需加 `Serialize` / `Deserialize` derive |
| `MemoryStore` trait | `memory/store.rs` | ✅ 已实现 | Checkpointer 的存储后端 |
| `RuntimeBundle` | `agent/runtime.rs` | ✅ 已实现(依赖注入容器) | `from_snapshot()` 需注入 `agent``bundle` |
| `InMemoryStore` | `memory/store.rs` | ✅ 已实现 | 测试用存储后端 |
| `SqliteStore` | `memory/sqlite_store.rs` | ✅ 已实现(Phase 7) | 生产环境存储后端 |
| `Message` | `llm/types/message.rs` | ✅ 已有 `Serialize` / `Deserialize` | 可直接序列化 |
### AgentSession 关键字段
```rust
pub struct AgentSession {
pub session_id: String,
pub agent: Arc<dyn Agent>, // ❌ 不可序列化
bundle: Arc<RuntimeBundle>, // ❌ 不可序列化
turn_index: u32, // ✅ 可序列化
cost_so_far: CostTracker, // ⚠️ 需加 derive
pub session_memory: SessionMemory, // ⚠️ 间接序列化
slots: HashMap<String, ContextSlot>, // ⚠️ 需加 derive
current_slot_id: String, // ✅ 可序列化
last_summary_turn: Option<u32>, // ✅ 可序列化
}
```
核心制约:`Arc<dyn Agent>``Arc<RuntimeBundle>` 无法 `Serialize` / `Deserialize`,必须通过独立 snapshot struct + 外部注入重建。
### 关键假设(设计分析 — 需实施后验证)
以下假设在方案设计中做出,标注验证方式。实施 Step 1-3 后应逐项确认。
| # | 假设 | 验证方式 |
|---|------|---------|
| 1 | `submit_turn_stream` 内部 `tokio::spawn` 不持有 `&mut self` → 可通过 `Arc<Mutex<AgentSession>>` 安全共享 | 代码审查覆盖 `submit_with_tools_stream``run_tool_loop` 的 spawn 捕获列表;确认所有捕获变量为 owned 数据 |
| 2 | `CostTracker``Clone` 不破坏现有代码 | 编译验证(`cargo build --all-targets`);检查 `CostTracker` 的所有消费方(`session.rs` 中只读引用) |
| 3 | `ContextSlot``Serialize` / `Deserialize` 不影响现有 `save` / `load` 路径 | 现有 `save()` 直接序列化 `self.messages` / `self.meta` / `self.config`,不走 `ContextSlot` 整体 serde → 两组路径可共存 |
| 4 | `Message` 已有 `Serialize` / `Deserialize` → 可直接嵌套序列化 | 代码确认(`message.rs` L21 已有 derive |
| 5 | `EngineError` 不需要 `derive Serialize` → 纯运行时错误类型 | Checkpoint 只存 `SessionSnapshot`,不存错误枚举 |
| 6 | `MemoryStore` 操作是可靠的——失败时返回 `EngineError::Memory` 透传错误 | 当前不内置 store 重试逻辑;调用方负责 retry 或 failover |
| 7 | session_id 使用 UUID v4 自动生成,冲突概率可忽略 | 实施确定 ID 生成方案(`uuid::Uuid::new_v4()` 或 时间戳+计数器无依赖方案) |
| 8 | session_memory 当前只支持字符串值;未来支持复杂类型时 `SessionMemoryEntry``value` 字段需改用 `serde_json::Value` | 已预留在注释中 |
---
## 调研发现
### 可选方案对比
#### 方案 A(推荐):SessionSnapshot + 组合式架构
**做法**:用一个独立 `SessionSnapshot` struct 存储可序列化状态,避开 `Arc<dyn Agent>` 的序列化限制。`Checkpointer` 作为独立 struct`SessionManager` 组合持有 `Checkpointer`
**优点**
- 不污染 `AgentSession` 主类型,序列化逻辑与运行逻辑分离
- `Checkpointer` 独立可测,不依赖 `SessionManager`
- 组合关系清晰:`SessionManager` 持有 `Checkpointer`
- 所有字段使用 `#[serde(default)]` 宽松反序列化,前向兼容
**缺点**
- 需要额外同步逻辑:`to_snapshot()` / `from_snapshot()` 双向转换
#### 方案 B(已否决):直接给 AgentSession derive Serialize
**做法**:给 `AgentSession``#[derive(Serialize)]`,用 `#[serde(skip)]` 跳过 `agent``bundle`
**否决原因**
1. `#[serde(skip)]` 跳过了 2 个核心字段,序列化后的结果名不副实
2. 技术债重:主类型获得"跳过一半字段"的诡异 serde 行为,未来维护者可能误以为 `AgentSession` 可整体序列化/反序列化
3. 反序列化时 `agent``bundle` 缺失,仍需外部注入 → 不如直接使用独立的 snapshot struct
#### 方案 C(已否决):Checkpointer 作为 SessionManager 内部方法
**做法**:将 `checkpoint` / `rollback` 直接作为 `SessionManager` 的方法。
**否决原因**
1. 违反单一职责原则(SRP):`SessionManager` 承担 session 生命周期 + 检查点管理双重责任
2. 破坏独立可测试性:检查点逻辑与 `SessionManager` 耦合
3. `rollback` 返回后自动注册到 `SessionManager`,但调用方可能不需要注册
4. 应返回 `AgentSession` 让调用方决定如何处理
### 技术决策清单
| 编号 | 决策项 | 选择 | 理由 |
|------|--------|------|------|
| D1 | 序列化方式 | `SessionSnapshot` 独立 struct | 不污染 `AgentSession`,序列化逻辑与运行逻辑分离 |
| D2 | 并发模型 | `tokio::sync::Mutex` | 安全跨 `.await`,与 `AgentSession` 现有模式一致 |
| D3 | 模块拆分 | `Checkpointer` 独立 + `SessionManager` 组合 | 独立可测,SRP 合规 |
| D4 | 存储格式 | 全量 JSON | 简洁可靠,ponytail>500 轮再优化为增量 |
| D5 | Key 命名 | `session:{id}:meta` / `ckpt:{id}:{ckpt_id}` | 与 `slot_data:` 风格一致,prefix 查询友好 |
| D6 | Checkpoint 触发 | `SessionManager` 封装方法中自动;同步写入 + `tracing::error!` 记录失败 | `AgentSession` 保持纯净;不提供强持久化保证(显式调 `checkpointer.checkpoint()` 确认) |
| D7 | 序列化兼容 | `#[serde(default)]` 宽松 | 防前向破坏,新增字段自动兼容旧快照 |
| D8 | 流式 checkpoint 时序 | 仅在 `finalize_turn` 时创建 checkpoint | `submit_turn_stream` 返回流时不做 checkpoint;客户端断开后不留下半成品 checkpoint 污染 |
| D9 | `SessionManager` trait | 不需要 | YAGNI,无多后端需求 |
| D10 | `CostTracker` / `ContextSlot` / `MergeStrategy` derive | 加 `Clone` + `Serialize` / `Deserialize` | 共约 7 行改动,支持快照序列化 |
### MVP 范围
| 做(Phase 17 首批) | 推迟 |
|---------------------|------|
| ① `SessionManager`: `create` / `get` / `create_child` / `children` / `parent` / `destroy` / `replace` / `recover` | ① `destroy_subtree` — 首次只做单节点 `destroy`。父被销毁后子 session 的 `parent()` 返回 `None`(允许孤儿)。调用方如需级联删除应自行遍历。 |
| ② `Checkpointer`: `checkpoint` / `rollback` / `list_checkpoints` / `delete_all` | ② `tree()``children()` + `parent()` 组合查询在 v0.3 够用;Phase 18 SubAgent Dispatch 需要全量树快照时再补。 |
| ③ `SessionSnapshot` + `to_snapshot()` / `from_snapshot()`(位于 `engine/snapshot.rs`+ `restore_memory()` | ③ `Checkpointer::fork` — 推迟理由:`fork` 底层可拆解为 `rollback` + `create_child`,当前 Checkpointer + SessionManager 已提供原始能力。`fork` 作为高层 API 等价于约 30 行组合代码,风险可控延后到 Phase 18。若产品认为 fork 是 time-travel MVP 的必要项,可重新划入 Phase 17。 |
| ④ `EngineError`(含 `MemoryError` 透传) | |
| ⑤ 涉及的 derive 改动(`CostTracker` + `ContextSlot` + `MergeStrategy` | |
**变更记录**(审查修复):
- `create()` / `create_child()` 返回类型改为 `Result<String, EngineError>`
- `get()` 改为仅内存查询,新增 `recover()` 显式恢复方法
- 新增 `replace()` 方法支持 rollback 后无缝切换
- MVP 推迟列补充 `tree()`(含推迟理由)、完善 `destroy_subtree`(定义孤儿语义)、
补充 `fork` 推迟理由(含技术拆解和产品权衡)
---
## 推荐方案
### 架构概览
```
┌──────────────────────────────────────────────┐
│ Engine │
│ ┌────────────────┐ ┌──────────────────┐ │
│ │ SessionManager │──│ Checkpointer │ │
│ │ │ │ │ │
│ │ create() │ │ checkpoint() │ │
│ │ get() │ │ rollback() │ │
│ │ create_child() │ │ list_checkpoints│ │
│ │ children() │ │ │ │
│ │ parent() │ └──────────────────┘ │
│ │ destroy() │ │
│ └────────┬───────┘ │
│ │ 组合 │
│ │ 持有 │
│ ▼ │
│ ┌────────────────┐ │
│ │ MemoryStore │ ── 存储后端 │
│ └────────────────┘ │
└──────────────────────────────────────────────┘
┌──────────────────┐
│ SessionSnapshot │ ── 可序列化的状态快照
│ (to/from │
│ AgentSession) │
└──────────────────┘
```
### 模块划分
**新增文件**5 个):
```
src/engine/
├── mod.rs # 约 30 行:模块根 + pub use 重导出
├── session_manager.rs # 约 300 行:SessionManager 实现(含 replace/recover
├── checkpointer.rs # 约 220 行:Checkpointer 实现
├── snapshot.rs # 约 50 行:SessionSnapshot + SessionMemoryEntry 定义
└── error.rs # 约 70 行:EngineError 枚举
```
**修改文件**5 个):
| 文件 | 改动量 | 内容 |
|------|--------|------|
| `src/agent/session.rs` | +~80 行 | `to_snapshot()` / `from_snapshot()` / `restore_memory()` |
| `src/agent/context.rs` | +4 行 | `ContextSlot` + `MergeStrategy``Serialize` / `Deserialize` |
| `src/llm/types/usage.rs` | +3 行 | `CostTracker``Clone` + `Serialize` / `Deserialize` |
| `src/lib.rs` | +2 行 | `pub mod engine` 声明 |
| `examples/engine_demo.rs` | +~100 行(新增) | 端到端示例(含 rollback + replace 流程) |
### SessionSnapshot(位于 `engine/snapshot.rs`
设计决策:`SessionSnapshot` 是 engine 层为持久化引入的序列化 DTO,定义在 `engine/snapshot.rs` 而非 `agent/session.rs`,保持依赖方向为 `engine → agent`
```rust
/// SessionMemory 条目的可序列化形式(保留元数据与时间戳)。
#[derive(Serialize, Deserialize, Clone)]
struct SessionMemoryEntry {
pub value: String,
#[serde(default)]
pub metadata: serde_json::Value,
#[serde(default)]
pub created_at: Option<i64>, // Unix 时间戳秒;Option 兼容旧快照
}
/// AgentSession 的可序列化快照。
///
/// 不持有 `Arc<dyn Agent>``Arc<RuntimeBundle>` —— 这两个由调用方在
/// `from_snapshot()` 时注入。所有字段使用 `#[serde(default)]` 确保前向兼容。
///
/// **变更记录**(审查修复):
/// - 位置从 `agent/session.rs` 移至 `engine/snapshot.rs`
/// - `session_memory_data``HashMap<String, String>` 改为 `HashMap<String, SessionMemoryEntry>`
/// 保留 metadata 和 created_at,避免恢复后时间戳丢失
#[derive(Serialize, Deserialize, Clone)]
pub(crate) struct SessionSnapshot {
pub session_id: String,
pub agent_name: String,
pub turn_index: u32,
#[serde(default)]
pub cost_so_far: CostTracker,
#[serde(default)]
pub slots: HashMap<String, ContextSlot>,
pub current_slot_id: String,
pub last_summary_turn: Option<u32>,
#[serde(default)]
pub session_memory_data: HashMap<String, SessionMemoryEntry>,
}
```
### AgentSession 扩展方法
```rust
impl AgentSession {
/// 将当前状态拍平为 SessionSnapshot。
///
/// **需要 async**:因为 session_memory 的数据存储在 `MemoryStore` 中,读取需要异步 I/O。
/// 可通过 `SessionMemory::list_entries()` 获取完整条目(含 metadata/created_at):
///
/// ```ignore
/// let entries = self.session_memory.list_entries().await?;
/// for (key, value, metadata, created_at) in entries {
/// map.insert(key, SessionMemoryEntry { value, metadata, created_at: Some(created_at) });
/// }
/// ```
/// `from_snapshot` 保持同步(构造器不应做 I/O),`to_snapshot` 做 async(快照输出可 I/O)—
/// 两个方向不矛盾,设计上各自成立。
pub async fn to_snapshot(&self) -> SessionSnapshot {
// 拍平 session_memory → HashMap<String, SessionMemoryEntry>(通过 list_entries
// 复制 slots / cost_so_far / turn_index 等可序列化字段
}
/// 从 SessionSnapshot + agent + bundle 重建 AgentSession。
///
/// **纯同步重建**:只做内存数据结构恢复(slots/turn_index/cost_so_far 等),
/// 不执行任何 I/O。session_memory 的持久层恢复由 `restore_memory()` 完成。
///
/// 调用方负责:
/// - 提供与 `agent_name` 对应的 `Arc<dyn Agent>`
/// - 提供合法的 `Arc<RuntimeBundle>`
///
/// 返回 `Result` 以传播序列化反序列化错误(如 JSON 格式不兼容)。
pub fn from_snapshot(
snapshot: SessionSnapshot,
agent: Arc<dyn Agent>,
bundle: Arc<RuntimeBundle>,
) -> Result<Self, EngineError> {
// session_memory_data 存入临时字段(不写 store)
// 重建 slots HashMap
// 恢复 turn_index / cost_so_far / last_summary_turn
}
/// 将 snapshot 中的 session_memory_data 写回持久层。
/// 从 `from_snapshot()` 中剥离的异步操作,调用方显式 await。
/// 放置在 `restore_memory` 而非构造函数中,确保构造函数是纯同步的。
///
/// **错误处理**:逐条写入,某条失败时返回 Err 但不回滚已写入的条目。
/// 调用方可选择重试或忽略(不影响 AgentSession 内存状态)。
pub async fn restore_memory(&self) -> Result<(), EngineError>;
}
```
**标准使用流程**
```rust
// rollback:四步走
let snapshot = cp.rollback_load(session_id, ckpt_id).await?; // ① 从存储读
let session = AgentSession::from_snapshot(snapshot, agent, bundle)?; // ② 同步重建
session.restore_memory().await?; // ③ 恢复持久层
sm.replace(session_id, session).await?; // ④ 注册到 Manager
```
**checkpoint 流程**(自动或显式调用):
```rust
// checkpoint 内部:
let snapshot = session.to_snapshot().await; // async:从 MemoryStore 读取 session_memory
cp.save(snapshot).await?;
```
### Checkpointer 公开 API
```rust
/// 检查点元数据。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CkptMeta {
pub ckpt_id: String,
pub session_id: String,
pub turn_index: u32,
pub created_at: u64, // Unix 时间戳,秒
}
/// Time-travel 检查点管理器。
///
/// **不依赖 SessionManager**,可独立使用。直接操作 MemoryStore。
/// 存储 key 格式:`ckpt:{session_id}:{ckpt_id}` → SessionSnapshot JSON
pub struct Checkpointer {
store: Arc<dyn MemoryStore>,
}
impl Checkpointer {
/// 创建新检查点。返回 ckpt_id。
pub async fn checkpoint(&self, session: &AgentSession) -> Result<String, EngineError>;
/// 回滚到指定检查点。返回恢复后的 AgentSession。
///
/// 调用方需提供 `agent``bundle`(与 SessionSnapshot 反序列化的要求一致)。
/// rollback 不自动注册到任何 SessionManager——调用方决定如何处理返回的 session。
pub async fn rollback(
&self,
session_id: &str,
ckpt_id: &str,
agent: Arc<dyn Agent>,
bundle: Arc<RuntimeBundle>,
) -> Result<AgentSession, EngineError>;
/// 列出某 session 的所有检查点(按创建时间降序)。
pub async fn list_checkpoints(&self, session_id: &str)
-> Result<Vec<CkptMeta>, EngineError>;
/// 删除某 session 的所有检查点(session 被 destroy 时调用)。
pub async fn delete_all(&self, session_id: &str) -> Result<(), EngineError>;
}
```
**注意**`Checkpointer::fork()` 推迟到 Phase 18(详见 MVP 范围表)。
**关于 Checkpointer 的独立可用性**Snapshot 数据的读写(`checkpoint` / `list_checkpoints`)不依赖 SessionManager,可直接用 `Checkpointer` 操作 MemoryStore。但 `rollback()` 重建 AgentSession 需要调用方提供与 session_id 匹配的 `Arc<dyn Agent>``Arc<RuntimeBundle>`——调用方需自行管理 agent→session 的映射(或通过 `SessionMeta.agent_name` 查询注册表)。
### SessionManager 公开 API
```rust
/// SessionManager 配置。
pub struct SessionManagerConfig {
/// 每次 submit_turn 后是否自动 checkpoint(默认 true)。
pub auto_checkpoint: bool,
/// 默认 RuntimeBundle,用于从存储重建 session 时的 bundle 注入。
/// 如果为 None`recover()` 需要调用方手动传入 bundle。
pub default_bundle: Option<Arc<RuntimeBundle>>,
}
impl Default for SessionManagerConfig {
fn default() -> Self {
Self {
auto_checkpoint: true,
default_bundle: None,
}
}
}
/// Session 生命周期管理器。
///
/// 组合持有 Checkpointer,提供 session 的 CRUD、树形关系查询和自动检查点。
/// 内部用 `HashMap<String, Arc<tokio::sync::Mutex<AgentSession>>>` 管理活跃 session。
/// 存储 key 格式:`session:{session_id}:meta` → SessionMeta JSON
///
/// **锁契约**
/// - 所有写操作(create/destroy/replace)内部先完成 HashMap 操作,释放 RwLock 后再调用
/// Checkpointer/MemoryStore 的异步 I/O。调用方不应假设某个操作持有跨 .await 点的锁。
/// - `get()` 返回 `Arc<Mutex<AgentSession>>` 后立即释放 RwLock 读锁,调用方持有的是
/// session 级别的 Mutex 锁而非管理器级别的锁。
pub struct SessionManager {
sessions: RwLock<HashMap<String, Arc<tokio::sync::Mutex<AgentSession>>>>,
checkpointer: Checkpointer,
store: Arc<dyn MemoryStore>,
config: SessionManagerConfig,
}
impl SessionManager {
/// 创建新 session。session_id 由内部自动生成(UUID v4)。
/// 持久化 SessionMeta 后注册到 sessions HashMap。
pub async fn create(
&self,
agent: Arc<dyn Agent>,
bundle: Arc<RuntimeBundle>,
) -> Result<String, EngineError>;
/// 从父 session 创建子 session(继承父的 RuntimeBundleArc::clone 共享引用)。
/// session_id 由内部自动生成(UUID v4)。
/// 如果 `parent_id` 不存在,返回 `EngineError::SessionNotFound(parent_id)`
pub async fn create_child(
&self,
parent_id: &str,
agent: Arc<dyn Agent>,
) -> Result<String, EngineError>;
/// 按 ID 获取 session(仅查内存,不自动从存储恢复)。
/// 冷启动时 `get()` 未命中返回 `EngineError::SessionNotFound`
/// 如需从存储恢复,使用 `recover()` 方法。
pub async fn get(
&self,
session_id: &str,
) -> Result<Arc<tokio::sync::Mutex<AgentSession>>, EngineError>;
/// 从存储恢复 session。需要调用方提供 agent 和 bundle(与 SessionSnapshot
/// 反序列化的要求一致)。
/// 恢复后自动注册到 sessions HashMap(与 create 的行为一致)。
pub async fn recover(
&self,
session_id: &str,
agent: Arc<dyn Agent>,
bundle: Arc<RuntimeBundle>,
) -> Result<Arc<tokio::sync::Mutex<AgentSession>>, EngineError>;
/// 替换 SessionManager 中指定 session_id 的 AgentSession 实例。
/// 用于 Checkpointer::rollback() 后的无缝切换:
/// ```ignore
/// let rolled_back = cp.rollback(sid, ckpt_id, agent.clone(), bundle.clone()).await?;
/// sm.replace(sid, rolled_back).await?;
/// ```
/// 内部执行:内存替换 + 写回 SessionMeta。
pub async fn replace(
&self,
session_id: &str,
session: AgentSession,
) -> Result<(), EngineError>;
/// 查询某 parent 的所有直接子 session 的 ID 列表。
pub async fn children(&self, parent_id: &str) -> Result<Vec<String>, EngineError>;
/// 查询某 child session 的 parent ID。
/// 如果 parent 已被销毁,返回 `Ok(None)`(允许孤儿 session 存在)。
pub async fn parent(&self, child_id: &str) -> Result<Option<String>, EngineError>;
/// 销毁 session:从内存移除 + 清理 SessionMeta + 清理检查点。
///
/// **父子关系处理**:允许孤儿 session 存在(子 session 的 parent_id 仍指向已删除的父,
/// 但 `parent()` 返回 `None`)。不递归删除子 session——调用方如需级联删除应自行遍历。
pub async fn destroy(&self, session_id: &str) -> Result<(), EngineError>;
/// 暴露 Checkpointer 引用(调用方可直接操作检查点)。
pub fn checkpointer(&self) -> &Checkpointer;
}
```
**变更记录**(审查修复):
- `create()` 返回类型从 `String` 改为 `Result<String, EngineError>`
- `create()` / `create_child()` session_id 统一为内部自动生成(UUID v4)
- `get()` 改为"仅查内存",新增 `recover()` 显式恢复方法
- 新增 `replace()` 方法支持 rollback 后的无缝替换
- `destroy()` 明确孤儿策略:允许孤儿存在,不递归删除
- `create_child()` 不再接受 `child_id` 参数(统一自动生成)
- 锁契约明确化为 struct doc comment
- `SessionManagerConfig` 新增 `default_bundle` 字段为后续扩展预留
### SessionMeta
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct SessionMeta {
pub session_id: String,
pub agent_name: String,
pub parent_id: Option<String>,
pub created_at: u64, // Unix 时间戳,秒
pub turn_count: u32,
}
```
### 存储 Key 命名
| Key 模式 | 内容 | 说明 |
|----------|------|------|
| `session:{session_id}:meta` | `SessionMeta` JSON | session 元数据,含 parent_id |
| `ckpt:{session_id}:{ckpt_id}` | `SessionSnapshot` JSON | 全量检查点,含 slots |
风格与 `ContextSlot``slot_data:{session_id}:{slot_id}` 一致:`前缀:session_id:后缀`
**关于两种持久化路径共存**`ContextSlot::save()`(增量消息持久化)和 `Checkpointer::checkpoint()`(全量快照)是互补的"增量基线 vs 全量备份"关系:
- `ContextSlot::save()` 每轮追加消息到 slot 存储(增量),是进程重启后消息不丢的基线
- `Checkpointer::checkpoint()` 全量序列化 session 状态(含所有 slot 消息),是 time-travel 回滚的快照
- rollback 时优先使用 checkpoint 的 snapshot 数据(一致性保证),不依赖 slot 持久化中的消息状态
### EngineError
```rust
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum EngineError {
/// 指定 session_id 不存在。
/// 适用场景:get() 内存未命中、create_child() parent 不存在、destroy() 操作不存在的 session。
#[error("Session not found: {0}")]
SessionNotFound(String),
/// 创建 session 时 ID 已存在(自动生成 ID 时通常不会触发)。
#[error("Session already exists: {0}")]
SessionAlreadyExists(String),
/// 指定 ckpt_id 不存在。
#[error("Checkpoint not found: {0}")]
CheckpointNotFound(String),
/// 存储错误(透传 MemoryError)。
/// Checkpointer 和 SessionManager 的所有 MemoryStore 操作通过此变体传播错误。
/// 与项目既有模式一致(对比 AgentError:直接 #[from] LlmError/ToolError/MemoryError)。
#[from]
#[error("存储错误: {0}")]
Memory(#[from] MemoryError),
/// 序列化/反序列化失败(serde_json/snapshot 格式错误)。
#[error("序列化错误: {0}")]
Serialization(String),
/// Agent 错误(透传 AgentError)。
#[from]
#[error("Agent 错误: {0}")]
Agent(#[from] AgentError),
}
```
### 并发模型
`SessionManager` 内部使用 `tokio::sync::RwLock` 保护 `sessions: HashMap`
```rust
pub struct SessionManager {
sessions: RwLock<HashMap<String, Arc<tokio::sync::Mutex<AgentSession>>>>,
// ... 其他字段
}
```
- `RwLock` 适合读多写少的场景(`get()` 高频 > `create()` / `destroy()`
- `get()` 返回 `Arc<Mutex<AgentSession>>` 后立即释放 RwLock 读锁,调用方持有的是 session 级别的 Mutex 锁而非管理器级别的锁。**不持有 RwLock 跨越 .await**
- 所有写操作(`create`/`destroy`/`replace`)先完成 HashMap 操作(持有写锁),释放 RwLock 后再调用 Checkpointer/MemoryStore 的异步 I/O
- 返回的 `AgentSession``Arc<tokio::sync::Mutex<AgentSession>>` 包裹,支持跨 `.await` 的安全可变访问
- `Checkpointer` 无锁(纯函数式操作 MemoryStore,依赖其内部实现)
---
## 实施建议
### 阶段划分(共 7 步)
```
Step 1: 前置 derive 改动 → step-1-branch
Step 2: EngineError + 模块骨架 → step-2-branch
Step 3: SessionSnapshot + 扩展 → step-3-branch
Step 4: Checkpointer → step-4-branch
Step 5: SessionManager → step-5-branch
Step 6: 自动 checkpoint 集成 → step-6-branch
Step 7: 示例 + 测试补强 → step-7-branch
```
#### Step 1:前置 derive 改动
- **文件**`src/llm/types/usage.rs``src/agent/context.rs`(×2
- **内容**
- `CostTracker``#[derive(Debug, Default)]``#[derive(Debug, Default, Clone, Serialize, Deserialize)]`
- `ContextSlot``#[derive(Debug, Clone)]``#[derive(Debug, Clone, Serialize, Deserialize)]`
- `MergeStrategy``#[derive(Debug, Clone)]``#[derive(Debug, Clone, Serialize, Deserialize)]`
- **验证**`cargo build --all-targets` 编译通过
#### Step 2EngineError + 模块骨架
- **文件**
- `src/engine/error.rs`(新增):`EngineError` 枚举定义
- `src/engine/mod.rs`(新增):模块根声明 + `pub use` 重导出 `EngineError` / `SessionManager` / `Checkpointer` / `CkptMeta`
- `src/lib.rs`(修改):加 `pub mod engine;`
- **验证**`cargo build --all-targets && cargo clippy --all-targets -- -D warnings`
#### Step 3SessionSnapshot + AgentSession 扩展
- **文件**`src/engine/snapshot.rs`(新增,来自 SA 审查建议)、`src/agent/session.rs`
- **内容**
- `src/engine/snapshot.rs``SessionMemoryEntry` 结构体(含 `value`/`metadata`/`created_at`)、`SessionSnapshot` 结构体定义(`pub(crate)`
- `src/agent/session.rs``pub async fn to_snapshot(&self) -> SessionSnapshot`**异步**,通过 `SessionMemory::list_entries()` 读取完整 session_memory 条目,复制 slots/cost_so_far/各标量字段)
- `pub fn from_snapshot(snapshot, agent, bundle) -> Result<Self, EngineError>`**纯同步**,不写 storesession_memory_data 暂存于内存,不写入持久层)
- `pub async fn restore_memory(&self) -> Result<(), EngineError>`(异步,将 from_snapshot 暂存的 session_memory_data 写回持久层;逐条写入,失败时记录 error 但不回滚已写入条目)
- `SessionMemory` 新增 `list_entries()` 方法返回 `Vec<(String, String, serde_json::Value, i64)>`(含 value/metadata/created_at),供 `to_snapshot` 消费
- **验证**:单元测试 roundtrip`to_snapshot().await``from_snapshot()` → 关键字段一致);`restore_memory` 幂等性测试
#### Step 4Checkpointer
- **文件**`src/engine/checkpointer.rs`(新增)
- **内容**
- `Checkpointer` 结构体(持有 `Arc<dyn MemoryStore>`
- `CkptMeta` 结构体
- `checkpoint()`:生成 ckpt_id(时间戳+计数器方案优先,ponytail;`uuid` 备选,需加依赖),`session.to_snapshot()` → JSON → 存 `ckpt:{session_id}:{ckpt_id}`
- `rollback_load()`(两阶段 rollback 的第一阶段):读取 JSON → 反序列化为 `SessionSnapshot` → 返回 `SessionSnapshot`
- 调用方拿到 `SessionSnapshot` 后,自行调用 `AgentSession::from_snapshot()`(纯同步)+ `restore_memory()`(异步)+ `SessionManager::replace()`(注册)
- `list_checkpoints()`prefix 查询 `ckpt:{session_id}:` → 反序列化 `CkptMeta`(从 snapshot JSON 中提取 `turn_index` / `created_at`)→ 按时间降序
- `delete_all()`prefix 查询 + 逐个删除
- **验证**3-5 个单元测试(checkpoint roundtrip / rollback_load 反序列化正确 / list 排序 / delete_all 幂等性)
#### Step 5SessionManager
- **文件**`src/engine/session_manager.rs`(新增)
- **内容**
- `SessionManagerConfig` 结构体(含 `auto_checkpoint: bool` + `default_bundle: Option<Arc<RuntimeBundle>>`
- `SessionMeta` 结构体(`pub(crate)`
- `SessionManager` 结构体(`RwLock<HashMap<...>>` + `Checkpointer` + `store` + `config`
- `create()`:内部自动生成 session_idUUID v4),`AgentSession::new()` → 存 `SessionMeta` → 注册到 `sessions` HashMap → `Ok(session_id)`
- `create_child()`:验证 parent 存在 → 自动生成 child session_id → 设置 `parent_id``create()` 流程
- `get()`**仅查内存**,未命中返回 `SessionNotFound`(不自动从存储恢复)
- `recover(session_id, agent, bundle)`:从存储读取 `SessionMeta` + 调 `Checkpointer` 最近 checkpoint → 重建 `AgentSession` → 注册到 HashMap
- `replace(session_id, session)`:内存替换(覆盖 Mutex 中的 AgentSession+ 写回 SessionMeta
- `children(parent_id)`prefix 查询 `session:{parent_id}:` → 过滤 `parent_id` 匹配 → 返回 child_id 列表
- `parent(child_id)`:读 `SessionMeta.parent_id`,父已被销毁时返回 `Ok(None)`
- `destroy(session_id)`:移除内存记录 → 删除 `SessionMeta` → 调 `Checkpointer::delete_all()`。**允许孤儿 session 存在**(不递归删除子 session)
- **验证**8-10 个单元测试(CRUD / recover 恢复 / replace 替换 / 树形关系 / session 隔离 / destroy 后 get 失败 / 孤儿 parent 返回 None
#### Step 6:自动 checkpoint 集成
- **文件**`src/engine/session_manager.rs`(扩展)
- **内容**
- 在 `SessionManager` 上添加封装方法 `submit_turn(session_id, user_input)`,内部:
1. `get(session_id)` 获取 session
2. `session.lock().await.submit_turn(user_input).await`
3. 如果 `config.auto_checkpoint == true`,同步调用 `checkpointer.checkpoint(&session).await`
- checkpoint 失败时通过 `tracing::error!` 记录,不阻断 `submit_turn``Ok` 返回
- 调用方如需强持久化保证,应显式调用 `checkpointer.checkpoint()` 并处理其 `Result`
- 流式路径:仅在 `finalize_turn` 时创建 checkpoint`submit_turn_stream` 返回流时不做 checkpoint
- 客户端断开连接导致 `finalize_turn` 未被调用时,保持上一个 checkpoint 的状态,不留下半成品 checkpoint 污染
- `auto_checkpoint` 配置控制开关
- **验证**:集成测试(`submit_turn``list_checkpoints` 中可查到新 checkpoint);关闭 `auto_checkpoint` 时不产生 checkpoint
#### Step 7:示例 + 测试补强 + Tracing 埋点
- **文件**`examples/engine_demo.rs`(新增,~100 行)
- **示例流程**
1. `SessionManager::create` → submit_turn
2. `Checkpointer::checkpoint` → list_checkpoints
3. `Checkpointer::rollback` + `AgentSession::restore_memory` + `SessionManager::replace`
4. 验证回滚后 turn_index 和 cost 恢复到 checkpoint 时刻
- **Tracing 埋点**(每个关键操作添加 `tracing` 日志,与项目既有风格一致):
- `Checkpointer::checkpoint()` 成功时:`tracing::info!(ckpt_id, turn_index, snapshot_size, "checkpoint created")`
- `Checkpointer::rollback()` 成功时:`tracing::info!(ckpt_id, session_id, turn_index, "rolled back")`
- `Checkpointer::list_checkpoints``tracing::debug!(session_id, count)`
- `SessionManager::create``tracing::info!(session_id, agent_name, "session created")`
- `SessionManager::destroy``tracing::info!(session_id, "session destroyed")`
- `SessionManager::get` / `recover` / `replace``tracing::debug!(session_id, ...)`
- 序列化错误 / 存储错误 → `tracing::error!(session_id, error, ...)`
- **补充测试**12-15 个):
- 空 slot checkpoint → rollback 后消息为空
- Destroy 后再 checkpoint → 返回 `SessionNotFound`
- 跨 session 检查点隔离(session A checkpoint 不影响 session B
- 序列化版本兼容(`#[serde(default)]` 兜底:缺少新字段的旧 snapshot 可正常反序列化)
- 10 并发 session 创建/销毁(RwLock 写锁争用验证)
- 父子 session 消息隔离(子 session 写数据不污染父 session
- `restore_memory` 幂等性(重复调用不产生重复数据)
- `from_snapshot` 纯同步验证(检查构造过程中无 async 调用路径)
- **验证**`cargo test --all-targets` 全绿 + `cargo clippy` 0 警告
### 高层建议
1. **Step 1 应先行独立提交**:derive 改动可能触发整个 crate 的重新编译,与其他步骤分开可减少冲突
2. **`get()` 只查内存,`recover()` 用于存储恢复**`get()` 不自动从存储重建(因无 `agent`/`bundle` 通道)。冷启动后先 `create()``get()`,或显式调用 `recover(session_id, agent, bundle)`
3. **ckpt_id 生成**:使用 `uuid::Uuid::new_v4()`(需在 `Cargo.toml` `[dependencies]` 中添加 `uuid = { version = "1", features = ["v4"] }`),或走无新增依赖方案:`format!("{}_{}", session_id, timestamp_nanos)` 结合单调计数器。建议优先走无新增依赖方案(ponytail)
4. **SessionManager 的 RwLock 粒度**:避免持写锁时调 `checkpointer`(涉及 I/O),锁范围应仅限于 HashMap 操作;`get()` 返回 `Arc` 后立即释放读锁
5. **自动 checkpoint 的持久化语义**:自动 checkpoint 采用`同步写入 + tracing::error! 记录失败` 模式(与 Phase 16 `maybe_summarize` 的静默模式一致)。**不提供强持久化保证**——调用方如需确保 checkpoint 成功,应显式调用 `checkpointer.checkpoint()` 并处理其 `Result`
6. **ContextSlot 持久化与 Checkpointer 快照的关系**:两者是"增量基线 vs 全量备份"的互补关系。`ContextSlot::save()` 负责每轮追加消息到 slot 存储(增量),`Checkpointer::checkpoint()` 负责全量序列化 session 状态(快照)。rollback 时优先使用 checkpoint 数据(一致性),不依赖 slot 持久化的消息状态
7. **`from_snapshot` 后调用 `restore_memory`**`AgentSession::from_snapshot()` 是纯同步的,不写 store;写回 session_memory 需要显式 `await session.restore_memory()`。三步全流程:`from_snapshot → restore_memory → replace`
### @Chart 提示
```
flowchart TD
subgraph "engine/"
SM[SessionManager]
CP[Checkpointer]
EE[EngineError]
end
subgraph "现有模块"
AS[AgentSession]
CS[ContextSlot]
CT[CostTracker]
MS[MemoryStore]
end
SM -->|组合持有| CP
SM -->|RwLock 保护| HM[(sessions HashMap)]
CP -->|持久化| MS
AS -->|to_snapshot| SS[SessionSnapshot]
SS -->|from_snapshot| AS
SM -->|get / create / destroy| AS
CP -->|checkpoint / rollback| AS
```
---
## 变更记录(审查修复)
| 日期 | 变更 | 触发 |
|------|------|------|
| 2026-07-15 | **🔴 `to_snapshot(&self)` 从同步改为 `pub async fn`** | SA 第 2 轮审查:同步方法无法 async 读 MemoryStore;需通过 `SessionMemory::list_entries()` 获取完整条目 |
| 2026-07-15 | **🔴 `docs/roadmap.md` Phase 17 交付物列表同步更新** | PM 第 2 轮审查:Roadmap 仍使用旧版范围(`tree()`/`fork()`/`destroy_subtree()` 未推迟,`create()` 签名未更新,缺 `recover()`/`replace()` |
| 2026-07-15 | **`SessionMemory::list_entries()` 新增方法** | SA 第 2 轮审查:`to_snapshot` 需要读取完整 entry 数据,现有 API 只返回 `Option<String>` |
| 2026-07-15 | **`to_snapshot` 注释清理:移除错误的 Cell/RefCell 方案** | SA 第 2 轮审查:同步方法中无法通过 Cell/RefCell 绕开 async |
| 日期 | 变更 | 触发 |
|------|------|------|
| 2026-07-15 | **🔴 `SessionManager::get()` 改为仅查内存,新增 `recover()` 显式恢复方法** | SA 审查:get() "从存储恢复"不可实现(无 agent/bundle 通道) |
| 2026-07-15 | **🔴 `from_snapshot()` 改为纯同步构造 + 分离 `restore_memory()` 异步方法;返回 `Result`** | SA 审查:异步 I/O + 返回 Self 导致脏数据 |
| 2026-07-15 | **🔴 `session_memory_data``HashMap<String, String>` 改为 `HashMap<String, SessionMemoryEntry>`** | SA 审查:拍平丢失 metadata/created_at |
| 2026-07-15 | **🔴 `EngineError` 新增 `Memory(#[from] MemoryError)` 透传变体** | SA 审查:缺少 MemoryError 透传 |
| 2026-07-15 | **🔴 `tree()` 在 MVP 推迟列补充(含推迟理由)** | PM 审查:Roadmap L781 需求完全未提及 |
| 2026-07-15 | **🔴 `fork()` 推迟理由补充(技术拆解 + 产品权衡)** | PM 审查:推迟理由不充分 |
| 2026-07-15 | **🔴 新增 `SessionManager::replace()` API 支持 rollback 后无缝切换** | PM 审查:rollback 后 session 无法替换到 Manager |
| 2026-07-15 | **`SessionSnapshot` 移至 `engine/snapshot.rs`** | SA 审查:DTO 应放在 engine 层,保持依赖方向 engine→agent |
| 2026-07-15 | **uuid 依赖修正:改为"时间戳+计数器优先,uuid 备选"** | SA 审查:文档声称"已有依赖"但 Cargo.toml 不含 |
| 2026-07-15 | **160KB 具体数字删除(替换为保守上限描述)** | SA 审查:无测量依据 |
| 2026-07-15 | **"关键假设(已验证)"改为"设计分析" + 验证方式** | PM 审查:"已验证"字面与实际不符 |
| 2026-07-15 | **流式 checkpoint 时序明确定义:仅在 `finalize_turn` 时创建** | PM 审查:时序未定义 |
| 2026-07-15 | **自动 checkpoint 语义:`tracing::error!` 模式,非强持久化** | SA 审查:fire-and-forget 不可靠 |
| 2026-07-15 | **`create()` 返回 `Result<String, EngineError>` + 统一自动生成 ID** | PM 审查:返回 String 不能表达错误 |
| 2026-07-15 | **`destroy()` 明确孤儿策略:允许孤儿,不递归删除,parent() 返回 None** | PM+SA 审查:孤儿语义未定义 |
| 2026-07-15 | **`create_child()` 不再接受 `child_id`(统一自动生成)** | PM 审查:ID 策略不一致 |
| 2026-07-15 | **`RuntimeBundle` 继承语义补充(`Arc::clone` 共享引用)** | PM 审查:继承语义未定义 |
| 2026-07-15 | **并发模型补充 RwLock 锁范围注释** | SA 审查:跨 await 风险缺文档 |
| 2026-07-15 | **Checkpointer 独立可用性约束标注** | SA 审查:rollback 重建需要 agent+bundle |
| 2026-07-15 | **ContextSlot 与 Checkpointer 两种持久化路径关系补充说明** | SA 审查:共存缺说明 |
| 2026-07-15 | **Step 7 扩充:示例流程 + 12-15 个边界测试 + Tracing 埋点规划** | SA 审查:缺 tracing 规划 |
| 2026-07-15 | **`SessionManagerConfig` 新增 `default_bundle` 字段** | PM 审查:未来扩展预留 |
| 2026-07-15 | **项目文件新增/修改数量同步更新(5 新增 + 5 修改,~725 行)** | 全部审查修复导致文件范围变化 |
## 参考来源
- Phase 10 方案文档:`docs/17-phase10-contextslot.md`ContextSlot 持久化设计,Phase 17 的前置依赖)
- Phase 16 方案文档:`docs/22-phase16-summary-auto-generation.md`(上一 Phase 的实施风格参考)
- 当前代码:`src/agent/session.rs`AgentSession 当前实现,`to_snapshot` / `from_snapshot` 扩展点)
- 当前代码:`src/agent/context.rs`ContextSlot 当前实现,derive 改动点)
- 当前代码:`src/llm/types/usage.rs`CostTracker 当前实现,derive 改动点)
- 当前代码:`src/lib.rs`(模块注册点)
- 当前代码:`src/agent.rs`(模块组织风格参考)
@@ -0,0 +1,700 @@
# Phase 18Agent 角色热切换与子代理调度
## 背景与目标
### 问题空间
agcore 已完整交付 Phase 0-17,具备 SessionManager 会话生命周期管理、会话树(父子层次)、Checkpointer 检查点、流式输出、ContextSlot 上下文分区、MemoryStore 持久化、摘要自动生成等能力。当前 Session 在 `create()` 时绑定一个 `Arc<dyn Agent>`,此后无法变更角色;会话间调度仅通过 `create_child()` + `submit_turn()` 手动编排,缺乏内建的子代理派发机制。
Phase 18 要解决两个正交但关联的问题:
1. **Agent 角色热切换**:运行时替换 session 绑定的 Agent,保留上下文(slot 历史、turn_index、session_memory、cost_so_far
2. **子代理调度**:在 SessionManager 上提供声明式的 `dispatch` / `dispatch_stream` / `dispatch_all` API,支持父子 session 间的 Memory 继承、bridge_keys 注入、并发控制、结构化回传
### 目标
- 提供 `SessionManager::switch_agent(session_id, new_agent)`,替换 `Arc<dyn Agent>`,全量保留 slot / turn_index / session_memory
- 提供 `DispatchConfig` / `SubTaskResult` / `SubTaskStreamEvent` 类型以及 `dispatch` / `dispatch_stream` / `dispatch_all` 三个核心方法
- 实现父转子三层级交互:父->子(Memory 快照继承 + bridge_keys)、子->父(SubTaskResult 结构化回传 + result_summary)、子<->子(shared namespace
- 产出 4 个端到端示例:`agent_switch_demo` / `sub_agent_dispatch_demo` / `bridge_keys_demo` / `dispatch_stream_demo`
### 依赖与优先级
- **依赖**Phase 17SessionManager + 会话树 + Checkpointer[高]
- **优先级**P0
- **预估规模**:约 720 行核心 + 210 行测试 + 450 行示例
- **审查修复**:第 1 轮审查修复(Finalize 方案重写 + 4 个 🔴 阻塞 + 8 个 🟡 改进)
---
## 当前状态分析
### 现有架构中的关键接入点
| 接入点 | 位置 | 可用性 | 分析 |
|--------|------|--------|------|
| `AgentSession.agent` | `src/agent/session.rs:53` | `pub` 字段 | 可直接替换,无需新增 setter [高] |
| `AgentSession.session_id` | `src/agent/session.rs:51` | `pub` 字段 | 子 session 创建后可读取 [高] |
| `AgentSession.session_memory` | `src/agent/session.rs:58` | `pub` 字段 | dispatch 后父可读子 memory [高] |
| `SessionManager::create_child(parent_id, agent)` | `src/engine/session_manager.rs:202-239` | `pub async` | dispatch 可直接复用,bundle 继承避免重复构造 [高] |
| `SessionMemory::list_entries()` | `src/agent/session_memory.rs:81-110` | `pub async` | 可获取全量条目用于父子继承 / bridge_keys 过滤 [高] |
| `SessionMemory::set_with_meta()` | `src/agent/session_memory.rs:61-79` | `pub async` | 子 session 写入继承数据时保留原始 metadata [高] |
| `CostTracker` | `src/llm/cycle.rs` | derive `Clone` | SubTaskResult 可直接 clone usage [高] |
| `SessionMeta` 持久化 | `src/engine/session_manager.rs:32-67` | `pub(crate)` | 以 `session:{id}:meta` key 存到 MemoryStoreswitch 后需更新 agent_name [高] |
| `SessionManager::save_session_meta()` | `src/engine/session_manager.rs:131-142` | `async fn` (非 pub) | switch_agent 需要类似的 meta 更新能力;考虑提取为 `pub(crate)` [高] |
| `SessionManager::destroy()` | `src/engine/session_manager.rs:495-512` | `pub async` | dispatch 失败时清理子 session 可直接复用 [高] |
| `SessionManager.sessions` | `src/engine/session_manager.rs:92` | `pub(crate)` RwLock<HashMap> | switch_agent 和 dispatch 的 get / replace 操作均依赖此字段 [高] |
| `EngineError` 枚举 | `src/engine/error.rs:16-46` | `#[non_exhaustive]` | 已有 6 个变体,需追加 DispatchFailed / SwitchFailed / SubAgentStreamError [中] |
| `futures-core` / `futures-util` | `Cargo.toml:17-18` | 已引入 | dispatch_stream 返回 `Pin<Box<dyn Stream>>` 所需依赖已就绪,无需新增 [高] |
### Agent trait 与 SessionManager 之间的关系
```
AgentSession {
agent: Arc<dyn Agent>, // 可替换
session_memory: SessionMemory, // 可继承(clone backend
slots: HashMap<String, ContextSlot>,
turn_index: u32,
cost_so_far: CostTracker,
// ... 其余内部字段
}
SessionManager {
sessions: RwLock<HashMap<String, Arc<Mutex<AgentSession>>>>,
checkpointer: Checkpointer,
store: Arc<dyn MemoryStore>,
config: SessionManagerConfig,
}
```
`AgentSession.agent``pub` 字段,这意味着 `switch_agent` 只需 `get()``lock()` → 替换 `agent` → 写回 meta。Route 明确、无架构阻力 [高]。
### 锁契约(需格外注意)
`src/engine/session_manager.rs:4-12` 记录了锁契约:**不持有 RwLock 跨越 `.await`**。所有 `.await` 点必须在 RwLock guard drop 之后。这意味着:
- `switch_agent`:读锁 `get()` 返回 `Arc<Mutex<AgentSession>>` 后释放,然后 lock session 级别的 Mutex → 替换 agent → 释放 Mutex → save_session_metaI/O[高]
- `dispatch`:读锁 `get()` 父 session → 释放 → `create_child`(内部写锁)→ lock 子 session → inherit memory → submit_turn → 释放 [高]
- 不会引入新的死锁风险 [高]
### 现有测试覆盖
SessionManager 已有 906 行(含 12 个测试),覆盖 create/get/destroy/create_child/replace/recover/children/parent/并发创建等场景:`src/engine/session_manager.rs:527-906`。Phase 18 新增测试不修改这些已有测试。
---
## 调研发现
### 1. bridge_keys 注入位置
**问题**bridge_keys 本质是父 session 想注入到子 agent prompt 中的上下文数据。它应该放在哪里?
**调研来源**
- `docs/note-opencode-subagent-dispatch.md` — 明确反对修改 Agent 的 `system_prompt()` [高]
- `src/agent/agent.rs:21``system_prompt()` 返回 `&str`,无状态变更能力 [高]
- `src/agent/session_memory.rs``SessionMemory::set()` 提供 key-value 写入,子 agent 可读 [高]
**结论**bridge_keys 通过 SessionMemory 副本继承 + 过滤注入,不碰 `system_prompt()`。子 agent 通过 `get_session_data(key)` 读取桥接数据 [高]。
### 2. SessionMemory 继承策略
**问题**:子 agent 启动时,父 session 的 SessionMemory 如何传递?
**方案 A —— 引用共享**:父子共享同一 `SessionMemory` 实例(Arc clone 后端)。优点是零拷贝,缺点是父子隔离被破坏 [中]。
**方案 B —— 快照副本**:父调用 `list_entries()` 获取全量条目,子通过 `set_with_meta()` 写入自己的 namespace。优点是隔离性强,缺点是 O(n) 拷贝开销 [高]。
**来源**
- `src/agent/session.rs:503-524``to_snapshot()` 已实现类似的 list_entries → HashMap 拍平 [高]
- `src/agent/session_memory.rs:61-79``set_with_meta()` 可保留原始 metadata [高]
- `docs/note-opencode-subagent-dispatch.md` — SA 建议副本策略 [中]
**结论**:采用方案 B(快照副本),隔离性优先。`inherit_session_memory` 内部使用 `list_entries()` → 按 `bridge_keys` 过滤 → `set_with_meta()` 写入子 namespace [高]。
### 3. dispatch_all 部分成功语义
**问题**:当一批子代理中部分失败时,dispatch_all 应该整体失败还是返回部分成功的 `Vec`
**来源**`docs/note-opencode-subagent-dispatch.md` — PM 和 SA 一致认为应返回部分成功语义 [高]。
**结论**:返回 `Vec<Result<SubTaskResult, EngineError>>`。调用方可迭代检查每个结果,失败条目保留 checkpoint 以便审计 [高]。
### 4. dispatch_stream 生命周期
**问题**`dispatch_stream` 需要返回一个流,流内部要做 `submit_turn_stream` + `finalize_active_stream`。session 所有权和生命周期如何管理?
**来源**
- `src/engine/session_manager.rs:390-412``submit_turn_stream` 的锁模式:短持锁获取流后立即释放 [高]
- `src/agent/session.rs:384-445``submit_turn_stream` 自身不持有跨 await 的锁 [高]
- `docs/note-opencode-subagent-dispatch.md` — SA 建议在 AgentSession 新增 `finalize_active_stream()` 内部方法 [中]
**结论**`dispatch_stream` 使用 `&Arc<Self>` 签名 + `tokio::spawn`。内部流管道:create_child → inherit_memory → submit_turn_stream → mpsc channel 转发事件 → 流消费完毕后调用 `finalize_active_stream()`。session 通过 `Arc<Mutex<AgentSession>>` 在 spawned task 中持有 [高]。
### 5. Cargo.toml 依赖分析
**来源**
- `Cargo.toml:17``futures-util = "0.3"` 已在依赖中 [高]
- `Cargo.toml:15``tokio-stream = "0.1"` 已在依赖中 [高]
- `Cargo.toml:16``futures = "0.3"` 已在依赖中 [高]
**结论**dispatch_stream 所需的 `StreamExt` / `ReceiverStream` 所需的基础设施已全部就绪,无需新增任何依赖 [高]。
---
## 可选方案
### 方案 Aswitch_agent 作为 AgentSession 方法 vs SessionManager 方法
| 维度 | A1: AgentSession 方法 | A2: SessionManager 方法 |
|------|-----------------------|------------------------|
| 实现位置 | `agent/session.rs` | `engine/switch.rs` |
| 职责归属 | session 实例级 | 管理器级 |
| 能否更新 SessionMeta | 不能(无 store 引用) | 能(有 store + checkpointer |
| 能否做自动 checkpoint | 不能(无 checkpointer | 能 |
| 与 create_child / replace 对齐 | 不对齐(create 在 SM) | 对齐(都在 SM |
**来源**
- `src/agent/session.rs:49-71` — AgentSession 不持有 store / checkpointer 引用 [高]
- `src/engine/session_manager.rs:325-347``replace()` 是 SM 方法,涉及 meta 持久化 [高]
- roadmap lines 838 — `switch_agent(session_id, new_agent)` 签名暗示 SM 方法 [中]
**结论**:采用 A2SessionManager 方法)。AgentSession 没有 store 引用,无法更新 SessionMeta。独立文件 `engine/switch.rs` 作为 SessionManager 的 impl 块。
### 方案 Bbridge_keys 注入方式
| 维度 | B1: SessionMemory 副本继承 + 过滤 | B2: 修改 Agent trait |
|------|------------------------------------|----------------------|
| 系统 prompt 侵入性 | 无 | 需新增 `set_bridge_data()` 方法 |
| switch_agent 兼容性 | 天然兼容(与 agent 解耦) | switch 后需重新注入 |
| 实现复杂度 | 一个私有辅助函数 | 需改 Agent trait + 所有实现 |
| 测试增量 | 小(只测 `inherit_session_memory` | 大(需测所有 Agent impl |
**来源**
- `src/agent/agent.rs:16-30` — Agent trait 当前仅 3 个方法,简洁 [高]
- `docs/note-opencode-subagent-dispatch.md` — "bridge_keys 通过 slot 注入,不修改 Agent system_prompt" [高]
**结论**:采用 B1。隔离关注点:Agent 负责"角色"SessionMemory 负责"桥接数据"。
### 方案 Cdispatch_stream 返回类型
| 维度 | C1: `Pin<Box<dyn Stream<Item=SubTaskStreamEvent>+Send>>` | C2: 自定义 struct 包装 |
|------|----------------------------------------------------------|------------------------|
| 与现有 API 一致性 | 与 `submit_turn_stream` 一致 [高] | 不一致 |
| 调用方灵活性 | 直接 `.next()` + StreamExt | 需解包装 |
| 实现复杂度 | 直接返回 stream | 需额外 struct + 方法 |
| 可组合性 | 高(可直接 map/filter/collect | 低 |
**来源**
- `src/engine/session_manager.rs:390-412``submit_turn_stream` 返回 `Pin<Box<dyn Stream<Item=StreamEvent>+Send>>` [高]
**结论**:采用 C1。保持一致的模式,调用方可以 `StreamExt::collect` / `map` 等。
### 否决方案
| 方案 | 否决原因 |
|------|----------|
| switch_agent 做自动 checkpoint | 与 auto_checkpoint 语义不一致(submit_turn 才触发),用户可手动 checkpoint。来源:`src/engine/session_manager.rs:357-384` auto_checkpoint 仅在 submit_turn/finalize_turn 触发 |
| AgentSession 中的 `Agent``Box<dyn Agent>` | 与现有 `Arc<dyn Agent>` 不一致,且 SessionSnapshot 不序列化 agent`src/agent/session.rs:502`)。来源:`src/agent/session.rs:53` |
| dispatch_all 返回所有成功再返回 | 需要调用方等待全部完成才能拿到第一个结果。Rust 已有 `JoinSet` / `FuturesUnordered` 可选,但 v0.3 先保持简单 |
| child_memory 加额外权限控制 | 子 session 是父创建的,父天然有 destroy / read 权限。来源:`docs/note-opencode-subagent-dispatch.md` PM 明确"不做额外权限控制" |
| 子 session 失败时保留 checkpoint | `destroy()``checkpointer.delete_all``session_manager.rs:508`),不保留持久化残留。失败路径的调试信息通过 `tracing::error!` 日志记录 |
---
## 推荐方案
### 整体架构
```
SessionManager (existing)
├── switch_agent(id, new_agent) → engine/switch.rs
├── dispatch(parent, agent, task, cfg) → engine/sub_agent.rs
├── dispatch_stream(parent, agent, task, cfg) → engine/sub_agent.rs
└── dispatch_all(parent, tasks, cfg) → engine/sub_agent.rs
```
`switch.rs``sub_agent.rs` 均为 SessionManager 的 `impl` 块文件,通过 `pub mod``engine/mod.rs` 中注册 [高]。
### 决策清单
| # | 决策 | 结论 | 理由 |
|---|------|------|------|
| D1 | switch_agent 位置 | SessionManager 方法,在 `engine/switch.rs` | 需 store 更新 SessionMetaAgentSession 无 store 引用 |
| D2 | bridge_keys 注入方式 | SessionMemory 副本继承 + 过滤,不碰 system_prompt | 概念正交,switch_agent 友好 |
| D3 | Memory 继承策略 | 快照副本(list_entries → set_with_meta | 父子隔离优先,O(n) 拷贝可接受 |
| D4 | dispatch_all 返回类型 | `Vec<Result<SubTaskResult, EngineError>>` | 部分成功语义,Rust-idiomatic |
| D5 | dispatch_stream 返回类型 | `Pin<Box<dyn Stream<Item=SubTaskStreamEvent>+Send>>` | 与 `submit_turn_stream` 一致 |
| D6 | switch_agent 的 lock 策略 | get() 读锁立即释放 → Mutex lock → 替换 → 释放 Mutex → I/O | 严格遵循已有锁契约 |
| D7 | switch checkpoint | 不自动 checkpoint | 与 `auto_checkpoint` 语义一致(仅 submit_turn/finalize_turn 触发) |
| D8 | dispatch 失败清理 | destroy 子 session | 不留僵尸 session |
| D9 | dispatch 失败时 checkpoint | destroy 清理全部(含 checkpoint | `destroy()` 内部调 `checkpointer.delete_all`,不留持久化垃圾 |
| D10 | dispatch_all 并发控制 | `tokio::sync::Semaphore` | 轻量、内建、语义清晰 |
| D11 | dispatch_all / dispatch_stream 签名 | `self: &Arc<Self>` | 满足 `tokio::spawn` `'static` 约束 |
| D12 | 子 session 创建时 bundle | 从父 session 的 `RuntimeBundle` clone | 复用 `create_child` 已有逻辑 |
### 设计理由详述
**D1 为什么 switch_agent 必须放在 SessionManager 下**:因为 switch 后需要更新持久化的 SessionMeta`agent_name` 变化),而 `save_session_meta` 需要 `&self.store`。AgentSession 不持有 store 引用(纯内存对象)。如果放在 AgentSession 上,要么给它加 store 引用(开历史倒车),要么让调用方手动调 `save_session_meta`(容易遗漏)[高]。
**D3 为什么选副本而非引用**:隔离性优先原则。父 session 可能在子运行期间 `set` 新数据,引用共享会导致子看到父的运行时中间状态;副本确保子看到的是 dispatch 时刻的稳定快照。性能方面,SessionMemory 条目数通常 < 100O(n) 拷贝可忽略 [高]。
**D5 dispatch_stream 方案**:核心挑战是 session 生命周期管理和消息 finalize。方案使用 spawn task + `tokio::sync::mpsc::unbounded_channel`。spawn task 通过事件追踪重建消息列表:记录 `user_input` 作为首条 `UserMessage`,从 `StreamEvent::ToolExecutionCompleted` 事件提取工具结果,从 `StreamEvent::MessageComplete` 提取完整响应。流结束后直接调用 `AgentSession::finalize_turn(response, new_messages).await`。当 receiver 端 drop 时 sender 侧的 `send()` 错误会被捕获,task 内清理。`dispatch_stream` 返回的 stream 发出 `SubTaskStreamEvent::ChildCreated`(先导)+ `Stream(StreamEvent)`(中间,透传)+ `Completed(SubTaskResult)`(最终),消费者无需额外调 finalize [高]。
## 实施建议
### 阶段划分
共 9 个步骤,建议依次实施,不可并行。总预估时间由实现者在实施时评估。
#### Step 1 — `error.rs` 扩展
**文件**`src/engine/error.rs`
**内容**EngineError 追加 3 个变体
- `DispatchFailed(String)` — 子代理调度通用失败
- `SwitchFailed(String)` — 角色切换失败
- `SubAgentStreamError { child_id: String, detail: String }` — 流式调度中的子代理错误
**验证**`cargo build` 成功。
**注意**:已存在的 `#[non_exhaustive]` 属性确保这不是 breaking change [高]。
#### Step 2 — `session.rs` 无变更
**文件**`src/agent/session.rs` — 不修改现有 API。
**审查发现**:第一轮审查确认 `finalize_active_stream()` 假设不成立。`submit_turn_stream``session.rs:384-445`)返回 stream 后 `LlmCycle` 即被 drop`cycle.rs:647` 通过 `std::mem::take` 移出消息),不存在"active stream 内部状态"可读取。
**结论**:改为在 `dispatch_stream` 的 spawn task 中**从 StreamEvent 序列重建消息列表**,直接调用已有的 `finalize_turn(response, new_messages).await`。详见 Step 7 第 3 项。
**验证**:不修改 `session.rs`Step 7 实施前 `cargo build` 可通过。
#### Step 3 — `switch.rs`
**文件**`src/engine/switch.rs`
**内容**
```rust
impl SessionManager {
/// 热切换指定 session 的 Agent 角色。
///
/// - 保留 slot 历史 / turn_index / session_memory / cost_so_far
/// - 自动更新 SessionMeta 中的 agent_name(保持原始 created_at / parent_id
/// - 不自动 checkpoint(与 `auto_checkpoint` 语义一致:仅 submit_turn 触发)
/// - **注意**: 切换后新的 system_prompt 将与已有对话历史共存。
/// 建议在切换后发送一条明确的上下文过渡提示
/// (如"你现在以新角色 X 的身份继续对话")作为切换后的首条输入。
/// - **安全提示**: `AgentSession.agent``pub` 字段可直接访问,
/// 绕过 `switch_agent` 直接修改会导致 SessionMeta 中的 agent_name
/// 与内存状态不一致,请始终使用此方法。
pub async fn switch_agent(
&self,
session_id: &str,
new_agent: Arc<dyn Agent>,
) -> Result<(), EngineError> {
// 1. get sessionRwLock 读锁,返回后释放)
let session = self.get(session_id).await?;
// 2. lock Mutex,替换 agent,读 name + turn_index
let (agent_name, turn_index) = {
let mut guard = session.lock().await;
guard.agent = new_agent;
(guard.agent.name().to_string(), guard.turn_index())
}; // 释放 Mutex
// 3. 读取原始 SessionMeta(用于保留 created_at / parent_id
let existing_meta = self
.load_session_meta(session_id)
.await?
.ok_or_else(|| EngineError::SessionNotFound(session_id.to_string()))?;
// 4. 构造新 meta 并持久化(I/O,无锁)
let meta = SessionMeta {
session_id: session_id.to_string(),
agent_name,
parent_id: existing_meta.parent_id,
created_at: existing_meta.created_at,
turn_count: turn_index,
};
self.save_session_meta(&meta).await?;
tracing::info!(
session_id = %session_id,
agent_name = %meta.agent_name,
previous_agent = %existing_meta.agent_name,
"agent switched"
);
Ok(())
}
}
```
**测试**(预计 4 个):
1. 基本切换:switch 后 `agent.name()` 返回新 name
2. 上下文保留:turn_index / session_memory / slot 历史均不变
3. SessionMeta 持久化:`load_session_meta` 验证 agent_name 已更新
4. 不存在的 session:返回 `SessionNotFound`
**验证**`cargo test --all-targets` + clippy
#### Step 4 — `sub_agent.rs` 类型
**文件**`src/engine/sub_agent.rs`
**内容**3 个类型定义
```rust
/// 子代理调度配置。
#[derive(Debug, Clone)]
pub struct DispatchConfig {
/// 最大并发数(dispatch_all 用)。默认 10。
pub max_concurrency: usize,
/// 是否继承父 SessionMemory。默认 true。
pub inherit_session_memory: bool,
/// 桥接 key 列表:
/// - `None` = 不继承任何父 SessionMemory
/// - `Some(vec![])` = 继承全部父 SessionMemory
/// - `Some(keys)` = 仅继承指定的 keys
/// 默认 `None`(零继承),显式选择加入。
pub bridge_keys: Option<Vec<String>>,
/// 子↔子共享 namespace。如果为 `Some(prefix)`
/// 子 agent 可通过 `session.get_session_data(key)` 访问
/// `shared:{prefix}:{key}` 命名空间的数据。
/// 默认 `Some(parent_session_id)`
pub shared_namespace: Option<String>,
}
impl Default for DispatchConfig {
fn default() -> Self {
Self {
max_concurrency: 10,
inherit_session_memory: true,
bridge_keys: None,
shared_namespace: None,
}
}
}
/// 子代理执行结果。
///
/// dispatch 成功后子 session **保留在 SessionManager 中**,调用方可
/// 通过 `sm.get(&result.child_id)` 获取子 session 引用,进而通过
/// `session_memory()` 读取子 SessionMemory(如 "result_summary")。
#[derive(Debug)]
pub struct SubTaskResult {
/// 子 session ID。可通过此 ID 在 SessionManager 中读取子 session。
pub child_id: String,
/// LLM 最终响应。
pub response: MessageResponse,
/// 本次调用的 token 用量。
pub usage: CostTracker,
/// 可选摘要(读取子 session_memory 中的 "result_summary")。
pub summary: Option<String>,
}
```
```rust
/// 流式子代理调度事件。
#[derive(Debug)]
pub enum SubTaskStreamEvent {
/// 子 session 已创建(携带 child_id)。
ChildCreated { child_id: String },
/// LLM 流事件(透传)。
Stream(StreamEvent),
/// 执行完成(携带完整结果)。
Completed(SubTaskResult),
}
```
`SubTaskStreamEvent` 需实现 `Display``std::error::Error``Completed``ChildCreated` 不触发错误路径,`Display` 仅用于调试日志)[中]。
**验证**`cargo build`
#### Step 5 — `sub_agent.rs dispatch` 核心
**文件**`src/engine/sub_agent.rs`
**内容**
```rust
impl SessionManager {
/// 私有辅助:从父 session memory 继承条目到子 session。
///
/// **一致性模型**:捕获的是调用时刻的父 session_memory 快照。
/// 即使在 `list_entries()` 返回后、`set_with_meta()` 写入前
/// 父 session 被并发写入新数据,子 session 也**不会**看到这些
/// 新数据(快照副本的内生特征)。[审查确认]
async fn inherit_session_memory(
&self,
parent_id: &str,
child_id: &str,
config: &DispatchConfig,
) -> Result<(), EngineError> { /* ... */ }
/// 派发一个子任务,返回结构化结果。
pub async fn dispatch(
&self,
parent_id: &str,
sub_agent: Arc<dyn Agent>,
task: impl Into<String>,
config: DispatchConfig,
) -> Result<SubTaskResult, EngineError> { /* ... */ }
}
```
**dispatch 流程**
1. `create_child(parent_id, sub_agent)` → 获取 child_id
2. 若 `config.inherit_session_memory == true``inherit_session_memory(parent_id, child_id, config)`
3. `submit_turn(child_id, task)` → 获取 response
4. 读取 `"result_summary"`(可选)
5. 返回 `SubTaskResult`(子 session 保留在 SessionManager 中,可通过 `sm.get(&child_id)` 读取 child_memory
6. 失败路径:`let _ = self.destroy(&child_id).await; tracing::error!(...)`(静默吞掉清理错误,**原始 EngineError 优先**`destroy` 会清理子 session 的 SessionMeta + checkpoint 条目,不留僵尸)
**测试**(预计 5 个):
1. 基本调度:子 agent 返回预期响应
2. bridge_keys 过滤:仅指定的 key 被继承
3. memory 继承:父 set 的值子可读到
4. submit_turn 失败:错误传播 + 子 session 被销毁
5. 无效 parent_id:返回 `SessionNotFound`
**验证**`cargo test --all-targets`
#### Step 6 — `sub_agent.rs dispatch_all`
**文件**`src/engine/sub_agent.rs`
**内容**
```rust
impl SessionManager {
/// 并行派发一批子任务。
pub async fn dispatch_all(
self: &Arc<Self>,
parent_id: &str,
tasks: Vec<(Arc<dyn Agent>, String)>,
config: DispatchConfig,
) -> Vec<Result<SubTaskResult, EngineError>> { /* ... */ }
}
```
**设计要点**
- 使用 `tokio::sync::Semaphore` 限制并发数(默认 `config.max_concurrency`
- **Semaphore acquire 在 spawn 内**`let permit = semaphore.clone().acquire_owned().await;` — permit 所有权转移到 spawned task。避免 spawn N 个 task 时全量分配 Future 内存 [审查修复]
- 每个 task `tokio::spawn` + `Arc<Self>` clone
- 内部调用 `dispatch` 的同类逻辑(create_child → inherit → submit_turn
- **indexed 收集**:预分配 `Vec<Option<Result<...>>>``tasks` 索引填入,维持输入顺序。不使用排序(排序需等所有 child_id 生成后)[审查修复]
- 每个结果独立:`Ok(SubTaskResult)``Err(EngineError)`
**测试**(预计 4 个):
1. 并行 3 个全部成功
2. 部分失败(MockProvider 对特定 task 返回错误)
3. Semaphore 上限验证(max_concurrency=1 时串行执行)
4. 空 tasks 列表
**验证**`cargo test --all-targets`
#### Step 7 — `sub_agent.rs dispatch_stream`
**文件**`src/engine/sub_agent.rs`
**内容**
```rust
impl SessionManager {
pub async fn dispatch_stream(
self: &Arc<Self>,
parent_id: &str,
sub_agent: Arc<dyn Agent>,
task: impl Into<String>,
config: DispatchConfig,
) -> Result<
Pin<Box<dyn Stream<Item = SubTaskStreamEvent> + Send>>,
EngineError,
> { /* ... */ }
}
```
**设计要点**
- 同步部分(lock 外):create_child + inherit_memory
- 获取 Stream 后通过 `tokio::sync::mpsc::unbounded_channel` 转发事件(与 LLM stream 内部背压策略一致,避免有界 channel 的 sender 阻塞风险)[审查修复]
- spawn task 持有 `Arc<Mutex<AgentSession>>` 消费 LLM stream
- **消息重建机制**(替代已移除的 `finalize_active_stream()`):[审查修复]
```
// 在 spawn task 中:
let mut new_messages: Vec<Message> = vec![Message::user_text(&task)];
let mut final_response: Option<MessageResponse> = None;
while let Some(event) = llm_stream.next().await {
// 转发事件到输出 channel
tx.send(SubTaskStreamEvent::Stream(event.clone()))?;
// 从 ToolExecutionCompleted 构造 ToolResult 消息
if let StreamEvent::ToolExecutionCompleted { tool_name, tool_call_id, input, output } = &event {
new_messages.push(Message::tool_result(tool_call_id, tool_name, output));
}
// 捕获最终响应
if let StreamEvent::MessageComplete(ref resp) = event {
final_response = Some(resp.clone());
}
}
// 流结束后,追加 assistant 消息并 finalize
if let Some(response) = &final_response {
new_messages.push(response.message.clone());
child_session.lock().await
.finalize_turn(response, new_messages).await?;
}
```
- 事件序列:`ChildCreated``Stream(StreamEvent)` × N → `Completed(SubTaskResult)`
- 消费者 drop receiver → unbounded channel sender 错误 → task 自动退出
**测试**(预计 4 个):
1. 事件序列验证:收到 ChildCreated → 至少一个 Stream → Completed
2. 错误传播:LLM 内部错误 → 正确映射到 error 事件
3. receiver droppeddrop receiver 后 task 正确退出,不 panic
4. finalize 正确性:Completed 中的 usage / summary 正确
**验证**`cargo test --all-targets`
#### Step 8 — `mod.rs` + 集成验证
**文件**`src/engine/mod.rs`
**内容**:追加 `pub mod switch;``pub mod sub_agent;` + `pub use`
```rust
pub mod checkpointer;
pub mod error;
pub mod session_manager;
pub mod snapshot;
pub mod switch; // <-- 新增
pub mod sub_agent; // <-- 新增
```
**验证**
1. `cargo test --all-targets` — 374+ 测试全部通过
2. `cargo clippy --all-targets -- -D warnings` — 0 警告
3. `cargo doc --no-deps` — 0 warning
#### Step 9 — 示例
**文件 1**`examples/agent_switch_demo.rs`(约 80 行)
- 创建 session → submit_turn(角色 A)→ switch_agent(角色 B)→ submit_turn(角色 B)→ 验证上下文保留
- 演示目的:证明 switch_agent 保留 slot 历史 / turn_index / session_memory
**文件 2**`examples/sub_agent_dispatch_demo.rs`(约 150 行)
- 父 session → dispatch_all 3 个子 agent(研究、写作、审校)→ 收集结果 → 父汇总
- 树形验证:`children(parent_id)` 返回 3 个子 ID
- 演示目的:多 agent 协作完整链路
**文件 3**`examples/bridge_keys_demo.rs`(约 140 行)
- 父设置 SessionMemorykey: "project_goal", "constraints")→ dispatch + bridge_keys → 子 agent 通过 `get_session_data` 读取
- 子↔子交互:父通过 `DispatchConfig.shared_namespace` 设定共享命名空间,子 A 写入 `shared:{parent_id}:fact_x`,子 B 通过约定 key 读取
- 演示目的:bridge_keys 过滤机制 + 父子数据桥接 + 子↔子共享 namespace
**文件 4 — 新增**`examples/dispatch_stream_demo.rs`(约 100 行)
- 父 session → dispatch_stream 单个子 agent → 消费 `SubTaskStreamEvent` 序列
- 验证收到 `ChildCreated` + 至少一个 `Stream` + `Completed` 事件
- 输出 `SubTaskResult.child_id` / `usage` / `summary`,验证消息重建和 finalize 正确性
- 演示 `receiver dropped` 场景:中途 drop receiver 后 task 正确退出不 panic
- 演示目的:dispatch_stream 的事件序列 + finalize 完整性验证
**验证**4 个示例全部 `cargo run --example` exit 0
### 高层实施建议
1. **Step 1 优先于所有步骤**:Error 扩展是所有后续步骤的基础,无依赖可并行 [高]
2. **Step 3 独立性强**switch_agent 不依赖 dispatch 的任何类型,可单独实施和测试 [高]
3. **Step 4 是 Step 5-7 的前置**:类型定义不依赖其他逻辑,建议在 Step 3 完成后立即实施 [高]
4. **Step 5-7 按复杂度递增**dispatch → dispatch_all → dispatch_stream。dispatch_all 复用 dispatch 的核心逻辑;dispatch_stream 是最复杂的,建议最后实施 [高]
5. **Step 8 集成验证不可跳过**clippy + doc 全量验证确保无回归 [高]
6. **Step 9 在所有核心完成后实施**:示例是验收标准的一部分,PM 确认 3 个递进示例 [中]
7. **全量测试密码**:实施过程中持续 `cargo test --all-targets`,不在最后统一修复 [高]
### 风险矩阵
| # | 风险 | 等级 | 可能性 | 对策 |
|---|------|------|--------|------|
| R1 | dispatch_stream 的消息重建:从 StreamEvent 序列重建 `new_messages` 的完整性 | 🟡 中 | 低 | 已移除 `finalize_active_stream()` 方案。spawn task 通过追踪 `ToolExecutionCompleted` / `MessageComplete` 事件重建消息列表。完整性由 `MessageComplete` 事件保证 |
| R2 | `tokio::spawn` `'static` + SessionManager 引用 | 🟡 中 | 低 | dispatch_all 和 dispatch_stream 签名已明确用 `&Arc<Self>`;调用方包装 `Arc<SessionManager>` |
| R3 | 子 session 创建成功但后续 submit_turn 失败 | 🟡 中 | 中 | dispatch 内 `destroy(child_id)` 放在 `?` 前确保清理;通过 `let child_id = ...;` 先绑定,再 `let r = submit_turn(...).await`,失败时 `destroy(&child_id).await?` 清理 |
| R4 | 部分失败时孤儿 checkpoint 数据 | 🟢 低 | 必然 | **正向利用**:保留用于调试审计,存储开销可忽略 |
| R5 | 并发 dispatch_all 中任务 panic | 🟡 中 | 低 | `tokio::spawn``JoinHandle` 通过 `.await` 捕获 panicpanic 传播到 `dispatch_all` 内作为 `Err` 返回 |
| R6 | bridge_keys 中不存在的 key | 🟢 低 | 中 | 静默跳过(与 `List_entries` 返回全量后再过滤,不存在的 key 自然不会出现在结果中) |
### 架构图(文本示意)
```
SessionManager
/ | \
/ | \
switch_agent dispatch dispatch_stream
| | |
v v v
AgentSession create_child create_child
.agent = new inherit_mem inherit_mem
SessionMeta submit_turn submit_turn_stream
更新 返回结果 mpsc 转发事件
finalize_on_complete
交互层次:
父 -> 子: SessionMemory snapshot + bridge_keys 过滤
子 -> 父: SubTaskResult { child_id, response, usage, summary }
子 <-> 子: shared:{parent_session_id} namespace
```
---
## 参考来源
### 代码路径
| 文件 | 用途 |
|------|------|
| `src/agent/session.rs` | AgentSession 定义、`agent` pub 字段(L53)、`session_memory` pub 字段(L58)、`submit_turn_stream`L384)、`finalize_turn`L456 |
| `src/agent/agent.rs` | Agent trait 定义(3 个方法) |
| `src/agent/session_memory.rs` | SessionMemory: `set`L50)、`set_with_meta`L61)、`list_entries`L81 |
| `src/engine/session_manager.rs` | SessionManager: `create_child`L202)、`get`L244)、`destroy`L495)、`save_session_meta`L131)、`load_session_meta`L144)、SessionMetaL32)、锁契约(L4-12 |
| `src/engine/error.rs` | EngineError 枚举(当前 6 变体,`#[non_exhaustive]` |
| `src/engine/mod.rs` | 模块注册 |
| `src/engine/snapshot.rs` | SessionSnapshotfrom_snapshot / to_snapshot 所需) |
| `src/llm/stream.rs` | StreamEvent 枚举 |
| `Cargo.toml` | 依赖声明(`futures-util` L17、`tokio-stream` L15、`futures-core` L18 |
### 文档路径
| 文档 | 用途 |
|------|------|
| `docs/roadmap-v0.3.0.md` §Phase 18 | Phase 18 原始需求(交付物、交互层级、优先级) |
| `docs/note-opencode-agent-switching.md` | Agent 热切换调研笔记(桥接方案分析、生命周期讨论) |
| `docs/note-opencode-subagent-dispatch.md` | SubAgent Dispatch 调研笔记(PM/SA 建议、设计推演) |
| `docs/23-phase17-agent-execution-engine.md` | Phase 17 方案文档(SessionManager 设计背景) |
| `docs/7-agent-runtime.md` | Agent 运行时设计文档(Session 与 Agent 的关系) |
| `docs/17-phase10-contextslot.md` | ContextSlot 上下文管理(Phase 10 |
| `docs/24-phase18-agent-switch-and-dispatch.md` | 本文档 — 第 1 轮审查修复记录 |
### 决策轨迹
| 决策 | 参考来源 | 置信度 |
|------|----------|--------|
| switch_agent 在 SessionManager 而非 AgentSession | `src/agent/session.rs` AgentSession 无 store 引用 | 高 |
| bridge_keys 通过 SessionMemory 副本,不碰 system_prompt | `docs/note-opencode-subagent-dispatch.md` PM/SA 建议 | 高 |
| dispatch_all 返回 `Vec<Result<..>>` 部分成功 | `docs/note-opencode-subagent-dispatch.md` PM/SA 一致 | 高 |
| dispatch_stream 返回 `Pin<Box<dyn Stream>>` | `src/engine/session_manager.rs` `submit_turn_stream` 签名一致 | 高 |
| 失败时 destroy 子 session 清理全部(含 checkpoint | `session_manager.rs:508` destroy 调 `checkpointer.delete_all` | 高 |
| dispatch_all 用 `&Arc<Self>` 签名 | `tokio::spawn` `'static` 约束 | 高 |
| child_memory 不做额外权限控制 | `docs/note-opencode-subagent-dispatch.md` PM 明确 | 高 |
| `#[non_exhaustive]` 已存在 -> 新增 EngineError 变体不是 breaking change | `src/engine/error.rs:15` | 高 |
| `futures-util` 已存在,无需新增依赖 | `Cargo.toml:17` | 高 |
### 审查修复轨迹(第 1 轮)
| # | 问题 | 🔴/🟡 | 修复内容 |
|---|------|--------|---------|
| F1 | `finalize_active_stream()` 假设不成立:`submit_turn_stream` 返回后 cycle 被 drop | 🔴 | 移除 Step 2 的 `finalize_active_stream()`,改为 spawn task 内从 StreamEvent 重建消息列表直接调 `finalize_turn()` |
| F2 | `SubTaskResult` 缺 child_memory 访问路径 | 🔴 | `SubTaskResult.child_id` 可经由 `sm.get()` 读取子 session。构型 doc comment 增加说明 |
| F3 | dispatch 失败路径 `destroy` 自身 I/O 可能失败,覆盖原始错误 | 🔴 | 改用 `let _ = destroy` + `tracing::error!`,原始 `EngineError` 优先 |
| F4 | switch_agent SessionMeta 构造中 `created_at`/`parent_id` 用占位符 | 🔴 | 改用 `load_session_meta` 读取原始值,`turn_count``guard.turn_index()` 读取 |
| F5 | D9 与 `destroy()` 实现矛盾(方案说保留,代码说删除) | 🟡 | D9 修正为"destroy 清理全部",否决条目同步更新 |
| F6 | `bridge_keys` 默认值安全反直觉(空=全量继承) | 🟡 | 类型改为 `Option<Vec<String>>``None` = 不继承(默认),`Some(vec![])` = 全量 |
| F7 | Semaphore acquire 位置未指定 | 🟡 | 指定 `acquire_owned()` 在 spawn 内 + indexed 收集维持输入顺序 |
| F8 | dispatch_stream 缺少独立示例 | 🟡 | Step 9 追加 `dispatch_stream_demo` |
| F9 | mpsc channel 背压策略未指定 | 🟡 | 改用 `unbounded_channel`,与 LLM stream 内部模式一致 |
| F10 | 子↔子交互层缺少实现细节 | 🟡 | `DispatchConfig.shared_namespace` 字段 + 示例 3 演示 |
| F11 | inherit_session_memory 竞态窗口未文档化 | 🟡 | doc comment 声明快照一致性模型 |
| F12 | switch_agent system_prompt 断裂风险未说明 | 🟡 | doc comment 增加使用建议 + 安全提示 |
---
*本文档对应的实施步骤记录在 `docs/roadmap-v0.3.0.md` §Phase 18,实施完成后同步更新 roadmap 状态。*
@@ -0,0 +1,652 @@
# Phase 19:知识图谱 + 双通道检索
## 背景与目标
### 问题空间
agcore v0.3.0 已交付 Phase 0-18,记忆系统具备 `KnowledgeStore`(页面级内容检索)和 `VectorStore`(向量语义检索),但缺少实体-关系维度的关联检索能力。用户搜索"X 与什么相关"时,现有系统无法返回实体间的拓扑关系。
`docs/note-knowledge-graph-design.md` 已记录完整的知识图谱设计,Phase 19 将其落地为可编译、可测试的模块。
### 目标
- 新增 `memory/graph.rs`,实现 `KnowledgeGraph` trait + `InMemoryGraph` 内存实现
- 扩展 `MemoryRetriever` 为双通道:KnowledgeStore(内容)+ KnowledgeGraph(实体关系)
- 通过 `RetrievalStrategy` 枚举控制通道选择(Hybrid / KnowledgeOnly / GraphOnly
- 统一 `RetrievalResult.items``Vec<RetrievalItem>`enum 变体区分类别
- 标签管理 API 预留(无自动提取流程,Agent 层显式写入)
- Phase 19 仅提供底层 CRUD 接口,实体/关系的写入由 Agent 层(如 LLM 提取)在后续 Phase 中接入。当前无自动填充流程,需 Agent 显式调用 `add_entity`/`add_relation`
### 与现有模块的定位关系
```
KnowledgeStore: 页面级内容("什么是 X" ← Phase 6 已有
VectorStore: 向量语义(相似度检索) ← Phase 15 已有
KnowledgeGraph: 实体级关系("X 与什么相关" ← Phase 19 新增
MemoryRetriever: 统一检索入口 ← Phase 19 扩展为双通道
```
### 依赖与优先级
- **依赖**Phase 6KnowledgeStore[高]、Phase 15VectorStore 模式参考)[低]
- **优先级**P0v0.3.0 最后一个 Phase
- **预估规模**:约 600 行核心 + 200 行测试
---
## 需求分析
### 功能需求
| ID | 需求 | 优先级 |
|----|------|--------|
| F1 | `GraphEntity` / `GraphRelation` / `RelationDirection` 类型定义 | P0 |
| F2 | `KnowledgeGraph` trait10 个 async 方法) | P0 |
| F3 | `InMemoryGraph` 实现(HashMap + Vec + tag_index | P0 |
| F4 | BFS 图遍历(防环、权重衰减、方向过滤) | P0 |
| F5 | `RetrievalItem` / `RetrievalStrategy` / `RetrievalResult` 扩展 | P0 |
| F6 | `MemoryRetriever` 双通道(`tokio::join!` 并行) | P0 |
| F7 | 标签管理(set_entity_tags / find_tags / entity_count_by_tag | P1(预留) |
### 非功能需求
| ID | 需求 | 说明 |
|----|------|------|
| NF1 | 零新依赖 | 纯 std + tokio + 已有 crate |
| NF2 | 异步安全 | `InMemoryGraph` 内部 Mutex 保护,trait 方法 async |
| NF3 | 类型安全 | 不新增 `MemoryError` 变体,复用现有 5 个 |
| NF4 | 向后兼容 | `MemoryRetriever::new()` 签名不变,可选链式注入 graph |
| NF5 | Breaking change 受控 | `RetrievalResult.items` 类型变化,需在 CHANGELOG 标注 |
---
## 方案设计
### 3.1 数据模型
#### GraphEntity
```rust
/// 图谱实体 —— 表示一个可被关联检索的节点。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphEntity {
/// 唯一标识(如 "person:rust-dev-01")。
pub id: String,
/// 实体名称(用于展示和关键词匹配)。
pub name: String,
/// 实体类型("person" | "concept" | "project" | ...)。
pub entity_type: String,
/// 一句话描述。
pub description: String,
/// 检索标签(全小写,原子词,由 Agent 层显式写入)。
pub tags: Vec<String>,
/// 任意附加属性(与 PersistentVectorStore.metadata 保持一致)。
pub properties: HashMap<String, String>,
}
```
#### GraphRelation
```rust
/// 图谱关系 —— 连接两个实体的有向边。
///
/// 无 `id` 字段,用 `(source_id, target_id, relation_type)` 三元组唯一标识。
/// 提供 `composite_key()` 作为派生 id,满足未来独立 id 需求。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphRelation {
/// 源实体 ID。
pub source_id: String,
/// 目标实体 ID。
pub target_id: String,
/// 关系类型("works_on" | "part_of" | "related_to" | ...)。
pub relation_type: String,
/// 关系强度 [0.0, 1.0],用于 BFS 评分衰减。
pub weight: f32,
}
impl GraphRelation {
/// 复合键:`source_id:target_id:relation_type`,用于去重和查找。
pub fn composite_key(&self) -> String {
format!("{}:{}:{}", self.source_id, self.target_id, self.relation_type)
}
}
```
#### RelationDirection
```rust
/// 关系遍历方向。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RelationDirection {
/// 仅出边:source_id → target_id(默认)。
Outgoing,
/// 仅入边:target_id → source_id。
Incoming,
/// 双向遍历。
Both,
}
impl Default for RelationDirection {
fn default() -> Self {
Self::Outgoing
}
}
```
#### ScoredEntity
```rust
/// 带评分的实体 + 路径信息。
#[derive(Debug, Clone)]
pub struct ScoredEntity {
pub entity: GraphEntity,
/// 基于图距离的评分 [0.0, 1.0],沿路径权重乘积衰减。
pub score: f32,
/// 从查询实体到当前实体的 ID 路径(用于可解释性)。
pub path: Vec<String>,
}
```
#### TagConstraints
```rust
/// 标签约束配置。
#[derive(Debug, Clone)]
pub struct TagConstraints {
/// 每个实体最多标签数(默认 8)。
pub max_tags_per_entity: usize,
}
impl Default for TagConstraints {
fn default() -> Self {
Self {
max_tags_per_entity: 8,
}
}
}
```
### 3.2 KnowledgeGraph trait
```rust
/// 知识图谱抽象 —— 实体-关系存储与图遍历检索。
///
/// 所有方法 `async + Send + Sync`,支持跨 `.await` 调用。
/// 复用 `MemoryError`,不新增变体。
#[async_trait]
pub trait KnowledgeGraph: Send + Sync {
// ── 实体管理 ──
/// 添加或更新实体(upsert 语义)。
async fn add_entity(&self, entity: GraphEntity) -> Result<(), MemoryError>;
/// 按 ID 获取实体,不存在返回 `Ok(None)`
async fn get_entity(&self, id: &str) -> Result<Option<GraphEntity>, MemoryError>;
/// 删除实体及其所有关联关系。
async fn remove_entity(&self, id: &str) -> Result<(), MemoryError>;
// ── 关系管理 ──
/// 添加关系(若复合键已存在则覆盖 weight)。
async fn add_relation(&self, relation: GraphRelation) -> Result<(), MemoryError>;
/// 按复合键删除关系。
async fn remove_relation(
&self,
source_id: &str,
target_id: &str,
relation_type: &str,
) -> Result<(), MemoryError>;
/// 从指定实体出发,BFS 遍历 depth 层,返回关联实体(带评分)。
///
/// - `direction`:遍历方向(Outgoing / Incoming / Both
/// - `relation_types`:可选过滤,仅遍历指定关系类型
async fn get_related(
&self,
entity_id: &str,
depth: usize,
direction: RelationDirection,
relation_types: Option<&[&str]>,
) -> Result<Vec<ScoredEntity>, MemoryError>;
// ── 检索 ──
/// 按关键词子串匹配实体(不区分大小写),与 KnowledgeStore.search 一致。
async fn find_by_keywords(&self, keywords: &[String]) -> Result<Vec<GraphEntity>, MemoryError>;
// ── 标签管理(预留接口,Agent 层显式写入) ──
/// 按前缀查找已有标签(用于标签复用)。
async fn find_tags(&self, prefix: &str) -> Result<Vec<String>, MemoryError>;
/// 设置实体标签(替换式,保留前 max_tags_per_entity 个)。
/// 返回实际设置的标签数。
async fn set_entity_tags(
&self,
entity_id: &str,
tags: Vec<String>,
) -> Result<usize, MemoryError>;
/// 按标签统计实体数量。
async fn entity_count_by_tag(&self, tag: &str) -> Result<usize, MemoryError>;
/// 获取标签约束配置。
fn tag_constraints(&self) -> TagConstraints;
}
```
### 3.3 InMemoryGraph 实现
#### 内部结构
```rust
/// 内存知识图谱实现 —— 纯内存,无持久化。
///
/// 生命周期跟随实例;持久化路径参考 InMemoryVectorStore → PersistentVectorStore 演进模式。
pub struct InMemoryGraph {
/// 内部状态(单一锁结构,避免嵌套锁死锁)
inner: Mutex<GraphInner>,
/// 标签约束
constraints: TagConstraints,
}
struct GraphInner {
/// id → entity
entities: HashMap<String, GraphEntity>,
/// 所有关系(线性扫描,实测 5000 条 ≈ 1-50µs,无需邻接表索引)
relations: Vec<GraphRelation>,
/// tag → entity_ids(反向索引,用于 find_tags / entity_count_by_tag
tag_index: HashMap<String, HashSet<String>>,
}
```
#### BFS 遍历算法
```rust
async fn get_related(
&self,
entity_id: &str,
depth: usize,
direction: RelationDirection,
relation_types: Option<&[&str]>,
) -> Result<Vec<ScoredEntity>, MemoryError> {
// 1. 验证起点存在
let inner = self.inner.lock().unwrap();
if !inner.entities.contains_key(entity_id) {
return Err(MemoryError::NotFound(entity_id.to_string()));
}
// 2. BFS 初始化
let mut visited: HashSet<String> = HashSet::new();
let mut result: Vec<ScoredEntity> = Vec::new();
// 队列:(entity_id, score, path)
let mut queue: VecDeque<(String, f32, Vec<String>)> = VecDeque::new();
queue.push_back((entity_id.to_string(), 1.0, vec![entity_id.to_string()]));
visited.insert(entity_id.to_string());
// 3. BFS 逐层遍历
for _ in 0..depth {
let mut next_queue: VecDeque<(String, f32, Vec<String>)> = VecDeque::new();
while let Some((current_id, score, path)) = queue.pop_front() {
// 筛选与 current_id 相关的关系
for rel in inner.relations.iter() {
// 方向过滤
let (match_source, match_target) = match direction {
RelationDirection::Outgoing => (&rel.source_id, &rel.target_id),
RelationDirection::Incoming => (&rel.target_id, &rel.source_id),
RelationDirection::Both => {
if rel.source_id == current_id {
(&rel.source_id, &rel.target_id)
} else if rel.target_id == current_id {
(&rel.target_id, &rel.source_id)
} else {
continue;
}
}
};
if *match_source != current_id {
continue;
}
// 关系类型过滤
if let Some(types) = relation_types {
if !types.contains(&rel.relation_type.as_str()) {
continue;
}
}
let neighbor_id = match_target.clone();
if visited.contains(&neighbor_id) {
continue;
}
visited.insert(neighbor_id.clone());
// 权重乘积衰减
let new_score = score * rel.weight;
let mut new_path = path.clone();
new_path.push(neighbor_id.clone());
result.push(ScoredEntity {
entity: inner.entities.get(&neighbor_id).cloned()
.ok_or_else(|| MemoryError::NotFound(neighbor_id.clone()))?,
score: new_score,
path: new_path.clone(),
});
next_queue.push_back((neighbor_id, new_score, new_path));
}
}
queue = next_queue;
}
// 4. 按分数降序排列
result.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
Ok(result)
}
```
`depth=0` 时仅验证起点实体存在,返回空关联列表(不遍历任何边)。
**BFS 关键设计点**
| 特性 | 处理方式 |
|------|----------|
| 环路 | `visited: HashSet<String>` 已访问集合防环 |
| 评分衰减 | 沿路径 `score *= rel.weight`,权重乘积 |
| 多路径 | BFS 天然先到先得,同一实体只保留首次到达路径 |
| 关系类型过滤 | `relation_types: Option<&[&str]>``None` 表示不过滤 |
| 方向过滤 | `RelationDirection` 枚举,`Both` 时双向检查 |
> 以上性能数据为基于算法复杂度的估算值(O(R) 线性扫描,R=关系数),实际性能需通过基准测试验证。建议在实现后添加 `#[bench]` 或 criterion 基准测试。
### 3.4 检索扩展
#### RetrievalStrategy
```rust
/// 检索策略 —— 控制双通道分流。
#[derive(Debug, Clone, Default)]
pub enum RetrievalStrategy {
/// 并行 KnowledgeStore + KnowledgeGraph,合并排序(默认)。
#[default]
Hybrid,
/// 仅 KnowledgeStore。
KnowledgeOnly,
/// 仅 KnowledgeGraph。
GraphOnly,
}
```
#### RetrievalItem
```rust
/// 统一检索条目 —— enum 变体区分类别,两通道分数均在 [0,1] 区间。
#[derive(Debug, Clone)]
pub enum RetrievalItem {
/// 知识页面(来自 KnowledgeStore)。
KnowledgePage {
page: KnowledgePage,
/// TextOverlap 评分 [0.0, 1.0]。
score: f32,
},
/// 图谱实体(来自 KnowledgeGraph)。
GraphEntity {
entity: crate::memory::graph::GraphEntity,
/// 图距离评分 [0.0, 1.0]。
score: f32,
/// 从查询实体到当前实体的 ID 路径。
path: Vec<String>,
},
}
impl RetrievalItem {
/// 统一分数(用于合并排序)。
pub fn score(&self) -> f32 {
match self {
Self::KnowledgePage { score, .. } => *score,
Self::GraphEntity { score, .. } => *score,
}
}
}
```
> **注意**:两个通道的分数维度不同(TextOverlap vs 图距离),合并排序仅用于统一返回,不代表跨通道可比性。
#### 向后兼容导出(ScoredItem
```rust
// ── 向后兼容导出 ──
/// 旧版带评分的知识页面检索结果(已废弃)。
///
/// 请迁移到 `RetrievalItem::KnowledgePage { page, score }`
#[deprecated(since = "0.3.0", note = "使用 RetrievalItem::KnowledgePage 代替")]
#[derive(Debug, Clone)]
pub struct ScoredItem {
pub page: KnowledgePage,
pub score: f32,
}
// 在 memory.rs 模块根的重导出中保留:
// #[allow(deprecated)]
// pub use retriever::ScoredItem;
```
#### RetrievalResult 更新
```rust
/// 检索结果。
#[derive(Debug, Clone)]
pub struct RetrievalResult {
/// 统一条目列表,按分数降序排列。
pub items: Vec<RetrievalItem>,
pub query: String,
/// 本次检索实际执行的策略(可能因 graph 未注入而退化),而非用户通过 `with_strategy()` 配置的值。
pub strategy: RetrievalStrategy,
}
```
#### MemoryRetriever 扩展
```rust
pub struct MemoryRetriever {
knowledge_store: KnowledgeStore,
/// 可选知识图谱(None 时退化为单通道)。
knowledge_graph: Option<Arc<dyn KnowledgeGraph>>,
/// 检索策略(默认 Hybrid)。
strategy: RetrievalStrategy,
config: RetrieverConfig,
stop_words: HashSet<String>,
}
impl MemoryRetriever {
/// 创建新的 MemoryRetriever(保持向后兼容)。
pub fn new(knowledge_store: KnowledgeStore, config: RetrieverConfig) -> Self {
Self {
knowledge_store,
knowledge_graph: None,
strategy: RetrievalStrategy::default(),
config,
stop_words: default_stop_words(),
}
}
/// 注入知识图谱,启用双通道检索。
pub fn with_knowledge_graph(mut self, graph: Arc<dyn KnowledgeGraph>) -> Self {
self.knowledge_graph = Some(graph);
self
}
/// 设置检索策略。
pub fn with_strategy(mut self, strategy: RetrievalStrategy) -> Self {
self.strategy = strategy;
self
}
/// 检索相关记忆(双通道)。
pub async fn retrieve(&self, query: &str) -> Result<RetrievalResult, MemoryError> {
if query.is_empty() {
return Ok(RetrievalResult {
items: Vec::new(),
query: query.to_string(),
strategy: self.strategy.clone(),
});
}
let keywords = extract_keywords(query, &self.stop_words);
let has_graph = self.knowledge_graph.is_some();
// 按策略分流
match (&self.strategy, has_graph) {
// 仅知识页面
(RetrievalStrategy::KnowledgeOnly, _) | (_, false) => {
let items = self.search_knowledge_store(query, &keywords).await?;
Ok(RetrievalResult {
items,
query: query.to_string(),
strategy: RetrievalStrategy::KnowledgeOnly,
})
}
// 仅图谱
(RetrievalStrategy::GraphOnly, true) => {
let graph = self.knowledge_graph.as_ref().unwrap();
let items = self.search_graph(query, &keywords, graph).await?;
Ok(RetrievalResult {
items,
query: query.to_string(),
strategy: self.strategy.clone(),
})
}
// 混合:并行执行,合并排序
(RetrievalStrategy::Hybrid, true) => {
let graph = self.knowledge_graph.as_ref().unwrap();
let (kp_items, g_items) = tokio::join!(
self.search_knowledge_store(query, &keywords),
self.search_graph(query, &keywords, graph),
);
let mut items = kp_items?;
items.extend(g_items?);
items.sort_by(|a, b| {
b.score().partial_cmp(&a.score())
.unwrap_or(std::cmp::Ordering::Equal)
});
items.truncate(self.config.max_results);
Ok(RetrievalResult {
items,
query: query.to_string(),
strategy: self.strategy.clone(),
})
}
}
}
}
```
### 3.5 标签管理
#### 标签索引维护
`tag_index: HashMap<String, HashSet<String>>` 维护 tag → entity_ids 反向映射:
- **`set_entity_tags`**:先清除旧标签的反向引用,再写入新标签。超出 `max_tags_per_entity` 时截断。
- **`find_tags`**:遍历 `tag_index.keys()`,按前缀过滤。
- **`entity_count_by_tag`**:直接返回 `tag_index.get(tag).map_or(0, |s| s.len())`
#### 实现要点
```rust
async fn set_entity_tags(
&self,
entity_id: &str,
tags: Vec<String>,
) -> Result<usize, MemoryError> {
let mut inner = self.inner.lock().unwrap();
let entity = inner.entities.get_mut(entity_id)
.ok_or_else(|| MemoryError::NotFound(entity_id.to_string()))?;
// 清除旧标签的反向引用
for old_tag in &entity.tags {
if let Some(ids) = inner.tag_index.get_mut(old_tag) {
ids.remove(entity_id);
if ids.is_empty() {
inner.tag_index.remove(old_tag);
}
}
}
// 截断到 max_tags_per_entity
let max = self.constraints.max_tags_per_entity;
let new_tags: Vec<String> = tags.into_iter().take(max).collect();
// 写入新标签的反向引用
for tag in &new_tags {
inner.tag_index.entry(tag.clone())
.or_default()
.insert(entity_id.to_string());
}
entity.tags = new_tags.clone();
Ok(new_tags.len())
}
```
#### 标签复用流程(文档说明)
```
LLM 提取候选标签 → 对每个候选:
graph.find_tags(candidate.lowercase())
├─ 命中已有标签 → 复用
└─ 无匹配 → 注册新标签
```
> **标注**:当前无自动提取流程,需 Agent 层显式调用 `set_entity_tags`
---
## 实现计划
| Step | 内容 | 文件范围 | 验证标准 | 预估行数 |
|------|------|----------|----------|----------|
| 1 | `graph.rs` 核心类型:GraphEntity / GraphRelation / RelationDirection / ScoredEntity / TagConstraints | `src/memory/graph.rs` | `cargo check` 编译通过 | ~80 |
| 2 | `KnowledgeGraph` trait 定义(10 个 async 方法) | `src/memory/graph.rs` | trait 编译通过,无未实现方法 | ~90 |
| 3 | `InMemoryGraph` 实现 + BFS 遍历 | `src/memory/graph.rs` | 单元测试:添加实体/关系、BFS 遍历、方向过滤、类型过滤 | ~250 |
| 4 | 标签管理实现(set_entity_tags / find_tags / entity_count_by_tag | `src/memory/graph.rs` | 单元测试:标签增删查、截断、反向索引维护 | ~80 |
| 5 | `retriever.rs` 扩展:RetrievalItem / RetrievalStrategy / MemoryRetriever 改造 | `src/memory/retriever.rs` | `cargo check` + 双通道检索测试 | ~180 |
| 6 | `memory.rs` 模块根更新 + 重导出 | `src/memory.rs` | `cargo check`pub use 无编译错误 | ~10 |
| 7 | 内联测试 | `src/memory/graph.rs` + `src/memory/retriever.rs` | `cargo test --all-targets` 全绿 | ~150 |
**总预估**:约 840 行(核心 640 + 测试 200
---
## 风险评估
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| 标签 API 无消费者 | 低 — 预留接口,不影响核心功能 | 文档标注"Agent 层显式写入",后续 Phase 接入 |
| 评分不可比 | 中 — TextOverlap vs 图距离维度不同 | `RetrievalItem` enum 变体分离,合并排序仅统一返回,文档注明维度差异 |
| BFS 性能 | 低 — 5000 关系遍历 ≈ 1-50µs | 不引入邻接表索引,等实测超过 1ms 再优化 |
| Breaking change | 中 — `RetrievalResult.items` 类型变化 | CHANGELOG 标注,`ScoredItem` 保留为 `pub` 兼容导出(deprecate |
| Mutex 竞争 | 低 — InMemoryGraph 单实例场景 | 读多写少, Mutex 性能足够;后续可升级 RwLock |
---
## 验收标准
| 检查项 | 标准 | 验证命令 |
|--------|------|----------|
| 编译 | 0 error | `cargo check --all-targets` |
| 测试 | 全绿,测试数从 ~391 增至 ~410+ | `cargo test --all-targets` |
| Clippy | 0 warning | `cargo clippy --all-targets -- -D warnings` |
| 文档 | 0 warning | `cargo doc --no-deps` |
| BFS 覆盖 | 所有边界条件:空图、单实体、环路、深度 0、方向过滤、类型过滤 | 内联测试 |
| 双通道 | Hybrid / KnowledgeOnly / GraphOnly 三种策略功能正确 | 内联测试 |
| 向后兼容 | `MemoryRetriever::new()` 签名不变,现有调用无需修改 | `cargo check` 无 breaking error |
@@ -0,0 +1,694 @@
# AG Core v0.3.2 Step 1Phase 20)— Cargo Features 基础设施改造实施方案
## 1. 背景与目标
**背景**agcore 是一个 Rust 编写的智能体核心工具箱,目前约 23,718 行、66 个源文件。v0.3.0 发布后,所有模块在编译时全量捆绑,下游用户无法按需选择模块,即使只使用 LLM 对话也需要编译 sqlite / MCP / agent 引擎等全部依赖。
**目标**:通过 Cargo features 拆分让下游按需选择模块。Step 1 是基础设施变更 —— `Cargo.toml` features 定义 + 依赖 optional 化 + 必要的子模块 cfg 门控,编译通过后打 checkpoint。
**预期效果**
- `default = ["full"]` → v0.3.0 用户零迁移成本
- 最小组合(`document`)零重型外部依赖(仅依赖始终编译的轻量依赖:serde/serde_json/thiserror/async-trait/tracing
- 纯对话组合(`chat + provider-openai`)仅需 ~10 个依赖,不含 sqlite / MCP / engine
## 2. 需求分析
### 2.1 约束条件
| # | 约束 | 说明 |
|---|------|------|
| 1 | `default = ["full"]` | 保持向后兼容,v0.3.0 用户零迁移成本 |
| 2 | `document` feature 零重型外部依赖(仅依赖始终编译的轻量依赖:serde/serde_json/thiserror/async-trait/tracing | 纯 std + 始终编译的轻量依赖(serde/serde_json/thiserror/async-trait/tracing |
| 3 | tokio 从 `["full"]` 拆细 | 已验证全库无 net/fs/signal 使用,拆为 `["rt", "sync", "time", "macros", "process", "io-util"]` |
| 4 | 重型依赖全部 optional | tokio、reqwest、rusqlite、tracing-subscriber、tokio-stream、futures、futures-util、futures-core、bytes、async-stream、tokio-util、time |
| 5 | 始终编译的轻量依赖 | serde、serde_json、thiserror、async-trait、tracing |
### 2.2 关键决策
| # | 决策 | 理由 |
|---|------|------|
| 1 | `tools` feature 必须 `imply tokio` | `src/tools/registry.rs` 使用 `tokio::time::timeout` |
| 2 | `pub mod llm` 门控条件为 `any(feature = "llm-types", feature = "llm")` | `prompt → llm-types` 路径需要 llm 模块编译,但只需 types 子模块 |
| 3 | 测试 dev-dependencies 加 `tokio = { version = "1", features = ["rt", "macros"] }` | 现有 `#[tokio::test]` 需要 tokio runtime |
| 4 | 快捷组合名保持原名(chat/multi/light | 文档中说明各组合包含的 feature 约束 |
| 5 | `init_tracing()` 函数整体用 `#[cfg(feature = "tracing-init")]` 包裹 | 避免 `use tracing_subscriber` 出现在未启用 feature 时编译失败 |
## 3. 方案设计
### 3.1 Features 定义(完整 Cargo.toml `[features]` 草案)
```toml
[features]
default = ["full"]
# === 模块级 features ===
document = []
llm-types = []
prompt = ["llm-types"]
llm = ["llm-types", "tokio", "async-stream", "futures-core", "tokio-stream"]
tools = ["llm-types", "futures", "tokio-util", "tokio"]
tools-mcp = ["tools", "reqwest"]
# memory 模块依赖 llmconversation/vector_store 使用 compact/embedding)、tokioknowledge.rs 使用 Mutex)、timetypes.rs 使用 OffsetDateTime
memory = ["document", "llm", "tokio", "time"]
memory-sqlite = ["memory", "rusqlite", "time"]
agent = ["llm", "tools", "memory", "futures-util"]
engine = ["agent"]
# === Provider features ===
# Provider features — openai/anthropic 额外依赖 bytes(流式解析)和 futures-utilStream 组合)
provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
# deepseek/qwen 使用 openai_compat 适配层,不需要 bytes 和 futures-util
provider-deepseek = ["llm", "reqwest"]
provider-qwen = ["llm", "reqwest"]
provider-ollama = ["llm", "reqwest"]
# === 工具 features ===
tracing-init = ["tracing-subscriber"]
# === 快捷组合 ===
full = [
"document", "llm-types", "prompt", "llm",
"tools", "tools-mcp",
"memory", "memory-sqlite",
"agent", "engine",
"provider-openai", "provider-anthropic", "provider-deepseek",
"provider-qwen", "provider-ollama",
"tracing-init",
]
light = ["llm", "provider-openai", "tools", "tools-mcp", "memory", "agent", "engine", "prompt", "document"]
chat = ["agent", "provider-openai"]
multi = ["engine", "provider-openai"]
```
**features 依赖图(简略)**
```
document (零外部依赖)
└── memory (+llm, +tokio, +time) ─── memory-sqlite (+rusqlite, +time)
llm-types (零依赖)
├── prompt
└── llm (+tokio, +async-stream, +futures-core, +tokio-stream)
├── tools (+futures, +tokio-util) ─── tools-mcp (+reqwest)
├── provider-openai / provider-anthropic (+reqwest, +bytes, +futures-util)
├── provider-deepseek / provider-qwen / provider-ollama (+reqwest)
└── agent (+tools, +memory, +futures-util) ─── engine
```
### 3.2 依赖 optional 化方案
**始终编译(5 个,不参与门控)**:
```toml
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
async-trait = "0.1"
tracing = "0.1"
```
**12 个依赖加 `optional = true`**
| 依赖 | 原声明 | 新声明 |
|------|--------|--------|
| tokio | `{ version = "1", features = ["full"] }` | `{ version = "1", features = ["rt", "sync", "time", "macros", "process", "io-util"], optional = true }` |
| reqwest | `{ version = "0.12", features = ["json", "stream"] }` | `{ version = "0.12", features = ["json", "stream"], optional = true }` |
| rusqlite | `{ version = "0.32", features = ["bundled"] }` | `{ version = "0.32", features = ["bundled"], optional = true }` |
| tracing-subscriber | `{ version = "0.3", features = ["env-filter"] }` | `{ version = "0.3", features = ["env-filter"], optional = true }` |
| tokio-stream | `{ version = "0.1" }` | `{ version = "0.1", optional = true }` |
| futures | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
| futures-util | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
| futures-core | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
| bytes | `{ version = "1" }` | `{ version = "1", optional = true }` |
| async-stream | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
| tokio-util | `{ version = "0.7", features = ["rt", "sync"] }` | `{ version = "0.7", features = ["rt", "sync"], optional = true }` |
| time | `{ version = "0.3", features = ["serde", "parsing", "formatting", "macros"] }` | `{ version = "0.3", features = ["serde", "parsing", "formatting", "macros"], optional = true }` |
**dev-dependencies 新增**
```toml
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros"] }
```
### 3.3 源文件改动清单
共涉及 **8 个文件**(预估 ~100 行改动):`Cargo.toml``src/lib.rs``src/llm.rs``src/llm/cycle.rs``src/tools.rs``src/memory.rs``src/memory/store.rs``src/agent/session.rs`
---
#### 文件 1`Cargo.toml`
**改动 1.1** — 新增 `[features]` 表(约 45 行,插入在 `[package]` 之后、`[dependencies]` 之前)
```diff
+ [features]
+ default = ["full"]
+
+ # === 模块级 features ===
+ document = []
+ llm-types = []
+ prompt = ["llm-types"]
+ llm = ["llm-types", "tokio", "async-stream", "futures-core", "tokio-stream"]
+ tools = ["llm-types", "futures", "tokio-util", "tokio"]
+ tools-mcp = ["tools", "reqwest"]
+ # memory 模块依赖 llmconversation/vector_store 使用 compact/embedding)、tokioknowledge.rs 使用 Mutex)、timetypes.rs 使用 OffsetDateTime
+ memory = ["document", "llm", "tokio", "time"]
+ memory-sqlite = ["memory", "rusqlite", "time"]
+ agent = ["llm", "tools", "memory", "futures-util"]
+ engine = ["agent"]
+
+ # === Provider features ===
+ # Provider features — openai/anthropic 额外依赖 bytes(流式解析)和 futures-utilStream 组合)
+ provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
+ provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
+ # deepseek/qwen 使用 openai_compat 适配层,不需要 bytes 和 futures-util
+ provider-deepseek = ["llm", "reqwest"]
+ provider-qwen = ["llm", "reqwest"]
+ provider-ollama = ["llm", "reqwest"]
+
+ # === 工具 features ===
+ tracing-init = ["tracing-subscriber"]
+
+ # === 快捷组合 ===
+ full = [
+ "document", "llm-types", "prompt", "llm",
+ "tools", "tools-mcp",
+ "memory", "memory-sqlite",
+ "agent", "engine",
+ "provider-openai", "provider-anthropic", "provider-deepseek",
+ "provider-qwen", "provider-ollama",
+ "tracing-init",
+ ]
+ light = ["llm", "provider-openai", "tools", "tools-mcp", "memory", "agent", "engine", "prompt", "document"]
+ chat = ["agent", "provider-openai"]
+ multi = ["engine", "provider-openai"]
```
**改动 1.2** — tokio 依赖声明修改
```diff
- tokio = { version = "1", features = ["full"] }
+ tokio = { version = "1", features = ["rt", "sync", "time", "macros", "process", "io-util"], optional = true }
```
**改动 1.3** — 11 个重型依赖逐行加 `optional = true`
```diff
- reqwest = { version = "0.12", features = ["json", "stream"] }
+ reqwest = { version = "0.12", features = ["json", "stream"], optional = true }
- rusqlite = { version = "0.32", features = ["bundled"] }
+ rusqlite = { version = "0.32", features = ["bundled"], optional = true }
- tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+ tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
- tokio-stream = "0.1"
+ tokio-stream = { version = "0.1", optional = true }
- futures = "0.3"
+ futures = { version = "0.3", optional = true }
- futures-util = "0.3"
+ futures-util = { version = "0.3", optional = true }
- futures-core = "0.3"
+ futures-core = { version = "0.3", optional = true }
- bytes = "1"
+ bytes = { version = "1", optional = true }
- async-stream = "0.3"
+ async-stream = { version = "0.3", optional = true }
- tokio-util = { version = "0.7", features = ["rt"] }
+ tokio-util = { version = "0.7", features = ["rt", "sync"], optional = true }
- time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"] }
+ time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"], optional = true }
```
**改动 1.4** — `[dev-dependencies]` 新增 tokio
```diff
+ [dev-dependencies]
+ tokio = { version = "1", features = ["rt", "macros"] }
```
**说明**:如果原 `Cargo.toml` 已有 `[dev-dependencies]` 则追加该行;若无则新增整个 section。
---
#### 文件 2`src/lib.rs`(当前约 26 行 → 改动后约 40 行)
**当前内容(参考)**
```rust
//! agcore —— 智能体(Agent)核心工具箱。
pub mod llm;
pub mod document;
pub mod prompt;
pub mod tools;
pub mod memory;
pub mod agent;
pub mod engine;
pub use document::Document;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
static INIT: std::sync::Once = std::sync::Once::new();
pub fn init_tracing() {
INIT.call_once(|| {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("agcore=info"));
tracing_subscriber::registry()
.with(fmt::layer())
.with(filter)
.init();
});
}
```
**改动后内容**
```diff
//! agcore —— 智能体(Agent)核心工具箱。
- pub mod llm;
+ #[cfg(any(feature = "llm-types", feature = "llm"))]
+ pub mod llm;
- pub mod document;
+ #[cfg(feature = "document")]
+ pub mod document;
- pub mod prompt;
+ #[cfg(feature = "prompt")]
+ pub mod prompt;
- pub mod tools;
+ #[cfg(feature = "tools")]
+ pub mod tools;
- pub mod memory;
+ #[cfg(feature = "memory")]
+ pub mod memory;
- pub mod agent;
+ #[cfg(feature = "agent")]
+ pub mod agent;
- pub mod engine;
+ #[cfg(feature = "engine")]
+ pub mod engine;
- pub use document::Document;
+ #[cfg(feature = "document")]
+ pub use document::Document;
- use tracing_subscriber::{EnvFilter, fmt, prelude::*};
- static INIT: std::sync::Once = std::sync::Once::new();
- pub fn init_tracing() {
- INIT.call_once(|| {
- let filter = EnvFilter::try_from_default_env()
- .unwrap_or_else(|_| EnvFilter::new("agcore=info"));
- tracing_subscriber::registry()
- .with(fmt::layer())
- .with(filter)
- .init();
- });
- }
+ #[cfg(feature = "tracing-init")]
+ use tracing_subscriber::{EnvFilter, fmt, prelude::*};
+
+ #[cfg(feature = "tracing-init")]
+ static INIT: std::sync::Once = std::sync::Once::new();
+
+ #[cfg(feature = "tracing-init")]
+ pub fn init_tracing() {
+ INIT.call_once(|| {
+ let filter = EnvFilter::try_from_default_env()
+ .unwrap_or_else(|_| EnvFilter::new("agcore=info"));
+ tracing_subscriber::registry()
+ .with(fmt::layer())
+ .with(filter)
+ .init();
+ });
+ }
```
---
#### 文件 3`src/llm.rs`(当前约 12 行 → 改动后约 24 行)
**改动说明**:为每个子模块声明加 feature 门控。`types` 子模块在 `llm-types``llm` 任一 feature 启用时编译(`llm` imply `llm-types`,但 `prompt` 也 depend on `llm-types`);其余子模块(compact/convert/cycle 等)仅在 `llm` feature 启用时编译。
```diff
//! LLM 调用周期 —— 大模型基础调用周期控制。
- pub mod types;
+ #[cfg(feature = "llm-types")]
+ pub mod types;
- pub mod compact;
+ #[cfg(feature = "llm")]
+ pub mod compact;
- pub mod convert;
+ #[cfg(feature = "llm")]
+ pub mod convert;
- pub mod cycle;
+ #[cfg(feature = "llm")]
+ pub mod cycle;
- pub mod embedding;
+ #[cfg(feature = "llm")]
+ pub mod embedding;
- pub mod error;
+ #[cfg(feature = "llm")]
+ pub mod error;
- pub mod hooks;
+ #[cfg(feature = "llm")]
+ pub mod hooks;
- pub mod mock;
+ #[cfg(feature = "llm")]
+ pub mod mock;
- pub mod provider;
+ // provider 模块依赖 reqwest(通过 reqwest::Client),仅在任一 provider feature 启用时编译
+ #[cfg(any(feature = "provider-openai", feature = "provider-anthropic", feature = "provider-deepseek", feature = "provider-qwen", feature = "provider-ollama"))]
+ pub mod provider;
- pub mod stream;
+ #[cfg(feature = "llm")]
+ pub mod stream;
```
---
#### 文件 3b`src/llm/cycle.rs`(新增文件,约 35 行)
**改动说明**`cycle.rs` 中使用 `crate::tools::ToolRegistry`(第 29 行),依赖 `tools` feature。工具相关字段和方法需加 `#[cfg(feature = "tools")]` 门控。
> ⚠️ 这是 Phase 22.7 原计划的门控变更,因为编译阻塞提前到 Step 1 执行。
```diff
//! Cycle —— 多轮对话与工具调用编排。
use async_trait::async_trait;
use futures::StreamExt; // 来自 llm→tokio imply 链
+ #[cfg(feature = "tools")]
use crate::tools::ToolRegistry;
// ... struct / enum 定义 ...
// ===== CycleConfig — 工具相关字段加 cfg 门控 =====
pub struct CycleConfig {
pub max_retries: usize,
pub max_history: usize,
+ #[cfg(feature = "tools")]
pub max_tool_turns: usize,
+ #[cfg(feature = "tools")]
pub tool_timeout_secs: u64,
// ... 其他字段 ...
}
// ===== Cycle — 方法加 cfg 门控 =====
impl Cycle {
/// 仅在有 tools feature 时才有工具调用相关方法
+ #[cfg(feature = "tools")]
pub async fn submit_with_tools(&self, ...) -> Result<...> {
// ...
}
+ /// submit_with_tools_stream 方法同样需要 tools 门控,
+ /// 因参数包含 Arc<ToolRegistry> 而与 submit_with_tools 同理。
+ #[cfg(feature = "tools")]
+ pub async fn submit_with_tools_stream(
+ &self, ... // 方法签名中包含 Arc<ToolRegistry> 参数
+ ) -> Result<...> {
+ // ...
+ }
+ #[cfg(feature = "tools")]
async fn run_tool_loop(&self, ...) -> Result<...> {
// ...
}
}
+ // ===== 顶层函数 — 同样依赖 ToolRegistry =====
+ /// run_tool_loop 函数(顶层函数,非 LlmCycle 方法)同样依赖 Arc<ToolRegistry>
+ /// 参数包含 Arc<ToolRegistry>,需 #[cfg(feature = "tools")]。
+ #[cfg(feature = "tools")]
+ pub async fn run_tool_loop(
+ // ... 函数签名中包含 Arc<ToolRegistry> 参数
+ ) -> Result<...> {
+ // ...
+ }
**说明**`Cycle` 本身的 struct 定义、`submit()` 基础方法、`ResponseStream` 等不依赖 tools 的部分保持无门控,仅在 `llm` feature 下编译即可。
---
#### 文件 4`src/tools.rs`(当前约 13 行 → 改动后约 15 行)
**改动说明**`mcp` 子模块及对应的 `pub use` 仅在 `tools-mcp` feature 启用时编译。其余子模块(base/error/permission/registry)始终在 `tools` feature 下编译。
```diff
//! 工具系统 —— 工具抽象、注册、调用、权限控制与 MCP 集成。
pub mod base;
pub mod error;
- pub mod mcp;
+ #[cfg(feature = "tools-mcp")]
+ pub mod mcp;
pub mod permission;
pub mod registry;
pub use base::{BaseTool, ToolContext, ToolRef};
pub use error::ToolError;
- pub use mcp::{McpClient, McpTransport};
+ #[cfg(feature = "tools-mcp")]
+ pub use mcp::{McpClient, McpTransport};
pub use permission::{Permission, PermissionChecker, PermissionConfig};
pub use registry::{ToolInvocation, ToolRegistry};
```
---
#### 文件 5`src/memory/store.rs`(当前约 62 行 → 改动后约 64 行)
**改动说明**`sqlite_store` 子模块及其 `pub use` 仅在 `memory-sqlite` feature 启用时编译。
```diff
//! MemoryStore 抽象接口与默认实现。
use async_trait::async_trait;
use crate::memory::error::MemoryError;
use crate::memory::types::{MemoryFilter, MemoryItem};
pub mod in_memory;
- pub mod sqlite_store;
+ #[cfg(feature = "memory-sqlite")]
+ pub mod sqlite_store;
pub use in_memory::InMemoryStore;
- pub use sqlite_store::SqliteStore;
+ #[cfg(feature = "memory-sqlite")]
+ pub use sqlite_store::SqliteStore;
```
**说明**`MemoryStore` trait、`EvictionConfig``EvictionPolicy` 等定义保持不变,不需要 cfg 门控。
---
#### 文件 6`src/memory.rs`(当前约 32 行 → 改动后约 34 行)
**改动说明**`SqliteStore` 的重新导出仅在 `memory-sqlite` feature 启用时编译。其余子模块声明和 `pub use` 保持不变(`memory` feature 门控由 `src/lib.rs` 负责)。
```diff
//! 记忆系统 —— 对话消息管理、知识页面存储与关键词检索。
// 所有子模块声明保持不变:
// pub mod conversation;
// pub mod error;
// pub mod graph;
// pub mod knowledge;
// pub mod retriever;
// pub mod store;
// ...
// 高频类型
pub use conversation::{ConversationMemory, ConversationMemoryConfig};
pub use error::MemoryError;
pub use graph::{GraphEntity, GraphRelation, InMemoryGraph, KnowledgeGraph, RelationDirection, ScoredEntity};
pub use knowledge::KnowledgeStore;
pub use retriever::MemoryRetriever;
pub use store::{InMemoryStore, MemoryStore};
+ #[cfg(feature = "memory-sqlite")]
+ pub use store::SqliteStore;
// 其余 pub use 保持不变...
```
---
#### 文件 7`src/agent/session.rs`(新增文件,约 30 行)
**改动说明**`session.rs` 中引用了 `crate::engine::*`SessionMemoryEntry、SessionSnapshot、EngineError),而 `agent` feature 不含 `engine``engine = ["agent"]` 是反向依赖)。需要对 engine 相关导入和方法加门控。
> ⚠️ 阻塞 B3`src/agent/session.rs:28-29` 无条件引用 `crate::engine::*`,在 `agent` feature 下编译时因缺少 engine 而失败。
```diff
//! Session —— Agent 会话管理。
use async_trait::async_trait;
use crate::llm::types::LLMRequest;
use crate::memory::MemoryStore;
+ #[cfg(feature = "engine")]
use crate::engine::snapshot::{SessionMemoryEntry, SessionSnapshot};
+ #[cfg(feature = "engine")]
use crate::engine::EngineError;
// ===== AgentSession — pending_memory_restore 字段 =====
+ /// AgentSession 结构体中的 pending_memory_restore 字段类型来自 engine 模块,
+ /// 需要条件编译。
pub struct AgentSession {
+ // ... 其他字段 ...
+
+ #[cfg(feature = "engine")]
+ pending_memory_restore: Option<HashMap<String, SessionMemoryEntry>>,
+ // ... 其他字段 ...
+ }
impl Session {
/// to_snapshot / from_snapshot / restore_memory 仅在 engine feature 下可用
+ #[cfg(feature = "engine")]
pub fn to_snapshot(&self) -> SessionSnapshot {
// ...
}
+ #[cfg(feature = "engine")]
pub fn from_snapshot(snap: SessionSnapshot) -> Result<Self, EngineError> {
// ...
}
+ #[cfg(feature = "engine")]
async fn restore_memory(&mut self, entries: Vec<SessionMemoryEntry>) -> Result<(), MemoryError> {
// ...
}
}
```
**说明**`Session` 结构体本身以及不依赖 engine 的方法(如 `new()``add_message()``get_history()`)保持无门控,仅在 `agent` feature 下编译即可。
---
## 4. 实施步骤
**3 个 commit** 粒度执行,每个 commit 后编译验证。
### Commit 1Cargo.toml features 定义 + 依赖 optional 化
**涉及文件**:仅 `Cargo.toml`
**操作清单**
1. 在 `[package]` 之后、`[dependencies]` 之前插入 `[features]` 表(16 个 features + 4 个快捷组合,约 45 行)
2. tokio features 从 `["full"]` 改为 `["rt", "sync", "time", "macros", "process", "io-util"]` 并加 `optional = true`
3. reqwest / rusqlite / tracing-subscriber / tokio-stream / futures / futures-util / futures-core / bytes / async-stream / tokio-util / time 共 11 个依赖加 `optional = true`
4. 在 `[dependencies]` 之后新增 `[dev-dependencies]``tokio = { version = "1", features = ["rt", "macros"] }`
**验证**
```bash
cargo build --no-default-features # 不依赖任何 optional crate,应通过
cargo build -F document # 零外部依赖,应通过
```
---
### Commit 2cfg 门控(pub mod + pub use + init_tracing
**涉及文件**`src/lib.rs``src/llm.rs``src/llm/cycle.rs``src/tools.rs``src/memory/store.rs``src/memory.rs``src/agent/session.rs`
**操作清单**
按文件逐一执行:
1. `src/lib.rs` — 7 个 `pub mod``#[cfg(feature = "...")]``Document` pub use 加 cfg、`init_tracing` 整体用 `#[cfg(feature = "tracing-init")]` 包裹
2. `src/llm.rs` — 10 个子模块按 `llm-types` / `llm` / provider 分类门控
3. `src/llm/cycle.rs``ToolRegistry` 导入加 `#[cfg(feature = "tools")]`,工具字段和方法加相同门控
4. `src/tools.rs``pub mod mcp``pub use mcp::*``#[cfg(feature = "tools-mcp")]`
5. `src/memory/store.rs``pub mod sqlite_store``pub use sqlite_store::SqliteStore``#[cfg(feature = "memory-sqlite")]`
6. `src/memory.rs``pub use store::SqliteStore``#[cfg(feature = "memory-sqlite")]`
7. `src/agent/session.rs` — engine 相关导入加 `#[cfg(feature = "engine")]`to_snapshot/from_snapshot/restore_memory 加相同门控
**验证**
```bash
cargo build -F "full" # 全量回归
cargo build -F "prompt" # 验证 llm::types imply 路径
cargo build -F "tools" # 验证 tokio imply 路径
cargo build -F "memory" # 验证记忆模块不含 sqlite
cargo build -F "chat,provider-openai" # 纯对话组合
cargo build -F "chat,provider-openai,tools-mcp" # 带 MCP 对话
```
---
### Commit 3Checkpoint 全量验证
**操作清单**
1. 完整的验证矩阵执行(见第 5 节)
2. `cargo test -F "full"` 确认 427 passed
**验证**
```bash
cargo test -F "full"
cargo build -F "light"
cargo build -F "multi"
```
## 5. 验证标准
### 编译验证矩阵
| 命令 | 验证目标 | 预期结果 |
|------|---------|---------|
| `cargo build --no-default-features` | 空 crate | 编译通过(无模块) |
| `cargo build -F "full"` | 全量回归 | 编译通过,与 v0.3.0 语义一致 |
| `cargo test -F "full"` | 测试回归 | `427 passed` |
| `cargo build -F "document"` | 文档模块独立 | 编译通过,零外部依赖 |
| `cargo build -F "prompt"` | 提示词独立 | 编译通过,`llm::types` imply 路径正确 |
| `cargo build -F "tools"` | 工具独立 | 编译通过,tokio imply 路径正确 |
| `cargo build -F "memory"` | 记忆模块独立编译 | ✅ 不含 sqlite(依赖 B1/B2 修复) |
| `cargo build -F "memory-sqlite"` | 含 SQLite 的记忆模块 | ✅ 含 rusqlite |
| `cargo build -F "agent"` | Agent 独立编译 | ✅ 含 llm+tools+memory,不含 engine(依赖 B1/B3 修复) |
| `cargo build -F "engine"` | Engine 独立编译 | ✅ imply agent → llm+tools+memory |
| `cargo build -F "chat,provider-openai"` | 纯对话组合 | 编译通过,不含 MCP、sqlite |
| `cargo build -F "chat,provider-openai,tools-mcp"` | 带 MCP 对话 | 编译通过,含 reqwest 无 sqlite |
| `cargo build -F "light"` | 生产常用组合 | 编译通过 |
| `cargo build -F "multi"` | 多 provider 组合 | 编译通过 |
### 验证操作指令
每次编译验证后执行(验证编译产物不含意外符号):
```bash
# 确认空 crate 确实没有模块符号
cargo build --no-default-features 2>&1 && echo "OK"
# 确认 document 零外部依赖(无 reqwest/rusqlite 等符号)
cargo build -F "document" 2>&1 && echo "OK"
# 全量构建 + 测试
cargo build -F "full" 2>&1 && cargo test -F "full" 2>&1 | tail -5
```
### 验证通过条件
- 所有 14 条编译验证命令返回 exit code 0
- `cargo test -F "full"` 输出 `427 passed`(与 v0.3.0 基线一致,不要求测试数精确匹配,但必须全部通过且数量合理)
- 无 `unused import` / `unused variable` / `dead code` warning(由 `#[cfg]` 引起的新 warning 需逐一修复)
## 6. 风险评估
| 风险 | 等级 | 缓解措施 |
|------|------|---------|
| **tokio features 拆细遗漏**:某些代码路径用到 net/fs/signal | 低 | 已通过 SA(静态分析)验证全库无相关使用 |
| **`#[tokio::test]` 编译失败**:测试代码无 tokio runtime | 低 | `[dev-dependencies]` 添加 `tokio = { version = "1", features = ["rt", "macros"] }` |
| **下游 transitive tokio features 缩小**:依赖 agcore 的 crate 之前通过 agcore 间接获得 `full` tokio,现在范围缩小 | 中 | Phase 27 README 发布说明中明确告知迁移方案;下游如需完整 tokio 需自行添加 |
| **imply 链未闭合**:某个 feature 依赖了未 imply 的 feature | 低 | 7 种特征组合全部逐条构建验证;features 定义中有交叉引用的全部显式列出 |
| **unused cfg warning**:某些 `#[cfg]` 标记导致编译 warning | 低 | 每个 commit 后检查编译器输出,发现后立即修复 |
| **测试依赖循环**dev-deps 与普通 deps 版本冲突 | 低 | dev-deps 的 tokio 版本与主依赖保持一致 (`version = "1"`,由 cargo 自动选择兼容版本) |
| **memory 跨模块 imply 链** | **高** | memory 模块依赖 llmconversation/vector_store)、tokioknowledge)、timetypes),imply 链必须完整传递 | `memory` feature 定义已包含 `llm``tokio``time`;验证矩阵覆盖 memory 独立编译 |
| **跨模块引用未门控** | **高** | provider.rs 依赖 reqwest、cycle.rs 依赖 ToolRegistry、session.rs 依赖 engineStep 1 必须添加 cfg 门控 | provider 模块 cfg 改为 provider-xxx 条件;cycle.rs 加 tools 门控;session.rs 加 engine 门控 |
@@ -0,0 +1,424 @@
# AG Core v0.3.2 Step 3Phase 2627)— 验证固化 + 文档更新实施方案
## 1. 背景与目标
**背景**agcore v0.3.2 Step 1Phase 2025)已交付 —— Cargo features 拆分基础设施改造全部完成,所有模块 `#[cfg]` 门控注入完毕,依赖全部 optional 化,`default = ["full"]` 保持向后兼容。当前项目处于已改造完成但未经 CI 固化、无文档指引的状态。
**当前状态快照**
- v0.3.0 → v0.3.2 Step 168 个源文件,23,765 行
- 16 个 features10 模块级 + 5 provider + 1 工具)+ 4 个快捷组合
- `cargo test --features "full"`427 passed
- 7 种 feature 组合的 `cargo test --lib` 已全部通过(full / light / chat / chat+mcp / multi / multi+mcp / clippy),无需修复 cfg 遗漏
- 但 `cargo test`(不带 `--lib`)会因 18 个 example 缺少 `required-features` 而失败
- 项目无 CI/CD 配置
**目标**:通过 Step 3 将 features 体系验证固化到 CI 中,消除编译死代码警告,完成文档指引,使 v0.3.2 达到可发布状态。
**预期效果**
- 每次提交自动验证 7 种 feature 组合的编译 + 测试(零 warning)
- 18 个 example 各自标注准确的 `required-features`,外树用户可一键运行
- `cargo clippy --all-features -- -D warnings` 零告警
- README 含完整 feature 表 + `Cargo.toml` 配置示例 + 升级指南,新用户 5 分钟内可选定组合
- roadmap 同步更新
## 2. 需求分析
### 2.1 功能需求
| # | 需求 | 说明 | 对应工作 |
|---|------|------|---------|
| F1 | LlmProvider trait 不应依赖具体 provider feature | trait 自身不依赖 reqwest 或任何 provider 实现,纯 Mock 场景也应可用 | 工作 0 |
| F2 | 每个 example 通过 `cargo run --example xxx` 正确编译 | 18 个 example 各有精确的最小 features 声明 | 工作 1 |
| F3 | CI 自动验证 7 种特征组合的编译与测试 | push / PR 触发 | 工作 2 |
| F4 | 所有 feature 组合下 0 个编译器警告 | 消除 dead_code 等警告 | 工作 3 |
| F5 | README 提供完整的 feature 选择指引 | 表格 + 场景推荐 + Cargo.toml 示例 | 工作 5Phase 27 |
| F6 | example 文件顶部标注所需 features | 用户可一键复制运行命令 | 工作 5(Phase 27 |
| F7 | roadmap 状态同步 | 总入口 + v0.3.2 子文档 | 工作 5Phase 27 |
### 2.2 非功能需求
| # | 需求 | 指标 | 对应工作 |
|---|------|------|---------|
| N1 | 向后兼容 | `default = ["full"]` 行为与 v0.3.0 一致,427 tests passed | 全部 |
| N2 | CI 时效 | 全矩阵 ≤ 10 分钟 | 工作 2 |
| N3 | 最少侵入 | 不改动功能逻辑,仅 cfg / 配置 / 文档变更 | 全部 |
### 2.3 推演概要
**需求拆解**:从当前编译验证结果出发,发现三类待解决问题:
1. **架构归属问题**——`LlmProvider` trait 定义在 provider 模块门控下,语义上应归属 `llm` 基础设施。同时其返回类型 `ProviderCapabilities` / `ProviderFeatures` 也必须一并移出
2. **example 可编译性问题**——18 个 example 无 `required-features`,多组合下 `cargo test` 失败
3. **代码质量问题**——`session.rs``bundle()` 方法在 `chat` 组合下 dead_code
4. **工程缺失**——无 CI、README 无 features 说明、roadmap 未同步
**边界识别**
- 工作 0 仅移动 trait 及关联类型定义,不改变公开 API 签名
- 工作 1 的 required-features 是最小集合,不添加冗余 feature
- 工作 2 的 CI 仅验证编译 + 单元测试,不包含集成测试
- 工作 5 的文档更新不涉及新的功能描述
**非目标声明**
- 不新增 feature 组合(保持现有的 4 个快捷组合不变)
- 不重构 `ProviderConfig` / `ProviderType` / `create_provider()` 等 provider 模块创建逻辑(仅移出 trait 和元数据结构体)
- 不集成集成测试(CI 仅验证 `--lib` 单元测试 + example 编译验证)
- 不改动 `Cargo.toml``[dependencies]` 声明
- 不改变 `default = ["full"]` 的默认行为
### 2.4 需求映射矩阵
| 功能需求 | 非功能需求 | 对应工作 | 验收项 |
|---------|-----------|---------|-------|
| F1 + N1 + N3 | — | 工作 0 | A1, A2, A9 |
| F2 | — | 工作 1 | A10 |
| F3 | N2 | 工作 2 | A5, A11 |
| F4 | — | 工作 3 | A4 |
| F5 | N1 | 工作 5 | A6 |
| F6 | — | 工作 5 | A7 |
| F7 | — | 工作 5 | A8 |
| — | N3 | 全部 | A1 |
| — | R2 缓解 | 全部 | A10 |
## 3. 方案设计
### 3.1 总体架构调整
```
工作 0 — 架构修正(LlmProvider trait 及关联类型归属调整)
当前:
src/llm/provider.rs #[cfg(any(feature = "provider-openai", ...))]
├─ pub trait LlmProvider { ... }
├─ pub struct ProviderCapabilities { ... }
├─ pub struct ProviderFeatures { ... }
└─ pub fn capabilities(&self) -> ProviderCapabilities;
目标:
src/llm/provider_trait.rs #[cfg(feature = "llm")]
├─ pub trait LlmProvider { ... }
├─ pub struct ProviderCapabilities { ... }
├─ pub struct ProviderFeatures { ... }
└─ pub fn capabilities(&self) -> ProviderCapabilities;
src/llm/provider.rs #[cfg(any(feature = "provider-openai", ...))]
└─ 各 provider 实现 + ProviderConfig / ProviderType / create_provider()
src/llm.rs
└─ pub use provider_trait::{LlmProvider, ProviderCapabilities, ProviderFeatures};
影响文件(6 个源文件 + 1 个新建):
- src/llm.rs — 添加 mod provider_trait 声明 + pub use 重导出
- src/llm/provider_trait.rs — 新文件,trait + 关联类型定义移入
- src/llm/provider.rs — 移出 trait + 关联类型
- src/llm/provider/openai.rs — use super:: → use crate::llm::
- src/llm/provider/anthropic.rs — 同上
- src/llm/provider/ollama.rs — 同上
- src/llm/provider/openai_compat.rs — 同上(两个 import 合并)
```
### 3.2 各子项设计方案
#### 工作 0 — LlmProvider trait 归属修正
**设计方案**
1. 在 `src/llm/` 下新建 `provider_trait.rs`,门控为 `#[cfg(feature = "llm")]`
2. 从 `src/llm/provider.rs` 中提取以下定义到新文件:
- `pub trait LlmProvider`(含关联方法 `chat` / `chat_stream` / `capabilities`
- `pub struct ProviderCapabilities`(含字段 `features: ProviderFeatures`
- `pub struct ProviderFeatures`(含 8 个功能开关字段)
3. `src/llm.rs` 中声明 `mod provider_trait;`,并 `pub use provider_trait::{LlmProvider, ProviderCapabilities, ProviderFeatures};`
4. `src/llm/provider.rs` 移除上述定义,保留 `ProviderConfig` / `ProviderType` / `create_provider()` 等运行时代码
5. 更新所有 import 路径(详见下方清单)
**Import 路径调整清单**
现有写法 → 目标写法
| # | 文件 | 现有 import | 目标 import |
|---|------|------------|------------|
| 1 | `agent/builder.rs:16` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
| 2 | `agent/builder.rs:135`test | `use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
| 3 | `agent/session.rs:35` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
| 4 | `agent/runtime.rs:21` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
| 5 | `llm/mock.rs:46` | `use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
| 6 | `llm/cycle.rs:22` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
| 7 | `llm/cycle.rs:940,1401`test | `use crate::llm::provider::{ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{ProviderCapabilities, ProviderFeatures};` |
| 8 | `llm/provider/openai.rs:24` | `use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
| 9 | `llm/provider/anthropic.rs:21` | `use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
| 10 | `llm/provider/ollama.rs:14` | `use super::{LlmProvider, ProviderCapabilities};` | `use crate::llm::{LlmProvider, ProviderCapabilities};` |
| 11 | `llm/provider/openai_compat.rs:18,21` | `use super::ProviderCapabilities;` + `use crate::llm::provider::LlmProvider;` | `use crate::llm::{LlmProvider, ProviderCapabilities};`(合并为一行) |
| 12 | `llm/provider/registry.rs:6` | `use crate::llm::provider::{LlmProvider, ProviderConfig, ProviderType, create_provider};` | `use crate::llm::LlmProvider;` + `use crate::llm::provider::{ProviderConfig, ProviderType, create_provider};`(拆分) |
**示例文件 import 调整**
| # | 文件 | 现有 import | 目标 import |
|---|------|------------|------------|
| 13 | `examples/end_to_end.rs:21` | `use agcore::llm::provider::{create_provider, LlmProvider, ProviderConfig, ProviderType};` | `use agcore::llm::LlmProvider;` + `use agcore::llm::provider::{create_provider, ProviderConfig, ProviderType};` |
| 14 | `examples/context_slot_demo.rs:18` | `use agcore::llm::provider::LlmProvider;` | `use agcore::llm::LlmProvider;` |
| 15 | `examples/streaming_events_demo.rs:19` | `use agcore::llm::provider::LlmProvider;` | `use agcore::llm::LlmProvider;` |
| 16 | `examples/quick_start.rs:9` | `use agcore::llm::provider::LlmProvider;` | `use agcore::llm::LlmProvider;` |
**Breaking Change 声明**
工作 0 是**非兼容性变更**,现有用户可能通过以下路径引用 `LlmProvider`
| 旧路径(v0.3.0v0.3.2 Step 1 | 新路径(v0.3.2 Step 3 后) |
|--------------------------------|---------------------------|
| `agcore::llm::provider::LlmProvider` | `agcore::llm::LlmProvider` |
| `agcore::llm::provider::ProviderCapabilities` | `agcore::llm::ProviderCapabilities` |
| `agcore::llm::provider::ProviderFeatures` | `agcore::llm::ProviderFeatures` |
**向后兼容方案(可选)**:在 `src/llm/provider.rs` 中添加 `#[cfg(feature = "llm")]` 门控的类型别名,让老路径仍然可用:
```rust
#[cfg(feature = "llm")]
pub use super::provider_trait::LlmProvider;
#[cfg(feature = "llm")]
pub use super::provider_trait::ProviderCapabilities;
#[cfg(feature = "llm")]
pub use super::provider_trait::ProviderFeatures;
```
**推荐**:用户应迁移到新路径 `agcore::llm::LlmProvider``provider` 模块仅保留 `ProviderConfig` / `ProviderType` / `create_provider()` 等创建逻辑。
**验证**
- `cargo test --features "full"` 仍 427 passed
- `cargo test --no-default-features --features "llm,llm-types" --lib` 编译通过(无需任何 provider feature
- 所有 12 个内部文件 + 4 个示例文件的 import 路径正确
#### 工作 1 — examples required-features 标注
**设计方案**:在 `Cargo.toml` 中为每个 example 添加 `[[example]]` + `required-features`,精确到最小 features 集合。
```
上下文 slot 示例(context_slot_demo):
工作 0 后仅需 ["agent"](修正前需 ["agent", "provider-openai"]
因为 agent 的测试无需真实 providermock 即可
推理不变的 examplesimple_visit):
真正调用 LLM,需要 ["llm", "provider-openai", "tracing-init"]
```
完整映射关系见实施计划 §4.2。
#### 工作 2 — CI 配置
**设计方案**GitHub Actions 矩阵策略,7 个并行测试 job + 1 clippy + 1 format + 1 example 验证。
| Job | Command | 作用域 |
|-----|---------|--------|
| full | `RUSTFLAGS="-D warnings" cargo test --features "full" --lib` | 全量回归,零警告 |
| light | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "light" --lib` | 生产常用,零警告 |
| chat | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "chat,provider-openai" --lib` | 纯对话,零警告 |
| chat+mcp | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "chat,provider-openai,tools-mcp" --lib` | 对话 + 工具,零警告 |
| multi | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "multi,provider-openai" --lib` | 多 Agent,零警告 |
| multi+mcp | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "multi,provider-openai,tools-mcp" --lib` | 多 Agent + 工具,零警告 |
| clippy | `cargo clippy --all-features --lib -- -D warnings` | lint 检查 |
| format | `cargo fmt --check`stable toolchain | 格式检查 |
| examples | `cargo test --features "full"`(不加 `--lib`,编译并运行所有 example | example 编译验证 |
**关键决策**
- 矩阵中统一使用 `--lib` 而非 `--all-targets`。理由:examples 的编译由 `required-features` 独立管理,若混入矩阵会因 feature 组合不匹配导致 example 编译失败,干扰模块测试结果验证。
- 使用 `RUSTFLAGS="-D warnings"` 将警告升级为编译错误,确保 `F40 编译器警告)`被矩阵中所有 6 个测试 job 强制执行。
- format job 使用 stable toolchain`cargo fmt --check` 不需要 nightly)。
- 独立 `examples` job 使用 `cargo test --features "full"`(不加 `--lib`),验证所有 example 在完整 features 下编译并运行通过。
#### 工作 3 — bundle() 死代码警告修复
**设计方案**:在 `src/agent/session.rs:154``pub(crate) fn bundle()` 方法上添加 `#[cfg(feature = "engine")]` 条件编译。
背景:`bundle()` 仅被 `engine/session_manager.rs:219` 调用,当启用 `chat` 组合(agent 但非 engine)时产生 dead_code 警告。
**验证**`cargo test --no-default-features --features "chat,provider-openai" --lib` 0 warnings。
#### 工作 4(可选)— 编译时间基线
记录但不沉淀到代码或 CI 中,仅供性能参考:
```bash
time cargo build --features "full"
time cargo build --no-default-features --features "light"
```
注:此工作在 v0.3.2 发布前为手动执行,不纳入 CI 或验收标准。若后续版本需要编译时间回归检测,可将其提升为正式工作项。
#### 工作 5 — 文档更新
三处并行更新:
**README.md 新增 features 表格**
- 4 个快捷组合 + 推荐使用场景 + `Cargo.toml` 配置示例
- 下游用户可快速选择并复制配置
**升级指南章节(README.md 新增)**
- 针对工作 0 的 Breaking Change 提供迁移说明
- 列出旧路径 → 新路径的对照表
- 提供向后兼容的重导出方案说明
- 示例:`agcore::llm::provider::LlmProvider``agcore::llm::LlmProvider`
- 提醒用户更新 `use` 声明
**example 文件顶部注释**
- 每个 example 第一行格式:`// Required features: cargo run --example xxx --features "..."`
- 对应工作 1 的 `required-features` 声明
**roadmap 状态同步**
- `docs/roadmap.md`:补充 Phase 26-27 完成状态 + v0.3.2 链接
- `docs/roadmap-v0.3.2.md`Phase 26/27 状态从 ⏳ 改为 ✅ + 完成日期
### 3.3 ADR 记录
#### ADR-1LlmProvider trait 及关联类型归属 llm 模块
| 字段 | 内容 |
|------|------|
| 问题 | `LlmProvider` trait 定义在 provider 模块门控 `any(provider-openai, provider-anthropic, ...)` 下,纯 Mock 场景被迫引入至少一个 provider feature。其返回类型 `ProviderCapabilities` / `ProviderFeatures` 同样被困在 provider 门控中 |
| 决策 | 将 trait 定义 + `ProviderCapabilities` / `ProviderFeatures` 一并提取到 `src/llm/provider_trait.rs`,归属 `#[cfg(feature = "llm")]` |
| 备选方案 | 保持不动,在 mock provider 上添加 cfg 绕过 — 否决,因为 provider 模块整体门控错误 |
| 理由 | trait 本身是个接口定义,不依赖 reqwest 或任何 provider 实现细节;`ProviderCapabilities` / `ProviderFeatures` 是 trait 方法的返回类型,必须与 trait 同门控 |
| 影响 | 修改 6 个源文件 + 1 个新建文件 + 4 个示例文件(详见 §3.2 import 调整清单) |
| 状态 | 已采纳 |
#### ADR-2CI 使用 nightly toolchain
| 字段 | 内容 |
|------|------|
| 问题 | 项目已使用 edition 2024,是否降级到 2021 以使用 stable Rust |
| 决策 | 测试和 clippy 使用 nightlyedition 2024 目前要求 nightly);format 使用 stable |
| 备选方案 | 降级 edition 到 2021 — 否决,已迁移至 edition 2024 且编译通过 |
| 理由 | edtion 2024 是主动选择的方向,降级是倒退且涉及大量语法变更;`cargo fmt --check` 无需 nightly |
| 影响 | CI 依赖 `actions-rust-lang/setup-rust-toolchain@v1`format job 指定 `toolchain: stable` |
| 状态 | 已采纳 |
#### ADR-3CI 矩阵使用 `--lib` 而非 `--all-targets`
| 字段 | 内容 |
|------|------|
| 问题 | `cargo test --all-targets` 会编译所有 example,与矩阵中自选的 feature 组合可能冲突 |
| 决策 | 矩阵测试使用 `--lib`examples 由独立 job`cargo test --features "full"` 不加 `--lib`)验证 |
| 备选方案 | 在矩阵中也传入 `--all-targets` — 否决,example 编译失败会干扰模块测试验证 |
| 理由 | 分离关注点:矩阵验证模块级编译 + 零警告,独立 job 验证 example 编译 |
| 状态 | 已采纳 |
## 4. 实施计划
### 4.1 任务拆解与优先级
| 优先级 | 工作 | 编号 | 规模 | 依赖 |
|--------|------|------|------|------|
| P0 | LlmProvider trait 归属修正 | 工作 0 | ~30 行(含关联类型移动 + import 调整) | 无 |
| P0 | bundle() 门控修复 | 工作 3 | 1 行 | 无 |
| P0 | examples required-features | 工作 1 | ~50 行 | 工作 0context_slot_demo / quick_start 最小 features 从 agent+provider-openai 降为 agent |
| P0 | CI 配置 | 工作 2 | ~80 行 | 无 |
| P1 | 文档更新 | 工作 5 | ~150 行 | 全部 |
| P2 | 编译时间基线 | 工作 4 | 手动 | 全部 |
### 4.2 各 example 的 required-features 清单
| example 文件名 | required-features | 运行环境备注 |
|---------------|-------------------|------------|
| `prompt_composer` | `["prompt"]` | — |
| `custom_tool` | `["tools"]` | — |
| `conversation_memory_demo` | `["memory"]` | — |
| `knowledge_graph_demo` | `["memory"]` | — |
| `knowledge_search_demo` | `["memory"]` | — |
| `agent_session_demo` | `["agent"]` | — |
| `task_agent_demo` | `["agent"]` | — |
| `context_slot_demo` | `["agent"]` | 工作 0 后无需 provider |
| `quick_start` | `["agent"]` | 工作 0 后无需 provider |
| `simple_visit` | `["llm", "provider-openai", "tracing-init"]` | 需要 API key |
| `streaming_events_demo` | `["llm", "provider-openai"]` | 需要 API key |
| `agent_switch_demo` | `["engine"]` | — |
| `bridge_keys_demo` | `["engine"]` | — |
| `dispatch_stream_demo` | `["engine"]` | — |
| `engine_demo` | `["engine"]` | — |
| `sub_agent_dispatch_demo` | `["engine"]` | — |
| `document_demo` | `["memory", "tracing-init"]` | 需 sqlite 依赖(memory-sqlite feature 可选) |
| `end_to_end` | `["agent", "memory-sqlite", "provider-openai"]` | 需要 API key + sqlite 依赖 |
**验证策略**:每个 example 除 `--lib` 验证外,还需单独运行以下命令确认 required-features 精确性:
```bash
cargo test --no-default-features --features "<features>" --example <name>
```
### 4.3 Commit 策略
每个工作独立 commit,按依赖顺序排列:
| 顺序 | Scope | Type | 描述 | 依赖 |
|------|-------|------|------|------|
| 1 | `core` | `refactor` | 将 LlmProvider trait 及关联类型移出 provider 模块归属 llm | 无 |
| 2 | `agent` | `fix` | 为 session.rs bundle() 方法添加 engine feature 门控 | 无 |
| 3 | `examples` | `chore` | 为 18 个 example 添加 required-features 声明 | 工作 1context_slot 等受益于工作 0 的轻量 features |
| 4 | `ci` | `chore` | 创建 CI 测试矩阵配置 | 无 |
| 5 | `docs` | `docs` | 更新 README feature 表 + 升级指南 + 示例注释 + roadmap 状态 | 全部 |
### 4.4 参考实现:CI 配置
```yaml
name: CI
on: [push, pull_request]
env:
RUSTFLAGS: "-D warnings"
jobs:
test-matrix:
strategy:
matrix:
features:
- "full"
- "light"
- "chat,provider-openai"
- "chat,provider-openai,tools-mcp"
- "multi,provider-openai"
- "multi,provider-openai,tools-mcp"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo test --no-default-features --features "${{ matrix.features }}" --lib
clippy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo clippy --all-features --lib -- -D warnings
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
- run: cargo fmt --check
examples:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo test --features "full"
```
## 5. 风险评估
| 风险 | 概率 | 影响 | 缓解措施 | 对应验收项 |
|------|------|------|---------|-----------|
| 工作 0 重构后公开 API 被意外改变 | 低 | 高 | 重构前后分别跑 `cargo test --features "full"` 确认测试数一致(427),且 `cargo doc` 无差异 | A1 |
| required-features 标注不准确导致 example 运行时缺少 trait 实现 | 低 | 中 | 每个 example 在 `--lib` 验证后,再单独跑 `cargo test --example xxx --no-default-features --features "对应features"` 确认 | A10 |
| CI 首次在 GitHub runner 上因环境差异(OS、Toolchain 版本)失败 | 中 | 低 | 非阻塞问题,修复后重新推送即可;本地已在 macOS 验证 7 种组合 | A5 |
| edition 2024 在 GitHub runner 的特定 nightly 版本上不稳定 | 低 | 中 | 可在 `Cargo.toml` 中加 `rust-version = "1.85"` 下限约束 | A5 |
| bundle() 门控修复后 engine 组合下方法不可见 | 极低 | 中 | `cargo test --features "engine,provider-openai"` 编译通过即可验证 | A4 |
## 6. 验收标准
| # | 验收项 | 验证方式 | 对应工作 |
|---|--------|---------|---------|
| A1 | `cargo test --features "full"` 仍 427 passed | `cargo test -F full -q` | 工作 0 |
| A2 | `cargo test --no-default-features --features "llm,llm-types" --lib` 编译通过 | 无需任何 provider feature 即完成编译 | 工作 0 |
| A3 | 7 种组合下 `cargo test --no-default-features --features "组合" --lib -q` 全部通过 | 逐一验证 | 工作 1example features 正确不会干扰 --lib |
| A4 | 6 个矩阵组合(full / light / chat / chat+mcp / multi / multi+mcp)下 `RUSTFLAGS="-D warnings" cargo test --lib` 0 warnings | 所有组合均无编译器警告 | 工作 3 |
| A5 | `.github/workflows/ci.yml` 文件存在,结构包含 9 个 job6 测试 + 1 clippy + 1 format + 1 examples | 文件检查 | 工作 2 |
| A6 | README.md 包含 features 表格 + 使用场景 + Cargo.toml 配置示例 + 升级指南 | review 通过 | 工作 5 |
| A7 | 所有 18 个 example 文件首行含 `// Required features: cargo run --example xxx --features "..."` 注释 | review 通过 | 工作 5 |
| A8 | `docs/roadmap.md``docs/roadmap-v0.3.2.md` 中 Phase 26/27 状态标记为 ✅ | review 通过 | 工作 5 |
| A9 | 工作 0 后 `cargo doc --no-deps --features "llm,llm-types"` 可生成 `LlmProvider` / `ProviderCapabilities` / `ProviderFeatures` 的 API 文档(无需任何 provider feature | review 通过 | 工作 0 |
| A10 | 每个 example 单独验证:`cargo test --no-default-features --features "<对应features>" --example <name>` 编译通过 | 逐一验证 18 个 example | 工作 1 |
| A11 | 全矩阵 CI6 测试 + clippy + format + examples)从 checkout 到完成 ≤ 10 分钟 | 实测计时 | 工作 2 |
@@ -0,0 +1,790 @@
# Phase 28-30 — OpenAI Response API Provider 实施方案
> **版本**v1 | **作者**Writer Agent | **日期**2026-07-20
>
> **阅读前提**:本文档假设读者已熟悉现有的 Provider 实现模式(`AnthropicProvider` 独立实现方式)、IR 类型系统(`MessageRequest` / `MessageResponse` / `ContentBlock` / `StreamEvent` / `LlmProvider trait`)以及 Cargo features 门控机制。
>
> **前置条件**v0.3.2Phase 20-27)已发布,Cargo features 拆分完成,CI 矩阵 6 种组合全部通过。
---
## 1. 背景与目标
### 1.1 背景
OpenAI 于 2025 年下半年发布了 **Response API**`POST /responses`),作为 Chat Completions API`POST /chat/completions`)的下一代接口。Response API 不仅提供了更简洁的请求/响应结构,还将 `web_search``file_search``computer_use` 等内置工具提升为一等公民,并引入了 `previous_response_id` 多轮续写等新机制。
agcore 当前通过 `GenericOpenaiProvider` 实现了 OpenAI Chat Completions 协议。`ProviderType::OpenaiResponse` 枚举项已在 `src/llm/provider.rs` 中定义,但工厂函数返回 `Err("Phase 1 暂不实现;请使用 OpenaiChat")`
### 1.2 目标
- 实现独立的 `OpenaiResponseProvider`(不套用 `GenericOpenaiProvider`,参考 `AnthropicProvider` 模式)
- 覆盖 Response API 的核心能力:文本对话、流式输出、Vision 输入、工具调用(function calling
- 新增独立 feature `provider-openai-response`,加入 `full` 快捷组合
- 内置工具(`web_search` / `file_search` / `computer_use`)通过 `MessageRequest.extra` 逃生舱传递
- 多轮接续第一版走全量消息历史模式
### 1.3 范围
| 维度 | 包含 | 不包含 |
|------|------|--------|
| 协议端点 | `POST /responses` | `/responses/{id}/input_items` 等管理端点 |
| 输入模式 | 全量消息历史 + `previous_response_id` | 增量续写优化 |
| 内置工具 | 通过 `extra` 逃生舱透传 | 原生 ToolDef 结构改动 |
| 流式 | SSE 语义事件 → `StreamEvent` | — |
| 结构化输出 | `text.format` | 暂不专项封装 |
---
## 2. 需求分析
### 2.1 功能需求
| # | 需求 | 优先级 | 说明 |
|---|------|--------|------|
| F1 | 文本对话(非流式 + 流式) | P0 | 最基础的对话能力 |
| F2 | Vision 图片输入 | P0 | `UserImage``input_image` |
| F3 | Function Calling 工具调用 | P0 | `ToolDef``{type: "function", ...}` |
| F4 | 多轮接续 | P1 | 全量消息历史模式 |
| F5 | System 消息处理 | P0 | 多个 System 消息拼接到 `instructions` |
| F6 | 流式 SSE 事件映射 | P0 | 按 Response API SSE 事件序列映射 |
| F7 | 内置工具逃生舱 | P2 | `extra` 字段透传 `web_search` / `file_search` |
| F8 | 结构化输出逃生舱 | P2 | `extra` 字段透传 `text.format` |
### 2.2 非功能需求
| # | 需求 | 指标 |
|---|------|------|
| N1 | 编译隔离 | 新增 feature 不增加 `light` / `chat` 组合的依赖 |
| N2 | 测试覆盖 | wiremock 覆盖非流式 + 流式 + 错误路径 |
| N3 | 错误映射 | 复用 `GenericOpenaiProvider` 的错误映射逻辑 |
| N4 | Clippy 合规 | `cargo clippy --all-features --lib -- -D warnings` 通过 |
### 2.3 与 Chat Completions 的差异回顾
| 维度 | Chat Completions | Response API |
|------|-----------------|--------------|
| 端点 | `POST /chat/completions` | `POST /responses` |
| 输入 | `messages: [{role, content}]` | `input: string \| items[]` + 顶层 `instructions` |
| 输出 | `choices[n].message` | `output: []` 异构 items 数组 |
| 内置工具 | 无(仅 function calling | `web_search` / `file_search` / `computer_use` 一等公民 |
| 多轮接续 | 调用方拼接 messages | `previous_response_id` 参数 或 全量回传 |
| 流式 | SSE chunk `choices[n].delta` | SSE 语义事件:`response.text.delta` / `response.output_item.added` 等 |
| 结构化输出 | `response_format` | `text.format` |
| 认证 | `Authorization: Bearer` | 相同 |
| 错误结构 | 相同(401/429/500 | 相同 |
---
## 3. 方案设计
### 3.1 设计决策
| # | 决策 | 选项 | 选择 | 理由 |
|---|------|------|------|------|
| D1 | 实现方式 | 独立 Provider vs 套用 GenericOpenaiProvider | **独立 Provider** | Response API 请求/响应结构与 Chat Completions 差异过大,序列化/反序列化无共用价值 |
| D2 | Feature 粒度 | 合并到 `provider-openai` vs 独立 | **独立 feature** | 与 `AnthropicProvider` 对齐,避免 `full` 组合膨胀 |
| D3 | 加入快捷组合 | 加入 `full` 但不加入 `light` | **`full` 包含** | Response API 属于高级能力,`light` 保持轻量 |
| D4 | 多轮方案 | 全量历史 vs 增量 | **全量历史(模式 A** | 功能正确,无需改动 `LlmCycle` |
| D5 | 内置工具支持 | 改 ToolDef vs extra 逃生舱 | **extra 逃生舱** | 不改已有 IR 类型,最小侵入 |
### 3.2 Feature 定义
```toml
provider-openai-response = ["llm", "reqwest", "bytes", "futures-util"]
```
`provider-openai` / `provider-anthropic` 的依赖集合一致——`llm` 已包含 `tokio` / `async-stream` / `futures-core` / `futures-util` / `tokio-stream`,此处补充 `reqwest`HTTP 客户端)和 `bytes`(流式 buffer 操作)。
`full` 快捷组合追加 `"provider-openai-response"`
### 3.3 新增文件
所有实现集中在单一文件:
```
src/llm/provider/openai_response.rs ← 全部实现(Wire 类型 + Provider 结构体 + 请求转换 + 响应转换 + 流式处理 + 测试)
```
不在 `provider/` 下创建子目录。模块声明在 `src/llm.rs`,在现有 Provider features cfg 条件中追加 `feature = "provider-openai-response"`
```rust
#[cfg(any(
feature = "provider-openai",
feature = "provider-anthropic",
feature = "provider-deepseek",
feature = "provider-qwen",
feature = "provider-ollama",
feature = "provider-openai-response",
))]
pub mod provider;
```
### 3.4 架构概览
```
┌──────────────────────────────────────────────┐
│ OpenaiResponseProvider │
│ ┌──────────────────────────────────────────┐ │
│ │ convert_request() │ │
│ │ MessageRequest → OpenaiResponseRequest │ │
│ └──────────────────┬───────────────────────┘ │
│ │ │
│ ┌──────────────────▼───────────────────────┐ │
│ │ HTTP POST /responses │ │
│ │ (reqwest Client) │ │
│ └──────────────────┬───────────────────────┘ │
│ │ │
│ ┌──────────────────▼───────────────────────┐ │
│ │ convert_response() │ │
│ │ OpenaiResponseBody → MessageResponse │ │
│ └──────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────┐ │
│ │ ResponseSseEventStream │ │
│ │ SSE bytes → StreamEvent 流 │ │
│ └──────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
```
### 3.5 Wire 类型设计
#### 请求体类型
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct OpenaiResponseRequest {
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
pub input: Vec<ResponseInputItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<ResponseTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_response_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub truncation: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<Value>,
}
```
#### Input Item 枚举
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub(crate) enum ResponseInputItem {
Message {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
item_type: Option<String>, // 可选,固定为 "message"assistant 回传时使用)
role: String,
content: Vec<ResponseInputContent>,
},
FunctionCall {
#[serde(rename = "type")]
item_type: String, // 固定为 "function_call"
call_id: String,
name: String,
arguments: String,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<String>,
},
FunctionCallOutput {
#[serde(rename = "type")]
item_type: String, // 固定为 "function_call_output"
call_id: String,
output: String,
},
}
> **关于 `ResponseInputItem` 与 `ResponseOutputItem` 的职责划分**
>
> - **`ResponseInputItem`**`#[serde(untagged)]`):仅用于**请求序列化**`convert_request`),由代码控制枚举变体的生成,永远不会遇到未知的 `item_type`。因此 untagged 模式是安全的,无需 fallback。
> - **`ResponseOutputItem`**(非 untagged`item_type: String` 为必填字段):用于**响应反序列化**(`convert_response`),来自 API 响应。未知的 `item_type` 已通过 §3.7 的 `ContentBlock::Extension` fallback 处理,不会因新增 item 类型而触发 serde 反序列化失败。
/// 消息内容块(嵌套在 Message 变体的 content 数组中)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum ResponseInputContent {
InputText {
text: String,
},
InputImage {
image_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
},
}
```
> **补充说明**Response API 的 `input` 字段还支持简化格式——`input: "Hello"`(单字符串)或 `input: ["Hello", "Hi"]`(字符串数组),但这些格式只能表达纯文本消息。为支持多模态内容(文本 + 图片)和工具调用,本实现使用完整的消息对象数组格式。
#### Tool 类型
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum ResponseTool {
Function {
name: String,
description: String,
parameters: Value,
},
}
```
#### 响应体类型
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct OpenaiResponseBody {
pub id: String,
pub model: String,
pub output: Vec<ResponseOutputItem>,
pub usage: Usage,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ResponseOutputItem {
pub id: String,
#[serde(rename = "type")]
pub item_type: String,
pub status: Option<String>,
pub role: Option<String>,
pub content: Option<Vec<ResponseContentPart>>,
pub call_id: Option<String>,
pub name: Option<String>,
pub arguments: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ResponseContentPart {
#[serde(rename = "type")]
pub part_type: String,
pub text: Option<String>,
}
```
#### SSE 事件类型
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum ResponseSseEvent {
#[serde(rename = "response.created")]
ResponseCreated { response: ResponseSseMeta },
#[serde(rename = "response.completed")]
ResponseCompleted { response: ResponseSseMeta },
#[serde(rename = "response.failed")]
ResponseFailed { error: Option<serde_json::Value> },
#[serde(rename = "response.output_item.added")]
ResponseOutputItemAdded { item: ResponseOutputItem },
#[serde(rename = "response.output_item.done")]
ResponseOutputItemDone { item: ResponseOutputItem },
#[serde(rename = "response.output_text.delta")]
ResponseOutputTextDelta { delta: String, item_id: String },
#[serde(rename = "response.output_text.done")]
ResponseOutputTextDone { text: String, item_id: String },
#[serde(rename = "response.refusal.delta")]
ResponseRefusalDelta { delta: String, item_id: String },
#[serde(rename = "response.refusal.done")]
ResponseRefusalDone { refusal: String, item_id: String },
#[serde(rename = "response.function_call_arguments.delta")]
ResponseFunctionCallArgumentsDelta { delta: String, item_id: String },
#[serde(rename = "response.function_call_arguments.done")]
ResponseFunctionCallArgumentsDone { arguments: String, item_id: String },
#[serde(rename = "error")]
Error { code: String, message: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ResponseSseMeta {
pub id: String,
pub model: String,
pub status: String,
}
```
### 3.6 请求转换(convert_request
#### 消息类型映射
| 输入场景 | Message 类型 | → Response API input item |
|---------|-------------|--------------------------|
| 文本 User | `Message::User { content: [Text] }` | `{role: "user", content: [{type: "input_text", text}]}` |
| Vision | `Message::UserImage { data, mime_type, detail }` | `{role: "user", content: [{type: "input_image", image_url: "data:{mime};base64,{data}", detail}]}` |
| User 多模态 | `Message::User { content: [Text, Image, ...] }` | `{role: "user", content: [{type: "input_text", text}, {type: "input_image", image_url, detail}]}` |
| Assistant 文本 | `Message::Assistant { content: [Text] }` | User 侧:`{role: "assistant", content: [{type: "output_text", text}]}`(无 `type` 字段);回传时: `{type: "message", role: "assistant", content: [{type: "output_text", text}]}`(有 `type: "message"` |
| Assistant 工具调用 | `Message::Assistant { content: [ToolUse] }` | `FunctionCall { call_id, name, arguments }` |
| Assistant 文本+工具 | `Message::Assistant { content: [Text, ToolUse, ...] }` | 一个 `Message(assistant)` + 一个或多个 `FunctionCall` 项 |
| 工具结果 | `Message::ToolResult { tool_call_id, content, is_error }` | `FunctionCallOutput { call_id, output: content }` |
| System | `Message::System { content }` | 拼接到顶层 `instructions` 字段(非 input |
#### 字段映射
| MessageRequest 字段 | → Response API 字段 |
|---------------------|---------------------|
| `model` | `model` |
| `max_tokens` | `max_output_tokens` |
| `temperature` | `temperature` |
| `top_p` | `top_p` |
| `stop_sequences` | `stop` |
| `stream` | `stream` |
| `tools` (ToolDef) | `tools` = `[{type: "function", name, description, parameters}]` |
| `tool_choice` | `tool_choice` |
#### extra 字段映射
| `MessageRequest.extra` key | → Response API 字段 |
|---------------------------|---------------------|
| `previous_response_id` | `previous_response_id` |
| `store` | `store` |
| `metadata` | `metadata` |
| `truncation` | `truncation` |
| `reasoning.effort` | `reasoning: {effort: ...}` |
| 内置工具(`web_search` / `file_search` 等) | 追加到 `tools` 数组 |
> **备注**:当前仅支持 `reasoning.effort` 子字段(值为 `low`/`medium`/`high`),其他子字段(如 `reasoning.summary`)将在后续版本支持。
### 3.7 响应转换(convert_response
| Response API output item | → MessageResponse 中的表示 |
|-------------------------|------------------------------|
| `{type: "message", role: "assistant", content: [{type: "output_text", text}]}` | `Message::Assistant { content: [ContentBlock::Text { text }] }` |
| `{type: "function_call", name, arguments, call_id}` | `ContentBlock::ToolUse { id: call_id, name, input: arguments }` |
| `{type: "web_search_call", ...}` | `ContentBlock::Extension { kind: "web_search_call", data: ... }` |
| `{type: "reasoning", ...}` | `ContentBlock::Extension { kind: "reasoning", data: ... }` |
| `{type: "file_search_call", ...}` | `ContentBlock::Extension { kind: "file_search_call", data: ... }` |
**status → StopReason 映射**
- `completed``StopReason::Stop`
- `incomplete``StopReason::Length`
- `failed``StopReason::Other`
`response.output` 为空数组时,返回 `LlmError::Request { status: 200, body: "empty output" }`,表示响应格式异常。
对于未知的 `item_type`(非 `message`/`function_call`/`web_search_call`/`file_search_call`/`reasoning`),转换为 `ContentBlock::Extension { kind: item_type, data: serde_json::to_value(item)? }` 以保持前向兼容。
### 3.8 流式 SSE 事件映射
| Response API SSE event | → StreamEvent |
|------------------------|---------------|
| `response.created` | `MessageStart { id, model }` |
| `response.output_item.added` (type: message) | `ContentBlockStart { index, block_type: Text }` |
| `response.output_text.delta` | `TextDelta { text }` |
| `response.output_text.done` | `ContentBlockEnd { index }` |
| `response.refusal.delta` | `RefusalDelta { text }` |
| `response.refusal.done` | `ContentBlockEnd { index }` |
| `response.function_call_arguments.delta` | `ToolCallArgumentsDelta { index, arguments }` |
| `response.function_call_arguments.done` | `ToolCallEnd { index }` |
| `response.completed` | `MessageComplete { full_response }` |
| `response.failed` | `Error { message }` |
### 3.9 流式 SSE 状态机
`ResponseSseEventStream` 维护以下状态:
```
字段:
- byte_stream: reqwest 的 bytes_stream
- buffer: Vec<u8>SSE 行缓冲)
- partial: PartialMessageResponse(累积响应状态)
- block_index: u32(输出 block 序号计数器)
- saw_terminal: bool(是否已见到 response.completed / response.failed
流程:
line 级解析 → event: + data: 配对
→ 反序列化 ResponseSseEvent
→ try_into_stream_event() 映射为 StreamEvent
→ StreamEvent::apply_to(&mut partial)
→ yield StreamEvent
response.completed → partial.finalize() → yield MessageComplete
response.failed → yield Error
```
### 3.10 错误映射
复用 `GenericOpenaiProvider``handle_error_response()` 逻辑:
| HTTP 状态码 | → LlmError |
|------------|------------|
| 401 | `LlmError::Authentication(body)` |
| 429 | `LlmError::RateLimit { retry_after }` |
| 5xx | `LlmError::Request { status, body }` |
| 400 + `context_length_exceeded` | `LlmError::ContextLength` |
### 3.11 Provider 结构体
```rust
pub(crate) struct OpenaiResponseProvider {
http_client: Client,
base_url: String,
api_key: String,
model: String,
timeout_secs: u64,
}
```
#### 工厂方法
```rust
impl OpenaiResponseProvider {
pub(crate) fn from_parts(
base_url: String,
api_key: String,
model: String,
http_client: Client,
timeout_secs: u64,
) -> Self {
Self { http_client, base_url, api_key, model, timeout_secs }
}
}
```
### 3.12 LlmProvider trait 实现
```rust
#[async_trait]
impl LlmProvider for OpenaiResponseProvider {
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
self.chat_blocking(request).await
}
async fn chat_stream(
&self,
request: MessageRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
self.chat_stream_inner(request).await
}
fn capabilities(&self) -> ProviderCapabilities { ... }
}
```
### 3.13 Capabilities
```rust
ProviderCapabilities {
provider_name: "openai-response",
supported_models: Some(vec![model]),
features: ProviderFeatures {
streaming: true,
thinking: true, // o-series reasoning
vision: true, // image input
audio_input: false,
tool_use: true,
parallel_tool_calls: true,
system_prompt_in_messages: false,
max_context_window: 200_000,
},
}
```
### 3.14 工厂函数注册
```rust
ProviderType::OpenaiResponse => {
let client = build_client_with_timeout(config.timeout_secs)?;
Ok(Box::new(openai_response::OpenaiResponseProvider::from_parts(
config.base_url,
config.api_key,
config.model,
client,
config.timeout_secs,
)))
}
```
### 3.15 多轮接续方案
第一版走**全量消息历史模式(模式 A)**:
1. `convert_request()``MessageRequest.messages` 全部转换为 `input` items
2. System 消息拼接到 `instructions`
3. User / Assistant / ToolResult 消息转换为对应的 input items
4. 如果 `extra` 中有 `previous_response_id`,也传入请求体
此模式与 `LlmCycle::submit_with_tools()` 完全兼容——`LlmCycle` 在每次提交时都会填充完整的历史 messages,`OpenaiResponseProvider` 只是把这些 messages 全部序列化为 Response API 格式。无需改动 `LlmCycle`
---
## 4. 实施计划
实施拆分为 3 个 Phase9 个 Step。
### Phase 28Feature gate + Wire 类型 + Provider 骨架(~140 行)
#### Step 28.1Cargo.toml feature 定义
**文件操作**:修改 `Cargo.toml`
```toml
# 在 [features] 的 Provider features 区域追加
provider-openai-response = ["llm", "reqwest", "bytes", "futures-util"]
# 在 full 快捷组合中追加
full = [
"...",
"provider-openai-response",
]
```
**验证**`cargo build --features "provider-openai-response"` 编译通过
#### Step 28.2Wire 类型定义
**文件操作**:新建 `src/llm/provider/openai_response.rs`
定义 §3.5 中的所有 Wire 类型:
- `OpenaiResponseRequest`
- `ResponseInputItem`untagged 枚举:Message / FunctionCall / FunctionCallOutput
- `ResponseInputContent`tagged 枚举:InputText / InputImage
- `ResponseTool`
- `OpenaiResponseBody`
- `ResponseOutputItem`
- `ResponseContentPart`
- `ResponseSseEvent`(完整时序事件枚举)
- `ResponseSseMeta`
**无逻辑代码**,只有 `#[derive(Debug, Clone, Serialize, Deserialize)]` 的结构体和枚举。
**验证**`cargo build --features "provider-openai-response"` 编译通过
#### Step 28.3Provider 结构体 + from_parts
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
- `OpenaiResponseProvider` 结构体
- `from_parts()` 工厂方法
- 基础 HTTP 工具函数(`build_request_builder``handle_error_response``map_reqwest_error`
**验证**`cargo build --features "provider-openai-response"` 编译通过
#### Step 28.4Factory 注册 + 模块门控
**文件操作**
1. 修改 `src/llm.rs` — 在 cfg 条件中追加 `feature = "provider-openai-response"`
2. 修改 `src/llm/provider.rs` — 注册 factory
`src/llm.rs` 中修改现有 Provider features cfg 条件:
```rust
#[cfg(any(
feature = "provider-openai",
feature = "provider-anthropic",
feature = "provider-deepseek",
feature = "provider-qwen",
feature = "provider-ollama",
feature = "provider-openai-response",
))]
pub mod provider;
```
以及在 `src/llm/provider.rs``create_provider()` match 中替换当前 `Err` 为真实构造。
**验证**
- `cargo build --features "provider-openai-response"` 编译通过
- `cargo build --features "full"` 编译通过
---
### Phase 29:核心 Provider 实现(~680 行)
#### Step 29.1convert_request~200 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
实现 `OpenaiResponseProvider::convert_request(&self, request: MessageRequest) -> Result<OpenaiResponseRequest, LlmError>`
处理逻辑:
1. 遍历 `request.messages`,按 §3.6 消息类型映射表转换
2. Assistant 消息回传时设置 `item_type: Some("message".to_string())`,使序列化结果为 `{type: "message", role: "assistant", content: [...]}`User 消息保持 `item_type: None`,序列化为 `{role: "user", content: [...]}`(无 `type` 字段)
3. `request.tools``tools` 数组(`ToolDef``ResponseTool::Function`
4. `request.extra` → 解析 `previous_response_id` / `store` / `metadata` / `truncation` / `reasoning`
5. 标准字段映射(model / max_tokens / temperature / top_p / stop / stream
#### Step 29.2convert_response~100 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
实现 `OpenaiResponseProvider::convert_response(&self, response: OpenaiResponseBody) -> Result<MessageResponse, LlmError>`
处理逻辑:
1. 遍历 `response.output`,找到第一个 `type: "message"` 的 item,提取 text
2. 其他 items`function_call``ContentBlock::ToolUse`,内置工具 → `ContentBlock::Extension`
3. `response.status``StopReason`
4. `response.usage``Usage`
#### Step 29.3:非流式 chat()~80 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
实现 `OpenaiResponseProvider::chat_blocking()`
- `convert_request()` → serde 序列化 → HTTP POST `{base_url}/responses`
- Auth header: `Authorization: Bearer {api_key}`
- 错误处理映射
- 解析响应体 → `convert_response()`
#### Step 29.4SSE 事件类型 + 状态机(~230 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
实现 `ResponseSseEventStream` 结构体及其 `Stream` trait
- 字段:`byte_stream`, `buffer`, `partial: PartialMessageResponse`, `block_index: u32`, `saw_terminal: bool`
- 行级 SSE 解析:`event:` + `data:` 配对
- 事件 → `StreamEvent` 映射
- `PartialMessageResponse::apply_to()` 累积
- 流结束时 `finalize()``MessageComplete`
#### Step 29.5:流式 chat_stream()~50 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
实现 `OpenaiResponseProvider::chat_stream_inner()`
- `convert_request()` 设置 `stream: true`
- HTTP POST → bytes_stream → 包装为 `ResponseSseEventStream`
#### Step 29.6LlmProvider impl~50 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
实现 `LlmProvider for OpenaiResponseProvider`
- `chat()``chat_blocking()`
- `chat_stream()``chat_stream_inner()`
- `capabilities()` → 返回 `ProviderCapabilities`
#### Step 29.7:单元测试(~70 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs``#[cfg(test)] mod tests {}`
| 测试 | 场景 |
|------|------|
| `convert_request_text_only` | 纯文本输入转换 |
| `convert_request_vision` | Vision 输入转换 |
| `convert_request_tool_call` | 工具调用输入转换 |
| `convert_response_message` | 响应 message item 转换 |
| `convert_response_tool_use` | 响应 function_call item 转换 |
---
### Phase 30:测试 + CI + 文档(~520 行)
#### Step 30.1wiremock 非流式测试(~200 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs` 内联测试
| 测试 | 场景 | 验证 |
|------|------|------|
| `response_api_basic_text` | 纯文本响应 | `response.text()` 正确 |
| `response_api_tool_call` | 工具调用 | `stop_reason == ToolUse` |
| `response_api_multi_turn` | 两轮对话 | 第二轮携带历史 |
| `response_api_vision` | 图片输入 | 正确构造 `input_image` |
| `response_api_unauthorized` | 401 错误 | `LlmError::Authentication` |
| `response_api_rate_limit` | 429 错误 | `LlmError::RateLimit` |
| `response_api_server_error` | 500 错误 | `LlmError::Request` |
#### Step 30.2wiremock 流式测试(~200 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs` 内联测试
| 测试 | 场景 | 验证 |
|------|------|------|
| `response_api_stream_text` | 流式文本 | 完整 SSE 事件序列 |
| `response_api_stream_tool` | 流式工具调用 | `FunctionCallArgumentsDelta` 序列 |
| `response_api_stream_error` | 流中途失败 | `StreamEvent::Error` |
| `response_api_stream_multi_turn` | 流式多轮接续 | 第二轮携带历史消息时的完整 SSE 事件序列 |
#### Step 30.3CI 矩阵(~10 行)
**文件操作**:修改 `.github/workflows/ci.yml`
新增测试组合:
```yaml
- "chat,provider-openai,provider-openai-response"
```
#### Step 30.4:文档更新(~50 行)
**文件操作**:修改 `README.md` + `docs/roadmap.md`
- README feature 表新增 `provider-openai-response`
- `docs/roadmap.md``docs/roadmap-unsorted.md` 新增 v0.3.3 或下版本条目
#### Step 30.5Example~60 行)
**文件操作**:新建 `examples/response_api_demo.rs`
```toml
[[example]]
name = "response_api_demo"
required-features = ["llm", "provider-openai-response"]
```
基础对话示例,展示 Response API 的基本用法:
```
cargo run --example response_api_demo --features "full"
```
---
### 实施汇总
| Phase | 内容 | 代码行数估算 | 验证入口 |
|-------|------|------------|---------|
| 28 | Feature gate + Wire 类型 + Provider 骨架 | ~140 | `cargo build --features "provider-openai-response"` |
| 29 | 核心 Provider 实现(转换/HTTP/流式) | ~680 | 5 个单元测试 |
| 30 | 测试 + CI + 文档 | ~520 | 10 个 wiremock 测试 + CI 新组合 |
| **合计** | | **~1,340** | 全量 `cargo test --features "full"` |
---
## 5. 风险评估
| ID | 风险 | 影响 | 概率 | 缓解措施 |
|----|------|------|------|---------|
| R1 | Response API 协议快速迭代 | Wire 类型可能需更新 | 中 | Wire 类型集中在单个文件内,更新成本低 |
| R2 | `ContentBlock::Extension` 承载内置工具结果 | 下游消费方需适配 | 低 | 这是既有的逃生舱机制,已有消费模式 |
| R3 | 全量历史模式 token 开销 | 多轮时 input tokens 增长 | 低 | 功能正确,后续版本可优化为 `previous_response_id` 增量模式 |
| R4 | `instructions` 拼接多个 system 消息 | 语义可能与单 system 消息不同 | 低 | 已确认按 OpenAI 推荐方式全量拼接(`\n` 分隔),行为等价 |
| R5 | 与 `GenericOpenaiProvider` 的错误映射逻辑重复 | 维护两份相似逻辑 | 低 | 提取复用函数时需注意不影响现有 provider |
---
## 6. 验证标准
### 6.1 编译验证
| # | 检查项 | 命令 |
|---|--------|------|
| C1 | 独立 feature 编译 | `cargo build --features "provider-openai-response"` |
| C2 | full 组合编译 | `cargo build --features "full"` |
| C3 | light 组合不受影响 | `cargo build --features "light"`(不包含新 feature |
| C4 | Clippy 合规 | `cargo clippy --all-features --lib -- -D warnings` |
### 6.2 测试验证
| # | 检查项 | 通过条件 |
|---|--------|---------|
| T1 | 单元测试 | `cargo test --features "full"` 全部通过(+15 新增测试) |
| T2 | 非流式 wiremock | 7 个测试覆盖文本/工具/多轮/Vision/401/429/500 |
| T3 | 流式 wiremock | 3 个测试覆盖文本流/工具流/错误流 |
| T4 | 现有测试无回归 | 使用 `--features "full"` 时已有 427 测试全部通过 |
### 6.3 CI 验证
| # | 检查项 | 通过条件 |
|---|--------|---------|
| I1 | 新增 CI 组合 | 包含新 feature 的组合编译通过 |
| I2 | clippy + format | `cargo clippy` + `cargo fmt --check` 通过 |
### 6.4 Example 验证
| # | 检查项 | 通过条件 |
|---|--------|---------|
| E1 | Example 编译 | `cargo build --example response_api_demo --features "full"` 通过 |
| E2 | Example 运行 | `cargo run --example response_api_demo --features "full"` 可执行(需 API key |
@@ -0,0 +1,422 @@
# OpenAI Response Provider 自定义请求头支持
## 背景
OpenAI Responses API 的部分实现(如火山引擎豆包)需要携带特殊的 HTTP 请求头(如 `ark-beta-doubao-app: true`)来启用平台特定功能。当前 `OpenaiResponseProvider``build_request_builder()` 中只设置了 `Authorization` 头,没有途径注入自定义请求头。
原方案只覆盖 OpenAI Response Provider。经讨论后扩展为**三 Provider 统一**方案:OpenAI Chat`GenericOpenaiProvider`)、OpenAI Response`OpenaiResponseProvider`)、Anthropic`AnthropicProvider`)。
核心动机:
- OpenAI Responses API 的部分实现需要携带特殊 HTTP 请求头来启用平台特定功能
- 三种基础协议中,自定义头注入能力不一致
- 统一 API 让调用方用 `set_extra("custom_headers", ...)` 即可,与底层协议无关
## 需求
### 功能需求
双层自定义头机制:
- **Provider 级固定头**`extra_headers: Vec<(String, String)>`,构造时注入,所有请求自动携带。用于该 provider 所有请求都需要的固定标识头(如平台接入标记)
- **请求级临时头**`extra.custom_headers: HashMap<String, String>`,通过 `set_extra` 注入。用于特定请求需要覆盖或追加的头
### 约束
- 不可引入任何平台特定逻辑(火山、豆包等字符串不得出现)
- 自定义头仅运行时生效,不进入 JSON 序列化的请求体
- 兼容已有的 extra 逃生舱机制(builtin_tools、text_format 等)
- agcore 是支持库,不提供运行时敏感头过滤保护(如 Authorization/Cookie),但文档中应说明风险
- 不修改 `LlmProvider` trait、`ProviderType` 枚举
- `create_provider()` 工厂函数只传 `Vec::new()` 作为 extra_headers 默认值,不暴露配置能力;调用方如需 Provider 级固定头,直接构造 provider 后链式调用 `.with_extra_headers()`
### 用户故事
1. 作为集成者,我想对任意 provider 的请求注入自定义 HTTP 头,以启用平台特有功能(请求级)
2. 作为集成者,我想在 provider 构造时注入固定头,让所有请求自动携带,避免每次重复指定(Provider 级)
3. 作为维护者,我想三种基础协议使用统一的 API,调用方无需关心底层 provider 类型
## 方案设计
### 统一设计原则
```
调用方视角(统一 API):
request.set_extra("custom_headers", json!({"X-Foo": "bar"}));
// 不管底层是 OpenAI Chat / OpenAI Response / Anthropic,都能工作
构造方视角(Provider 级):
OpenaiResponseProvider::from_parts(..., extra_headers).with_extra_headers(...);
GenericOpenaiProvider::from_parts(..., extra_headers); // 已有
AnthropicProvider::from_parts(..., extra_headers);
头融合顺序(三 provider 一致):
认证头 (Authorization / x-api-key) → Provider 级 extra_headers → 请求级 custom_headers
↑ 后者覆盖前者
```
### 改动一:GenericOpenaiProvideropenai.rs
**① `OpenaiChatRequest` 新增字段**
`extra_body`(第 147 行)之后:
```rust
/// 请求级别自定义 HTTP 头。运行时注入,不进入 JSON 请求体。
/// ⚠️ 与 struct 已有的 `extra_headers: Option<Value>`OpenAI API 自身的 wire 格式字段)
/// 不同——后者是 OpenAI API 参数,本字段是 reqwest 层的 HTTP 头注入。
#[serde(skip)]
pub custom_headers: HashMap<String, String>,
```
`#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。
**② `convert_request()` 从 extra 提取**
`parallel_tool_calls`(第 559 行)之后:
```rust
let custom_headers: HashMap<String, String> = request
.get_extra_opt("custom_headers")
.unwrap_or_default();
```
**③ `build_request_builder()` 签名改具体类型 + 注入逻辑**
第 454 行,签名从 `&impl Serialize` 改为 `&OpenaiChatRequest`(两处调用点传入的均为该类型,安全):
```rust
fn build_request_builder(
&self,
url: &str,
body: &OpenaiChatRequest, // 从 &impl Serialize 改为具体类型
) -> Result<reqwest::RequestBuilder, LlmError> {
let mut builder = self
.http_client
.post(url)
.header("Authorization", format!("Bearer {}", self.api_key));
// 头融合顺序见上方「统一设计原则」。
// Provider 级固定头先注入,请求级临时头后注入(后者覆盖前者)。
Ok(builder.json(body))
}
```
两处调用点(`chat_blocking` 第 628 行、`chat_stream_inner` 第 669 行)传入的都是 `&OpenaiChatRequest`,零影响。
**④ `with_extra_headers()` builder 方法**
```rust
/// 注入 Provider 级别固定头。返回 self 以支持链式调用。
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
self.extra_headers = headers;
self
}
```
### 改动二:OpenaiResponseProvideropenai_response.rs
**① struct 新增 `extra_headers` 字段**
第 287 行,`pub struct OpenaiResponseProvider` 增加:
```rust
pub struct OpenaiResponseProvider {
// ... 已有字段 ...
extra_headers: Vec<(String, String)>,
}
```
**② `from_parts()` 新增参数**
第 299 行:
```rust
pub(crate) fn from_parts(
base_url: String,
api_key: String,
model: String,
http_client: Client,
timeout_secs: u64,
extra_headers: Vec<(String, String)>, // 新增
) -> Self { ... }
```
**③ `with_extra_headers()` builder 方法**
```rust
/// 注入 Provider 级别固定头。返回 self 以支持链式调用。
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
self.extra_headers = headers;
self
}
```
**④ `OpenaiResponseRequest` 新增字段**
第 73 行,`reasoning` 之后:
```rust
/// 请求级别自定义 HTTP 头。序列化时跳过,仅运行时由 build_request_builder 消费。
/// stream 模式的修改不影响该字段——header 由 convert_request 在请求构造时注入。
#[serde(skip)]
pub custom_headers: HashMap<String, String>,
```
`#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。
**⑤ `convert_request()` 从 extra 提取**
第 404 行,`reasoning` 之后:
```rust
let custom_headers: HashMap<String, String> = extra
.get("custom_headers")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
```
> **注意**OpenaiResponseProvider 的 `convert_request` 在顶部 destructure 了 `request`,因此使用 `extra.get()` 而非 `request.get_extra_opt()`。两者语义一致,均反序列化为 `HashMap<String, String>`,失败时静默降级为空 HashMap。
**⑥ `build_request_builder()` 签名 + 注入逻辑**
第 319 行,签名从 `&impl Serialize` 改为 `&OpenaiResponseRequest`(两处调用点传入的均为该类型,安全):
```rust
/// 构造 HTTP POST 请求 builder(含认证头与额外请求头)。
///
/// 头融合顺序:Authorization → Provider 级 extra_headers → 请求级 custom_headers
/// 后者覆盖前者。
fn build_request_builder(
&self,
body: &OpenaiResponseRequest, // 从 &impl Serialize 改为具体类型
) -> Result<reqwest::RequestBuilder, LlmError> {
let mut builder = self
.http_client
.post(self.endpoint_url())
.header("Authorization", format!("Bearer {}", self.api_key));
for (k, v) in &self.extra_headers {
builder = builder.header(k.as_str(), v.as_str());
}
for (key, value) in &body.custom_headers {
builder = builder.header(key.as_str(), value.as_str());
}
Ok(builder.json(body))
}
```
两处调用点(`chat_blocking` 第 708 行、`chat_stream_inner` 第 741 行)传入的都是 `&OpenaiResponseRequest`,零影响。
### 改动三:AnthropicProvideranthropic.rs
AnthropicProvider 是唯一没有统一 `build_request_builder` 方法的 provider,需要**前置重构**。
**① struct 新增 `extra_headers` 字段**
第 36 行:
```rust
pub struct AnthropicProvider {
// ... 已有字段 ...
extra_headers: Vec<(String, String)>,
}
```
**② `from_parts()` 新增参数**
第 128 行:
```rust
pub(crate) fn from_parts(
base_url: String,
api_key: String,
model: String,
http_client: Client,
timeout_secs: u64,
extra_headers: Vec<(String, String)>, // 新增
) -> Self { ... }
```
**③ `with_extra_headers()` builder 方法**
```rust
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
self.extra_headers = headers;
self
}
```
**④ `AnthropicRequestBody` 新增字段**
第 450 行,`stream` 之后:
```rust
struct AnthropicRequestBody {
model: String,
max_tokens: u32,
// ... 已有字段 ...
/// 请求级别自定义 HTTP 头。运行时注入,不进入 JSON 请求体。
#[serde(skip)]
custom_headers: HashMap<String, String>,
}
```
`#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。
**⑤ `build_request_body()` 从 extra 提取**
```rust
let custom_headers: HashMap<String, String> = request
.get_extra_opt("custom_headers")
.unwrap_or_default();
```
**⑥ 提取 `build_request_builder()` 统一方法(前置重构)**
```rust
/// 构造 HTTP POST 请求 builder(含认证头 + 自定义头)。
/// 认证头(x-api-key / anthropic-version)已由 Client 的 default_headers 提供。
fn build_request_builder(
&self,
body: &AnthropicRequestBody,
) -> Result<reqwest::RequestBuilder, LlmError> {
let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
let mut builder = self.http_client.post(&url).json(body);
for (k, v) in &self.extra_headers {
builder = builder.header(k.as_str(), v.as_str());
}
for (key, value) in &body.custom_headers {
builder = builder.header(key.as_str(), value.as_str());
}
Ok(builder)
}
```
**⑦ 改造 `chat_blocking()``chat_stream_inner()`**
改造前(`chat_blocking`,第 263-269 行):
```rust
let response = self
.http_client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| self.map_reqwest_error(e))?;
```
改造后:
```rust
let response = self
.build_request_builder(&body)?
.send()
.await
.map_err(|e| self.map_reqwest_error(e))?;
```
`chat_stream_inner`(第 298-304 行)同理。
### 改动四:create_provider()provider.rs
依据约束「`create_provider()` 工厂函数不暴露配置能力」,三处分支适配 `from_parts` 的新签名时全部传 `Vec::new()`
```rust
// OpenaiResponse(第 199-207 行)
openai_response::OpenaiResponseProvider::from_parts(
config.base_url, config.api_key, config.model,
client, config.timeout_secs,
Vec::new(), // extra_headers 默认空
)
// Anthropic(第 215-221 行)
anthropic::AnthropicProvider::from_parts(
config.base_url, config.api_key, config.model,
client, config.timeout_secs,
Vec::new(), // extra_headers 默认空
)
// OpenAI Chat(第 185-194 行)— 已有 Vec::new(),无需改动
```
### 调用方式
**请求级临时头**(统一 API,三 provider 通用):
```rust
request.set_extra("custom_headers", serde_json::json!({
"ark-beta-doubao-app": "true"
}));
```
**Provider 级固定头**(构造时注入):
```rust
let provider = OpenaiResponseProvider::from_parts(...)
.with_extra_headers(vec![
("ark-beta-doubao-app".into(), "true".into()),
]);
```
## 风险评估
### 风险点与缓解措施
| 风险 | 等级 | 缓解措施 |
|------|------|---------|
| 用户通过 `custom_headers` 覆盖 `Authorization` 等认证头 | 中 | 文档说明:自定义头按遍历顺序注入,同 key 后注入覆盖前注入。agcore 作为支持库不做运行时拦截 |
| `serde_json::from_value` 类型错误静默降级为空 HashMap | 低 | 与已有 extra 字段(builtin_tools、text_format)一致的模式,保持行为统一。类型错误时请求正常发出,只是不携带自定义头 |
| HashMap 迭代顺序不确定影响测试确定性 | 低 | HTTP 协议不要求 header 顺序,wiremock 按名匹配。无需特殊处理 |
| AnthropicProvider 前置重构引入回归 | 低 | 提取 `build_request_builder` 是纯重构,现有测试覆盖其请求构造行为。重构后运行现有测试套件即可验证 |
| `build_request_builder` 签名从泛型改为具体类型 | 低 | 已确认两处调用点(chat_blocking / chat_stream_inner)传入的均为具体类型,零影响 |
| AnthropicProvider 的 `default_headers`x-api-key / anthropic-version)与 `extra_headers` 同名头合并行为取决于 reqwest 实现 | 低 | 明确约定 Provider 级固定头不应意图覆盖认证头;`build_request_builder` 的 doc comment 中标注认证头来源 |
### 设计取舍记录
| 决策 | 选择 | 理由 |
|------|------|------|
| Provider 级 vs 请求级 | 双层都支持 | 满足固定头和临时头两种场景 |
| `create_provider` 是否暴露 extra_headers | 不暴露,只传 `Vec::new()` | 保持工厂函数签名简洁,固定头通过 builder 方法注入 |
| 敏感头保护 | 不做运行时拦截,文档说明 | agcore 是支持库,不替调用方做保护 |
| `OpenaiChatRequest.custom_headers` 命名 | 用 `custom_headers` 而非 `extra_headers` | 避免与已有的 `extra_headers: Option<Value>`OpenAI API wire 字段)混淆 |
## 验证标准
### 单元测试(每 provider 4 个)
| 测试 | 验证点 |
|------|--------|
| `*_custom_headers_from_extra` | `convert_request` / `build_request_body` 能从 extra 提取 `custom_headers` |
| `*_custom_headers_skipped_in_json` | `#[serde(skip)]` 确保 custom_headers 不进入序列化 JSON body |
| `*_custom_headers_invalid_type_fallback` | 传入错误类型(如字符串而非对象)时静默降级为空 HashMap |
| `*_extra_headers_from_constructor` | 验证 `from_parts` / `new_with_name_and_headers` 传入的 `extra_headers``build_request_builder` 中被正确注入到 HTTP 请求头 |
### 集成测试(每 provider 4 个,wiremock
| 测试 | 验证点 |
|------|--------|
| `*_custom_headers_are_sent` | mock 匹配器验证 HTTP 请求确实携带自定义头 |
| `*_provider_level_headers_are_sent` | 验证 Provider 级固定头(通过 `with_extra_headers` 注入)确实出现在 HTTP 请求中 |
| `*_custom_headers_override_provider_headers` | 当 Provider 级和请求级设置了相同 key 但不同值时,最终 HTTP 请求携带的是请求级的值 |
| `*_custom_headers_can_override_auth_header` | 注入含 `Authorization` 同 key 的 `custom_headers`,验证最终认证头值被覆盖(使行为可见、可预测,与文档风险说明一致) |
### 回归验证
1. 运行 `cargo test --features full` 确保所有现有测试通过
2. `cargo clippy --features full` 无新警告
3. `cargo fmt --check` 格式一致
## 不涉及的改动
- 不新增 Feature gate
- 不修改 `LlmProvider` trait
- 不修改 `ProviderType` 枚举
- 不新增任何平台相关代码
+375
View File
@@ -0,0 +1,375 @@
# Phase 0 剩余模块 — 实施方案
> 定稿日期:2026-06-02
## 背景与目标
AG Core Phase 0Foundation)已完成核心数据类型、错误体系、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 注入问题分析结论
+611
View File
@@ -0,0 +1,611 @@
# Phase 1: Prompt Engineering — 方案设计
> 定稿日期:2026-06-02
## 背景与目标
AG Core Phase 0Foundation)已完成 LLM 调用周期的全部基础设施。Phase 1 的目标是补齐**提示词工程**能力,提供提示词的组合、模板化与优化能力,使其能直接服务于 Phase 2(工具系统)和 Phase 4Agent 运行时)。
**目标**:实现 `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 有文档注释
+996
View File
@@ -0,0 +1,996 @@
# Phase 2: Tool System — 方案设计
> 定稿日期:2026-06-03
## 背景与目标
AG Core Phase 0Foundation)已完成 LLM 调用周期基础设施,Phase 1Prompt 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 协议客户端
MCPModel 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 消息必须紧跟在对应的 Assistanttool_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_callsparallel_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% | 工具结果包含页面内容 |
| 多工具数据 pipeline5-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 CRUDstore/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 3Memory 工具化)
├─ MemoryStore trait(扩展 BaseTool
├─ memory_store / memory_recall / memory_search 等作为工具注册
└─ ToolContext.progress 支持(分批返回检索结果)
Phase 4Agent + 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` traitname / 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 Assistanttool_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: McpClientMCP 协议客户端)
- 创建 `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 签名的情况下注入上下文
+655
View File
@@ -0,0 +1,655 @@
# 记忆系统设计方案
> 设计日期:2026-06-07
> 状态:待实现
---
## 1. 背景与目标
### 1.1 背景
AG Core 已完成 Phase 0LLM 调用周期)、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 RuntimePhase 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 {
/// 保存/覆盖一个 MemoryItemupsert 语义)。
/// - 如果 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 2ConversationMemory(含消息淘汰)
**文件**`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 3KnowledgeStore
**文件**`src/memory/knowledge.rs`
- 定义 `KnowledgePage``PageIndexEntry` 类型
- 实现 `KnowledgeStore` 具体 struct(非 trait
- 内部使用 `Arc<dyn MemoryStore>` 存储数据
- index 自动维护(add/update/delete 时同步)
- search 基于标题/摘要/标签的关键词匹配
- 单元测试:页面 CRUD、index 一致性、搜索
- 验收:`cargo build` + `cargo test` 通过
### Step 4MemoryRetriever + 模块整合
**文件**`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+ 无 logPhase 4 Agent 负责) | 日志是工作流层职责,非存储层 |
| LLM Agent 全权维护 | KnowledgeStore 提供数据接口,Phase 4 Agent 编排工作流 | core 只提供存储能力,不编排 |
| 文件系统为后端 | MemoryStore trait 抽象后端 | 可插拔设计需要 trait 抽象 |
| 基于文件系统搜索 | index + keyword 检索 | 文件系统搜索不适合所有后端 |
+884
View File
@@ -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 0LLM 调用周期)、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 RuntimePhase 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 / MemoryPhase 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 │ ◄── 触发 OnPlanStepCompletestatus=Running
└──────┬──────┘
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────┐
│Completed│ │ Failed │ │ Skipped │
└─────────┘ └──────────┘ └──────────┘
触发 OnPlanStepCompletestatus=Completed
触发 OnPlanStepCompletestatus=Failed
触发 OnPlanStepCompletestatus=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] AgentSessionsubmit_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/TypeScriptOpenHuman 虽是 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 providerPhase 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 已有能力"装配"成"智能体"的概念。**
+434
View File
@@ -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 keyCI 会跳过 | 中 | 高 | 用 `#[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 ChatDeepSeek、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-§8LlmCycle 改造(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--统一请求) | ✅ 已推演(方案 BResult-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) | ✅ 已推演(方案 ASseByteStream 通用层 + 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-的端到端流程) | ✅ 已推演(方案 CMessageComplete 兜底 + 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 ChatDeepSeek、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
+479
View File
@@ -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 blockAnthropic 风格)
/// - 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_idOpenAI 格式需要)
tool_call_id: String,
},
}
```
#### 与当前 OpenaiChatMessage 的映射
| 当前类型 | 新 IR 类型 | 说明 |
|---------|-----------|------|
| `OpenaiChatMessage::Developer { content, name }` | `Message::System { content }` | 合并到 SystemAnthropic 无 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::ToolUserefusal → 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 的本质是逃生舱——如果给逃生舱做全类型安全,就失去了逃生舱的灵活性。
> 方向 APer-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 到自定义 structProvider 内部使用)
> - `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` 类型可以直接复用,无需修改。
+446
View File
@@ -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_tokensmessage_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. #4ToolCallStart 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 | 安全降级 |
+459
View File
@@ -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 callfunction.name 有值)
> │ │ → ensure_block(ToolUse, id, name) → (不产参数事件,等后续 arguments)
> │ └─ 已有 tool callfunction.arguments 有值)
> │ → ToolCallArgumentsDelta(index, arguments)
> │
> └─ Phase 2: 处理汇总(后收束)
> ├─ finish_reason 存在 → close_current_block() + MessageComplete
> ├─ usage 存在 → CostUpdate
> └─ 同时存在 → CostUpdate → close_block → MessageComplete
> ```
>
> **核心抽象 `ensure_block`** 当新 chunk 的 delta 类型与当前活跃 block 不同时,
> 自动关闭当前 blockemit `ContentBlockEnd`)并开启新 blockemit `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 发 TextDeltaPhase 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::Erroris_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 AnthropicProviderMessages 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 | 3PendingStart、Active、Terminated |
> | index 管理 | `HashMap<openai_idx, global_idx>` 映射 | 直接使用 Anthropic index1: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 index1: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 后梳理)
+435
View File
@@ -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 traitchat、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` 为 Nonemessages 中的 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 层叠加额外处理
// (当前 pipelinestream → 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 大小估算 |
>
> ---
>
> ### 议题 3Thinking 是否在压缩范围内
>
> **决策:方案 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, &registry).await?;
self.cost_so_far.add(&response.usage); // Usage 类型不变
// 新
let response: MessageResponse = cycle.submit_with_tools(input, &registry).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 中先保留旧类型别名以减少改动。
+121
View File
@@ -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。
+96
View File
@@ -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 编译通过 | 编译检查 |
View File
View File
+200
View File
@@ -0,0 +1,200 @@
# AG Core Roadmap — Unsorted
> 本文件存放**尚未归到任何具体版本**的 roadmap 内容:跨版本的全局视图、面向未来的展望、风险与建议、阶段总回顾。
>
> **已分版本的内容**:请查阅
> - [`roadmap-v0.1.0.md`](./roadmap-v0.1.0.md) — Phase 04c + v0.1.0 Release
> - [`roadmap-v0.2.0.md`](./roadmap-v0.2.0.md) — Phase 512 + v0.2.0-rc.1
> - [`roadmap-v0.3.0.md`](./roadmap-v0.3.0.md) — Phase 1319(全部完成)
> - [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md) — Phase A-E 多 Agent 编排路线图
>
> 返回总入口:[`roadmap.md`](./roadmap.md)
---
## 全局愿景
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
**当前状态**v0.3.5。Phase 0-30 全部完成。v0.4.0 规划已确定,覆盖 5 个增量 Phase(A-E):Swarm 编排抽象、结果聚合、Human-in-the-loop + 用户 Steering、TokenJuice 语义压缩、自动校正。目标是从"多 Agent 基础系统"升级为"多 Agent 多职责编排系统"。
---
## 模块完整性评估
| 功能领域 | 方案状态 | 文档位置 | 实现优先级 |
|---------|---------|---------|-----------|
| LLM 调用周期 | ✅ 完整 | `specs/llm-call-lifecycle.md` | P0 |
| 提示词工程 | ✅ 完整 | `docs/4-prompt-engineering.md` | P1 |
| 工具系统 + 权限 | ✅ 完整 | `docs/5-tool-system.md` | P1 |
| 记忆检索 | ✅ 完整 | `docs/6-memory-system.md` | P2 |
| Agent 运行时(4a 胶水层) | ✅ 已实现 | `docs/7-agent-runtime.md` | P2 |
| 生命周期钩子 | ✅ 完整 | `docs/3-phase0-remaining.md` | P0LLM Cycle 扩展) |
| Provider 注册发现 | ✅ 完整 | `docs/3-phase0-remaining.md` | P0Provider 接口扩展) |
| 流式事件系统 | ✅ 完整 | `docs/3-phase0-remaining.md` | P0(流式接口前置) |
---
## v0.4.0 规划
v0.4.0 的完整规划已移入独立的 [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md),包含 5 个增量 Phase
| Phase | 内容 | 状态 |
|-------|------|------|
| **Phase A** | Swarm 编排(Star/Sequential/Hierarchical + Subgraph | 📋 待实施 |
| **Phase B** | 结果聚合 + 编排模式完善 | 📋 待实施 |
| **Phase C** | Human-in-the-loop + 用户 Steering | 📋 待实施 |
| **Phase D** | TokenJuice 语义压缩(工具结果/历史/跨 Agent) | 📋 待实施 |
| **Phase E** | 自动校正 / Reflection | 📋 待实施 |
### 未来版本(v0.5+
以下功能已从 v0.4 范围移出:
| 功能 | 说明 |
|------|------|
| Agent 自动创生 | LLM 自主决定何时派发子 agent — 设计复杂,v0.4 专注显式声明式编排 |
| 分布式 session 共享(Redis 后端) | 与编排正交,多数用户单进程即可 |
| 精确 tokenizer 计数(tiktoken-rs | 依赖引入,不在 v0.4 核心范围内 |
| 增量 Checkpoint | 存储优化,当前全量 JSON 够用 |
| 路线 BStateGraph 通用图引擎) | 预留为路线 A 的未来升级路径 |
| RL 轨迹导出 | 专项需求 |
### 明确不做(agcore 范围外)
| 功能 | 原因 |
|------|------|
| TUI / 多平台 Gateway | 应用层职责(Feishu / Telegram / Discord 桥接) |
| 配置自动加载(config/figment) | 配置来源策略应由上游应用决定,agcore 不定义配置格式 |
| 提示词自动优化 | 属于智能层,不应内建于 core 库 |
---
## 风险与建议
1. **持久化依赖**`rusqlite` + `bundled` 零外部依赖编译,但 SQLite 不适配所有场景(分布式/高并发写)。`MemoryStore` trait 的抽象层允许下游自行实现 Redis / PostgreSQL 后端
2. **ContextSlot 心智负担**`ContextSlot` 引入了一等抽象的复杂度。建议通过 `AgentBuilder` 默认创建 `"default"` slot,让简单场景无感使用
3. **向量检索规模上限**v0.3 的 `PersistentVectorStore` 全量加载到内存做余弦搜索,适合 ≤10 万条向量。超出此规模需换用专用向量库。v0.4 可以评估引入
4. **Scope 蔓延**v0.3 新增 `agent/summary` `document/` `engine/` `memory/vector_store` 模块,功能覆盖扩展到多 Agent 基础系统。始终保持 trait + reference impl 的边界,业务循环留给上层(Phase 16 已交付 `agent/summary` 摘要生产端 + `format_messages_as_text` 简洁版格式化 + 30K 字符整体截断保留最新;Phase 18 已交付 `engine/switch_agent` 热切换 + `engine/sub_agent` 调度全栈(dispatch / dispatch_all / dispatch_stream);实施后两轮审查 PASS,0 🔴 阻塞)
5. **API 稳定性**v0.3 引入 `Checkpointer``SessionManager``VectorStore` 等新公开 APIv0.2 已有的 `#[non_exhaustive]``#[deprecated]` 机制继续沿用
6. **Checkpointer 存储效率**:v0.3 使用全量 JSON 序列化存储 checkpoint,每轮对话约几百 KB。`fork` 从历史 checkpoint 创建新 session 时也会复制全量。等实际使用中发现存储瓶颈时再改为增量模式
---
## 下一步行动
1. **v0.4.0 启动**:按 [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md) 规划,从 Phase A(Swarm 编排)开始实施
2. **Phase A 实施**engine/supervisor.rs + tools/builtin.rs + Swarm::star/sequential/hierarchical
3. **示例先行**:每个 Phase 交付时同步提交对应的示例程序
4. **里程碑追踪**:以 M16-M20 为目标里程碑,逐 Phase 推进
---
**已完成 / 进行中阶段**
- ✅ Phase 0 Foundation — 全部交付物已完成
- ✅ Phase 1 Prompt Engineering — 全部交付物已完成
- ✅ Phase 2 Tool System — 全部交付物已完成
- ✅ Phase 3 Memory System — 全部交付物已完成
- ✅ Phase 4a Core Glue — 全部交付物已完成
- ✅ Phase 4b Task Execution — 全部交付物已完成
- ✅ Phase 4c Session Memory — 全部交付物已完成
- ✅ Phase 5 Warmup — ProviderConfig::from_env + OllamaProvider + `#[non_exhaustive]` 前置标记(ProviderType / StopReason / FinishReason / EvictionPolicy
- ✅ Phase 6 ToolDefinition IR — `ToolDef` 新类型 + 双向 `From` 转换 + 别名彻底移除 + `#[allow(deprecated)]` 清理(cycle/registry/mcp/agent);Anthropic 零改动;roundtrip 测试覆盖
- ✅ Phase 7 SqliteStore — `rusqlite 0.32` + WAL 模式 + `Arc<Mutex<Connection>>` + `spawn_blocking``memory/store.rs``store/{in_memory,sqlite_store}.rs` 模块化;9 个内联测试覆盖 CRUD/upsert/过滤/10×10 并发/持久化 round-trip`InMemoryStore ↔ SqliteStore` trait-box 互换兼容
- ✅ **Phase 8 MVP 集成出口** — 14 个公开枚举追加 `#[non_exhaustive]`P0 核心 IR + P0 Error + P1 其他) + `StepStatus::Completed(ChatResponse)``Completed(MessageResponse)` 迁移 + CHANGELOG v0.2.0-rc.1 + 2 个新示例(`quick_start` 60 行 + `end_to_end` 246 行),10 个离线示例全部 exit 0**v0.2.0-rc.1 标签已打**;实施后三方审查发现 6 项问题(1 🔴 + 2 🟡 + 3 💭)已全部修复
- ✅ **Phase 9 流式体验增强**`AgentSession::submit_turn_stream` 流式事件序列 + `LlmCycle::submit_with_tools_stream` spawn + mpsc 状态机 + `StreamEvent::ToolExecutionStarted`/`Completed` 新变体 + 9 单元测试 + 2 集成测试(含 `submit_turn_stream_end_to_end` 端到端 mock 验证 + `submit_turn_stream_triggers_turn_hooks` Hook 触发验证),全量 200 → 211;`CycleConfig``Clone` derive;方案文档 `docs/16-phase9-streaming-experience.md`821 行)
- ✅ **Phase 10 ContextSlot 上下文管理**`src/agent/context.rs` 新增 `ContextSlot` 核心类型(Full / Focused / Readonly 三种模式,New / Derived / Static 三种来源)+ JSON blob 批次持久化(每 slot 3-4 条 MemoryItem`slot_config` key 自恢复支持旧版本兼容);`AgentSession` 扩展 slots 字段 + 5 个管理方法(`create_slot` / `switch_slot` / `list_slots` / `derive_slot` / `delete_slot`,自动创建 `"default"` slot`delete_slot` 双重保护禁止删 default/最后一个);`submit_turn`/`finalize_turn` 改造为基于当前 slot 的增量追加写回(`cycle.messages()[input_len..]` 提取本轮新增消息,确保 Focused 模式"读时过滤"语义不丢失数据);`finalize_turn` 签名变更(新增 `new_messages_from_cycle: Vec<Message>` 参数,返回 `Result<(), AgentError>`);`agent/error.rs` 新增 3 个 Slot 错误变体(`SlotReadonly` / `SlotNotFound` / `SlotAlreadyExists`);`examples/context_slot_demo.rs` 新增分支对话示例(法律咨询入口 → 两个派生方向 → 切换 → 隔离验证 → 删除保护);方案文档 `docs/17-phase10-contextslot.md`(1227 行,含 §5 推荐方案、§6 实施建议、§9 实施计划,经过 4 轮方案/计划/实施审查 + 1 轮非阻塞建议修复);全量 211 → 254(+43 新测试),clippy 0 警告,doc 0 warning11 个离线示例全部 exit 0
- ✅ **Phase 11 测试与检索补强**`src/memory/vector.rs` 新增 `VectorRetriever` traitindex + search 抽象)+ `InMemoryVectorRetriever` 引用实现(HashMap + 全量余弦相似度扫描 + 零依赖 `dot()`),6 个内联测试覆盖 basic/empty/zero-vector/k=0/2 个并发;wiremock Provider roundtrip 测试 12 个(OpenAI 8 + Anthropic 4)覆盖请求体/header/401/429/500/529/流式 usage-only/流式错误/ToolUse/结构化错误体;`MemoryStore` 并发测试 5 个(InMemoryStore 3 + SqliteStore 2)覆盖 100 并发写、5 写+5 读混合 2 秒、15 写者容量淘汰;`openai.rs` `handle_error_response` 修复 429 retry-after 解析(5 行,与 anthropic 对齐);方案文档 `docs/18-phase11-testing-and-retrieval.md`(647 行,含 10 项架构决策 + 2 条实施偏差记录 #6 mid-stream mock 模式 + #7 retry-after 修复);全量 254 → 277+23 新测试),clippy 0 警告,doc 0 warning,并发测试 3 次稳定无 flaky
- ✅ **Phase 13 热身清理 + ContextSlot fork/merge** — 3 个旧 types 文件删除(`request.rs` 187 行 + `response.rs` 177 行 + `old_stream.rs` 45 行),所有 OpenAI wire-format 类型迁入 `provider/openai.rs` 可见性 `pub(crate)`Breaking Change:原 `agcore::llm::types::OpenaiChatRequest/Response/Chunk` 公共 re-export 路径已删除);`ChatResponse` 自 v0.1.0 标记 `#[deprecated]` 后在 Phase 13 整体删除;`ToolChoice``request.rs` 迁入 `tool.rs`(公共 `agcore::llm::types::ToolChoice` 路径不变);`ContextSlot::fork()` 派生独立子 slot`SlotSource::Derived { parent_id, strategy }` 血缘可追溯)+ `ContextSlot::merge(child, MergeStrategy)` 合入父 slot`Append` / `Replace` 两种策略,`#[non_exhaustive]` 预留扩展);`MergeStrategy` 防御性检查(self-merge / 跨 session / Readonly 目标全部阻断);`AgentSession::derive_slot` 重构复用 `fork()` 消除重复;`agent.rs` 追加 `MergeStrategy` re-export9 个 fork/merge 内联测试覆盖 happy path 与 error path`stream.rs` 简化为 module doc + `pub use` 重导出(保持 `use crate::llm::stream::StreamEvent` 路径兼容);方案文档 `docs/19-phase13-cleanup-and-fork-merge.md`640 行);全量 277 → 286+9 新测试),clippy 0 警告,doc 0 warning
- ✅ Provider IR 重构 — 统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen/Ollama 适配
- ✅ LlmCycle 简化 — IR 消息类型切换 + Phase 0 桥接层移除
- ✅ v0.1 Release — 技术债扫清、MockProvider 公开化、8 个离线示例(含 `simple_visit`)、README + 错误消息友好化、CHANGELOG 初始化
- ✅ **v0.2 规划细化完成** — 8 个增量 PhasePhase 5-12),17 个可验证 Step,覆盖 P0-P2 全部 12 项功能 + ContextSlot
- ✅ **v0.3.0 Phase 13 完成** — 技术债清理(3 旧 types 文件 + ChatResponse 删除)+ ContextSlot fork/merge9 新测试),M9 里程碑达成
- ✅ **v0.3.0 Phase 14 完成** — Document 类型(id/content/metadata/mime_type+ `RecursiveCharacterSplitter` 两阶段算法(按 separator 优先级递归分割 + 贪心合并 overlap,全部 `chars_len()` 字符级比较)+ `Embedding` traitasync + `LlmError` 复用)+ `MockEmbedding`sin-hash 零依赖伪随机 + L2 归一化)+ 19 Document 测试 + 6 Embedding 测试(含 1 个 split_multibyte_utf8_boundary CJK 边界测试);`src/document.rs`580 行)+ `src/llm/embedding.rs`183 行)+ `examples/document_demo.rs`74 行);`pub use document::Document` 在 lib.rs 重导出;CJK 分隔符(`。`/``/``)加入 `DEFAULT_SEPARATORS`;方案文档 `docs/20-phase14-document-and-embedding.md`1417 行);全量 286 → 313+27 新测试,0 失败),clippy 0 警告,doc 0 warning,零新外部依赖;M10 里程碑达成
- ✅ **v0.3.0 Phase 15 完成**`VectorStore` trait`add`/`search`/`remove`/`add_one`,返回 `(Document, f32)` 消除调用方 id→Document 维护开销)+ `InMemoryVectorStore``Mutex<HashMap>` + 余弦全量扫描 + 预计算 L2 norm 缓存)+ `PersistentVectorStore`(构造时全量加载,先写持久化后写内存,持久化失败时内存不污染重启自动恢复,`remove` 幽灵数据窗口已知)+ `RagPipeline` 组合器(ingest: split→embed→store.add / retrieve: embed→store.search`splitter: Option<RecursiveCharacterSplitter>` 灵活切换);`src/memory/vector_store.rs`(937 行,19 个内联测试覆盖 14 场景含 2 个性能基准)+ 零新外部依赖(纯 Rust `dot()` 余弦);旧 `VectorRetriever`/`InMemoryVectorRetriever` 标注 `#[deprecated(since = "0.3.0")]` 迁移路径清晰;`search_orthogonal_vectors` 返回 1 条 score≈0(文档已同步修正不过滤低分向量);方案文档 `docs/21-phase15-vector-store-persistence.md`(1570 行,经 3 轮审查 + 文档-代码一致化修复);全量 313 → 335(+22 新测试),clippy 0 警告,doc 0 warningM11 里程碑达成
- ✅ **v0.3.0 Phase 16 完成**`SummaryConfig` 配置结构体(6 个字段:`trigger_token_ratio=0.75` / `max_context_tokens=32_000` / `summary_prompt` / `debounce_turns=3` / `summary_model=None` / `max_tool_result_chars=500`,默认 `None` 沿用主模型避断裂非 OpenAI 用户)+ `AgentBuilder::summary_config(cfg)` 链式方法 + `AgentConfig.summary_config: Option<SummaryConfig>` 字段;`AgentSession` 新增 `last_summary_turn: Option<u32>` 字段(首次不受防抖约束,`should_summarize``Option` 哨兵实现)+ `maybe_summarize(current_turn)` 内联检查点(OnTurnEnd 之后 / `turn_index` 之前,对称 `submit_turn` / `finalize_turn` 两个入口,流式路径 `saturating_sub(1)` 修正)+ 关联函数 `generate_summary`(构造独立 `LlmCycle``submit_messages``vec![Message::user_text(prompt)]``max_tokens=1024`,空消息守卫直接返回空串)+ 公开 API `get_conversation_summary()``src/agent/summary.rs`~240 行,含 8 个 SummaryConfig/`format_messages_as_text` 内联测试——默认值/空输入/系统用户助理/ToolResult(含 `tool_call_id`/工具调用/Unicode 安全截断/整体 30K 截断保留最新;有效字符数截断多字节安全,droptest 验证保留尾部消息)+ `src/agent/session.rs` 注入 10 个摘要集成测试(默认值不触发 / 超阈值触发 / 防抖阻止重复 / SessionMemory 写入 / Full 模式不注入 / 失败不阻断主流程 / 流式路径触发 / 默认配置零影响 / **Focused `summary_override` 写入正向验证** / **空消息不调用 LLM** / **巨型 `max_context_tokens` 永不触发**);`format_messages_as_text` 简洁版消息格式化(`[Tool: name]` + `Tool Result [id]:` + ToolResult 字符级 `chars().take(max_tool_result_chars)` 截断 + 整段 30K 总长度截断从头部保留最新);所有错误静默(失败用 `tracing::error!`,成功用 `tracing::info!(turn, summary_len)`);`MergeStrategy` 注释中过时 "Summarize 指向"与 `context.rs:78` "v0.3 将支持 Hook 驱动" 过时注释在实施时同步移除/更新;方案文档 `docs/22-phase16-summary-auto-generation.md`(471 行),实施后**两轮审查 PASS**:第一轮 PM/SA 审查 11 项问题修复 + 第二轮实施审查 9 项问题修复(🔴 `generate_summary` 空消息 bug + 🟡 W4 流式路径防抖 + 🟡 W2 模型硬编码 + 🟡 W5 Full 模式无谓 save + 🟡 W3 30K 截断 + 🟡 W6 成功无日志 + 🟡 W1/W7 测试补全 + 💭 注释同步);零新外部依赖;全量 335 → **353**(+18 新测试,含二次审查增补 4 个),clippy 0 警告,doc 0 warning`quick_start` 示例正常 exit 0;**M12 里程碑达成** + 第二轮审查门禁 PASS
- ✅ **v0.3.0 Phase 17 完成** — 新建 `src/engine/` 模块(5 文件:`mod.rs`/`error.rs`/`snapshot.rs`/`checkpointer.rs`/`session_manager.rs`),实现 **SessionManager**10 个公开方法:`create`/`create_child`/`get`/`recover`/`replace`/`children`/`parent`/`destroy`/`submit_turn`/`submit_turn_stream`/`finalize_turn_stream`,内部 `RwLock<HashMap>` + `Arc<tokio::sync::Mutex<AgentSession>>` + `Checkpointer` 组合)和 **Checkpointer**5 个公开方法:`checkpoint`/`rollback_load`/`list_checkpoints`/`delete_all`/`latest_snapshot`);`SessionSnapshot` 独立 struct 避开 `Arc<dyn Agent>` 不可序列化,配套 `SessionMemoryEntry` 保留 metadata/created_at`AgentSession` 扩展三段式快照(`to_snapshot` async 读 MemoryStore + `from_snapshot` 纯同步构造 + `restore_memory` &mut self async 写回持久层);`SessionMemory` 新增 `list_entries()``set_with_meta()` 方法(恢复时保留完整 entry 数据);存储 key 风格统一为 `session:{id}:meta` / `ckpt:{id}:{ckpt_id}`(与 `slot_data:` 风格一致);`EngineError` 6 个变体(含 `Memory(#[from] MemoryError)` 透传 + `Agent(#[from] AgentError)`);`CkptMeta``created_at_nanos` 字段确保同秒内精确降序排序;ckpt_id 用纳秒+单调计数器生成(零外部依赖,ponytail);session_id 用纳秒+计数器自动生成(统一策略,UUID v4 备选);自动 checkpoint 失败 `tracing::error!` 不阻断主流程(不提供强持久化保证);流式 checkpoint 仅在 `finalize_turn_stream` 创建(不留半成品污染);孤儿策略:`destroy()` 不递归删除子 session,父被销毁后 `parent()` 返回 `Ok(None)`3 处 derive 改动(`CostTracker` + `ContextSlot` + `MergeStrategy` 加 serde`CostTracker` 额外加 `Clone`);`SessionManager::recover` + `replace` 内部自动 `restore_memory` 写回持久层;零新外部依赖;方案文档 `docs/23-phase17-agent-execution-engine.md`(775 行,经两轮 PM+SA 审查 + 实施后第三轮 PM+SA+Code Reviewer 三方联合审查),实施后**两轮审查门禁 PASS**:第一轮修复 6 🔴 + 第二轮修复 2 🔴(to_snapshot 同步→async + Roadmap 同步)+ 实施后修复 8 个 🟡(restore_memory metadata/created_at 完整恢复 + &mut self 签名 + 死代码清理 + 3 个边界测试 + tracing 补全 + 文档语义统一 + 示例 rollback 一致性 assert);15 个 `SessionManager` 内联测试(CRUD/recover/replace/树形/孤儿/auto_checkpoint on-off+ 6 个 `Checkpointer` 内联测试(roundtrip/不存在的 ckpt/同秒降序/delete_all 幂等/latest/隔离)+ 1 个 `snapshot_deserialize_with_minimal_fields` 序列化兼容测试;全量 353 → **374**+21 新测试),clippy 0 警告,doc 0 warning`engine_demo` 示例端到端演示 create→submit_turn→checkpoint→rollback→replace→destroy 全链路并验证 rollback 一致性;**M13 里程碑达成** + 两轮审查门禁 PASS
- ✅ **v0.3.0 Phase 18 完成** — 新增 `src/engine/switch.rs`222 行)实现 `SessionManager::switch_agent()` 热切换(替换 `Arc<dyn Agent>`slot 历史 / `turn_index` / `session_memory` / `cost_so_far` 全部保留,同步更新 `SessionMeta.agent_name` 到持久层,`created_at` / `parent_id` 保持原始不可变)+ 新增 `src/engine/sub_agent.rs`1071 行)实现 4 个公开方法(`dispatch` / `dispatch_all` / `dispatch_stream` 与前述 `switch_agent` 共 4 个 Phase 18 核心 API+ 3 个公开类型(`DispatchConfig` / `SubTaskResult` / `SubTaskStreamEvent`);`DispatchConfig` 4 字段(`max_concurrency=10` / `inherit_session_memory=true` / `bridge_keys=None` / `shared_namespace=None`+ 三态 `bridge_keys` 语义(`None` = 不继承 / `Some(vec![])` = 全部 / `Some(keys)` = 指定 keys+ 约定式 `shared_namespace` 子↔子共享(`shared:{prefix}:{key}`)不触发自动注入;`dispatch` 流程:`create_child``inherit_session_memory`(快照语义)→ `submit_turn` → 返回 `SubTaskResult``dispatch_all` `tokio::sync::Semaphore` 并发控制 + `Vec<Result<...>>` 部分成功语义按输入顺序 indexed 收集;`dispatch_stream` `unbounded_channel` + spawn task 消息重建 + `finalize_turn` 后台落库(明确不参与 `auto_checkpoint` 防重复);`SubTaskStreamEvent` 事件序列:`ChildCreated``Stream(StreamEvent) × N``Completed(SubTaskResult)``Error { child_id, error }``EngineError` 新增 `DispatchFailed(#[source] String)` 变体 + `CostTracker``From<Usage>` 转换;`save_session_meta` / `load_session_meta``pub(crate)``switch.rs` 调用;4 个端到端示例:`agent_switch_demo`115 行)+ `sub_agent_dispatch_demo`141 行)+ `bridge_keys_demo`197 行)+ `dispatch_stream_demo`121 行)全部 exit 017 个内联测试(4 switch + 5 dispatch + 4 dispatch_all + 4 dispatch_stream);零新外部依赖;方案文档 `docs/24-phase18-agent-switch-and-dispatch.md`700 行);全量 374 → **391**(+17 新测试,0 失败),clippy 0 警告,doc 0 warning**M14 里程碑达成**
- ✅ **v0.3.0 Phase 19 完成** — 知识图谱 + 双通道检索,详见 `docs/25-phase19-knowledge-graph-and-retrieval.md`;全量 391 → **427 passed / 0 failed**(+36 新测试);**M15 里程碑达成**
- ✅ **v0.3.2 Phase 20-27 全部完成** — Cargo features 拆分(16 模块级 + 5 provider + 4 快捷组合),详见 `docs/roadmap-v0.3.2.md`;全量 427 → **427 passed**(不变,门控验证)
- ✅ **Phase 28-30 OpenAI Response API Provider 完成** — 独立 feature `provider-openai-response`,全量约 450 passed
- 📋 **v0.4.0 规划完成** — 5 个增量 PhaseA-E)覆盖多 Agent 编排、HITL + Steering、TokenJuice、自动校正。详见 [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md)
---
## 设计笔记
### Checkpointer 分层存储模型
> 来源:v0.4.0 规划讨论中涉及增量 Checkpoint 的技术推演。当前全量 JSON checkpoint 够用,但为未来优化预留设计方案。
#### 分层叠加模型(OverlayFS 模式)
受容器分层文件系统启发,增量 Checkpoint 可以借鉴 overlayfs 的"底层只读 + 上层可写叠加"设计:
**全量基座(只读)**
```rust
pub struct SnapshotBase {
pub checkpoint_id: String,
pub session_id: String,
pub snapshot: SessionSnapshot, // 完整 JSON 化状态
}
```
**增量层(叠加 diff**
```rust
pub struct SnapshotLayer {
pub base_checkpoint_id: String,
pub applies_to_id: String, // 在哪个 checkpoint 上叠加
pub diff: Vec<DiffOp>, // JSON Patch 操作集合
}
pub enum DiffOp {
MessageAppended { message: Message },
SlotChanged { slot_id: String, diff: serde_json::Value },
TurnIndexIncremented { from: u32, to: u32 },
CostUpdated { diff: CostTracker },
}
```
**重建路径**
```
rollback_load("session_x", 6)
→ 读取 "ckpt:{session_x}:base"(全量)
→ 读取 "ckpt:{session_x}:layer:1" ~ "ckpt:{session_x}:layer:6"
→ 依次应用 layer.1 → layer.2 → ... → layer.6
→ 得到 session_6 的状态
```
**层折叠(类似 docker squash**
```
layer.1 → layer.2 → layer.3 → layer.4 → layer.5
↓ 合并
base.ckpt'(包含 layer.1-3)→ layer.4 → layer.5
```
#### Shadow FS 模型(运行中保护)
与分层模型互补,shadow 模型适用于运行中的 session 保护而非长期存储:
```rust
// submit_turn 在 shadow session 上执行,commit 时才原子切换
let shadow = current_session.fork(); // 复用 ContextSlot::fork
let result = shadow.submit_turn(input).await;
if result.is_ok() {
current_session.commit(shadow); // 原子替换
} else {
drop(shadow); // 丢弃,当前 session 完好无损
}
```
#### 适用场景对比
| 模型 | 适合场景 | 不适合场景 |
|------|---------|-----------|
| **分层叠加(OverlayFS** | Checkpoint 链长期存储、time-travel、多版本回退 | session 较小(< 10KB/轮)时复杂度不值得 |
| **Shadow FSCoW** | 运行中 session 保护、防止 submit_turn 失败污染 | 不能替代 checkpoint 链、不支持多时间点回退 |
**触发条件**:当单 session checkpoint 超过 500KB 且频繁保存导致性能瓶颈时,考虑实现分层模型。
+242
View File
@@ -0,0 +1,242 @@
# AG Core Roadmap — v0.1.0
> 本文件聚焦 **v0.1.0 版本** 的规划与交付(Phase 04c),已于 2026-07-04 完成发布。
> 返回总入口:[`roadmap.md`](./roadmap.md)
## v0.1.0 愿景
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
## v0.1.0 总体范围
**总体规模**5 个主体 PhasePhase 04c+ 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 transportStreamableHttp 预留)
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 0LlmProvider 接口传递 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 0llm::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 4aPhase 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
+378
View File
@@ -0,0 +1,378 @@
# AG Core Roadmap — v0.2.0
> 本文件聚焦 **v0.2.0 版本** 的规划与交付(Phase 5–12)。已打 `v0.2.0-rc.1` 标签。
> 返回总入口:[`roadmap.md`](./roadmap.md)
## v0.2.0 愿景
从"LLM 调用工具箱"升级为"生产可用的 Agent 服务"。解决 Rust Agent 工具箱从"能跑"到"能被人依赖"的鸿沟——持久化、配置层、上下文管理三大块补齐后,开发者可在 30 分钟内写出生产可用的 Agent 服务。
## v0.2.0 总体范围
**总体规模**8 个增量 PhasePhase 512),17 个可验证 Step,约 2000+ 行新增代码,测试 182 → 277+。
---
## v0.2.0 — 生产就绪(Production-Ready Core
**目标**:解决 Rust Agent 工具箱从"能跑"到"能被人依赖"的鸿沟。持久化、配置层、上下文管理三大块补齐后,开发者可在 30 分钟内写出生产可用的 Agent 服务。
**总体规模**8 个增量 PhasePhase 5-12),17 个可验证 Step。
### 功能清单
#### P0 — 必须交付
| # | 功能 | 模块 | 方案要点 |
|---|------|------|---------|
| 1 | SqliteStore | `memory` | `rusqlite` + `bundled` feature`MemoryStore` 的 SQLite 实现,进程重启数据不丢 |
| 2 | ProviderConfig 扩展 + `from_env()` | `llm` | 补全 `timeout_secs` / `max_retries` 字段;`AG_LLM_*` 环境变量辅助函数 |
| 3 | ToolDefinition IR 正式化 | `tools` | 移除 deprecated OpenAI wire 格式,替换为自定义 `ToolDef` 结构体 |
| 4 | API 稳定性管理 | `*` | 公开枚举加 `#[non_exhaustive]`CHANGELOG 记录 Breaking Changes;废弃 API 用 `#[deprecated]` 标记 |
| 5 | Quick Start + 端到端示例 | `examples/` | 30 行 `main.rs` 快速开始;一个"SQLite 持久化 + Provider + 工具调用 + 多轮对话"的可运行示例(`cargo run --example` |
#### P1 — 重要但不阻塞
| # | 功能 | 模块 | 方案要点 |
|---|------|------|---------|
| 6 | Ollama Provider | `llm/provider` | OpenAI Compat,本地 LLM 支持,实现量极小 |
| 7 | VectorRetriever trait | `memory` | 语义检索 trait 抽象(`index` / `search`),不绑定后端实现 |
| 8 | 流式 `submit_turn_stream` | `agent` | `AgentSession` 新增 `submit_turn_stream()`,返回 `Stream<Item = StreamEvent>` |
| 9 | 测试补强 | `*` | wiremock Provider roundtrip 测试;多线程并发写入 MemoryStore 测试 |
#### P2 — 有时间再做
| # | 功能 | 模块 | 备注 |
|---|------|------|------|
| 10 | MCP StreamableHttp | `tools` | 当前仅预留枚举变体 |
| 11 | Gemini Provider | `llm/provider` | 协议差异大,实现成本较高 |
| 12 | 文件系统 MemoryStore 后端 | `memory` | JSON/JSONL 轻量持久化 |
### ContextSlot 上下文管理
**模块归属**`src/llm/context.rs`(与 `compact.rs` 同级)
**核心概念**`ContextSlot` 是一段带策略配置的消息列表,以 `slot_id` 为 namespace 独立持久化到 `MemoryStore`。支持三种模式、三种来源和派生关联(记录 `parent_id`)。
**核心类型**
```rust
pub struct ContextSlot { id, session_id, config, messages, store }
pub struct SlotConfig { mode: SlotMode, source: SlotSource, budget, compact }
pub enum SlotMode {
Full, // 完整对话历史
Focused(FocusedConfig), // 聚焦:保持 LLM 注意力
Readonly, // 只读参考上下文
}
pub struct FocusedConfig { keep_system, recent_turns, inject_summary }
pub enum SlotSource {
New, // 全新空槽,独立持久化
Derived { parent_id, strategy: DeriveStrategy }, // 从父 slot 派生
Static(Vec<Message>), // 预置消息,不持久化
}
pub enum DeriveStrategy { Full, Focused(FocusedConfig) }
pub struct ContextBudget { system, history, tools, tool_results, reserve }
```
**持久化 Key 命名**
- `slot_msg:{session_id}:{slot_id}:{index}` → 消息内容
- `slot_meta:{session_id}:{slot_id}``SlotMeta`(含 `parent_id`
- `slot_rel:{session_id}:{child_id}:parent``"{parent_id}"`
**`AgentSession` 扩展**
- `create_slot(id, config)` — 创建新 slot
- `switch_slot(id)` — 切换当前 slot
- `list_slots()` — 列出所有 slot
- `derive_slot(id, parent_id, strategy)` — 从父 slot 派生
**与 `ConversationMemory` 的关系**:保留不废除。`ConversationMemory` 继续服务传统对话场景。
**v0.2 不做**
- ❌ `slot.fork()` / `merge()` — 分支方法推迟到 v0.3+
- ❌ `inject_summary` 自动生成 — v0.2 仅消费端(从 `SessionMemory` 读取),生成在 v0.3+
- ❌ 血缘关系图遍历 — 只存 `parent_id`,不做查询
**依赖**Phase 0MemoryStore trait)、Phase 3MemoryStore 持久化)
**优先级**P1
---
### v0.2.0 实施计划 — 8 个增量 Phase
> **编号说明**Phase 5-12 接续 v0.1 的 Phase 0-4c,按开发顺序排列。
#### Phase 5: 热身准备(Warmup
**目标**:快速交付三个互不依赖的独立改动,建立交付节奏。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **5.1** ✅ | `ProviderConfig` 扩展:补 `timeout_secs`(def=30) + `max_retries`(def=3);新增 `ProviderConfig::from_env(prefix)` | `llm/provider.rs` + 各 Provider `new()` 构造函数 | `cargo test` + `from_env()` 单元测试 |
| **5.2** ✅ | `OllamaProvider`:基于 `GenericOpenaiProvider` 包装,改 base_url 为 `http://localhost:11434``ProviderType` 新增 `Ollama` | `llm/provider/provider.rs` + `llm/provider/ollama.rs`(新增) | `cargo build` — 纯类型级验证 |
| **5.3** ✅ | 公开枚举 `#[non_exhaustive]` 前置标记:`ProviderType` / `StopReason` / `FinishReason` / `EvictionPolicy` / `SlotMode`(预置) | 各枚举定义处 | 编译通过 + `cargo clippy` 0 警告 |
**实际新增**2026-07-05 commit `98dfe6c`):
- 新增文件 1 个(`llm/provider/ollama.rs`72 行)
- 修改文件 2 个(`llm/provider.rs``from_env` + `Default` + 4 个字段;`memory/store.rs` EvictionPolicy 加 `#[non_exhaustive]`
- `ProviderType::Ollama` 变体 + `FromStr` 解析("ollama" → Ollama
- `OllamaProvider::new(base_url, api_key, model, timeout_secs)` + `with_client()` 构造函数
- `ProviderConfig::from_env(prefix)` 解析 `{prefix}_API_KEY` / `{prefix}_BASE_URL` / `{prefix}_MODEL` 环境变量
- 全量测试 182 → 190+8phase 5 新增 from_env 与 Ollama 相关单测)
- clippy 0 警告
**依赖**:无(三个 Step 互不冲突)
**优先级**P05.1+ P15.2+ P0 前置(5.3
**为何独立成 Phase**:三个改动零文件重叠,可以并行推进。它们是后续所有 Phase 的"门把手"——先做完热身再进入核心工作。
**状态**:✅ Phase 5 全部交付物已完成
---
#### Phase 6: ToolDefinition IR 正式化
**目标**:引入 `ToolDef` 新类型,替换已标记 `#[deprecated]``ToolDefinition``OpenaiToolDefinition` 别名)。
**这是 v0.2 技术风险最高的 Phase**,影响 4 个模块约 8 个文件。通过 5 个 Step 逐文件切割确保每步可编译。
| Step | 内容 | 验证标准 |
|------|------|---------|
| **6.1** ✅ | `types/tool.rs` 新增 `ToolDef` 结构体 + `From<ToolDef> for OpenaiToolDefinition` + 反向 `From` | 单元测试 roundtrip |
| **6.2** ✅ | `types/mod.rs` 切别名 `pub type ToolDefinition = ToolDef``MessageRequest.tools``Vec<ToolDef>` | `cargo build` 编译断点 |
| **6.3** ✅ | `cycle.rs` 4 个方法签名 + `registry.rs` `definitions()` 签名更新 | `cargo build` |
| **6.4** ✅ | Provider 适配层(openai.rs / anthropic.rs / openai_compat.rs):`build_request()` 内做 `ToolDef → wire-format` 转换 | `cargo test` 每个 provider 测试 |
| **6.5** ✅ | 所有测试/示例中 `ToolDefinition``ToolDef` 修复;移除旧 `#[deprecated]` alias | `cargo test --all-targets` 全绿 |
**边界切割技巧**
- Step 6.1 → 6.2 之间是安全 checkpoint:新类型存在但旧代码照常编译
- Provider 层不改序列化逻辑,只加一层 `From` 转换
- 当前代码中 `ToolDefinition` 已是 `#[deprecated(since = "0.1.0")]`,用户已有迁移预期
**依赖**:无(仅与 Phase 5.3 有枚举兼容关系)
**优先级**P0
**实际新增**2026-07-05 commit `4cf5918` / `9da9b83` / `b187519`,详见 `docs/13-phase6-tooldef-ir.md`):
- 修改文件 8 个:`llm/types/tool.rs``llm/types/mod.rs``llm/types/request_v2.rs``llm/cycle.rs``llm/provider/openai.rs``tools/registry.rs``tools/mcp.rs``agent/agent.rs`
- `ToolDef` IRname / description / parameters,无 `strict`)新增于 `types/tool.rs`,配套双向 `From` 转换
- `OpenaiToolDefinition` 降级为 `#[doc(hidden)]`,仅供 OpenAI 适配层内部消费
- `MessageRequest.tools` 切换为 `Vec<ToolDef>`
- `ToolDefinition` 别名最终完全移除(直接使用 `ToolDef`
- 4 处 `#[allow(deprecated)]` 抑制点全部清理(cycle/registry/mcp/agent);残留 `#[allow(deprecated)]` 均与 `ChatResponse` / `with_system_prompt` 等其他弃用项无关
- 新增 roundtrip 测试 `message_request_with_tools_roundtrip`(断言 `strict` 字段不泄漏到序列化输出)
- Anthropic 适配层字段名一致零改动;openai_compat/ollama 委托 `GenericOpenaiProvider` 零改动
- 全量测试 190 → 191+1Phase 6 新增 roundtrip);clippy 0 警告
**状态**:✅ Phase 6 全部交付物已完成
---
#### Phase 7: SqliteStore 持久化
**目标**:实现 `MemoryStore` 的 SQLite 后端,进程重启数据不丢。
**与 Phase 6 无耦合,可重叠开发。**
| Step | 内容 | 文件 | 验证标准 |
|------|------|-----|---------|
| **7.1** ✅ | 新增 `memory/store/sqlite.rs``Mutex<Connection>` + `spawn_blocking`,实现 `save/get/delete/list` + prefix 过滤 | `memory/store/sqlite.rs` + `Cargo.toml`add `rusqlite` | 单元测试 CRUD + prefix 查询 |
| **7.2** ✅ | WAL 模式 + 并发安全 + 集成测试(`tokio::spawn` 10 个并发 task | `sqlite.rs` 扩展 | 并发写入 100 轮无 race |
**设计决策**
- 用 `Mutex<Connection>` 而非连接池(ponytail:一个连接够用就不加 r2d2)
- WAL 模式:`PRAGMA journal_mode=WAL` 解决读写锁
**依赖**`MemoryStore` traitv0.1 Phase 3 已就绪)
**优先级**P0
**实际新增**2026-07-05 commit `13edacd` / `c8a91f6` / `c82af60`,详见 `docs/14-phase7-sqlite-store.md`):
- 方案文档:`docs/14-phase7-sqlite-store.md`526 行,Phase 7 设计推演与权衡记录)
- 结构重组:`src/memory/store.rs` 单体文件 → `src/memory/store/{mod.rs(in_memory.rs, sqlite_store.rs)}` 模块目录;外部导入路径 `crate::memory::store::MemoryStore` 不变
- 新增文件 2 个:`src/memory/store/sqlite_store.rs`545 行 SqliteStore 实现 + 9 个内联测试)、`src/memory/store/in_memory.rs`266 行,结构搬移)
- 核心实现要点:
- `Arc<Mutex<Connection>>` 串行化所有 IO`spawn_blocking` 卸载到阻塞线程池
- WAL 模式 + `synchronous=NORMAL` + `busy_timeout=5s` + `wal_autocheckpoint=1000`
- `PRAGMA user_version` schema 版本管理(`INITIAL_USER_VERSION = 1`
- `created_at` 归一化为 UTC 的 RFC 3339 TEXT,字典序等价时间序
- 错误精细映射:`SqliteFailure` / `InvalidQuery``InvalidInput``FromSqlConversionFailure``Serialization`;其他 → `Storage`
- 9 个内联测试覆盖:CRUD、upsert、prefix / since / offset+limit 过滤、10 写者 × 10 次并发写入、持久化 round-trip(重启连接不丢数据)、`InMemoryStore ↔ SqliteStore` trait-box 互换兼容性
- 依赖:`rusqlite = { version = "0.32", features = ["bundled"] }``time` 增补 `parsing` / `formatting` / `macros` features`dev-dependencies` 新增 `tempfile = "3"`
- 全量测试 191 → 200+9Phase 7 新增 SqliteStore 单测);clippy 0 警告
**状态**:✅ Phase 7 全部交付物已完成
---
#### Phase 8: MVP 集成出口(v0.2.0-rc.1 候选)
**目标**:P0 五项全部交付。开发者 clone 仓库后 10 分钟跑起持久化 Agent。
| Step | 内容 | 验证标准 |
|------|------|---------|
| **8.1** ✅ | API 稳定性扫尾:`#[non_exhaustive]` × 14 公开枚举 + `StepStatus::Completed``MessageResponse` + CHANGELOG v0.2.0-rc.1 + Cargo.toml 0.2.0-rc.1 | `cargo doc --no-deps` 0 warning + 零 deprecated warning |
| **8.2** ✅ | Quick Start 示例(57 行 `main.rs`):MockProvider + EchoTool + submit_turn 真实工具调用 | `cargo run --example quick_start` exit 0 |
| **8.3** ✅ | 端到端示例:SqliteStore + AG_LLM_* from_env 自动检测 + 3 工具 + 3 轮对话 + 持久化跨连接验证 | `cargo run --example end_to_end`Mock fallback,无需 API key|
**Phase 8 全部完成**。**已打 `v0.2.0-rc.1` 标签**。
**实际新增**2026-07-057 commits):
- `feat(core)` —— 14 个公开枚举追加 `#[non_exhaustive]`P0 核心 IR + P0 Error + P1 其他)
- `refactor(agent)` —— `StepStatus::Completed(ChatResponse)``Completed(MessageResponse)` + `task_agent_demo.rs` 清理 3 处废弃类型
- `docs` —— CHANGELOG v0.2.0-rc.1 条目 + Cargo.toml version 0.1.0 → 0.2.0-rc.1 + README 示例列表 7 → 10
- `test(core)` —— 验证 commit 1-3 零回归(test 200 passed + clippy 0 警告 + doc 0 warning
- `feat(examples)` —— `quick_start.rs`60 行)+ `end_to_end.rs`246 行)
- `docs(roadmap)` —— 标记 Phase 8 全部完成 + M4 里程碑 ✅
- `fix(examples)` —— 实施后 PM/SA/Code Reviewer 三方审查发现 6 项问题(🔴 CalcTool 除零 panic + 🟡 drop 注释准确性 + 🟡 EchoTool 错误处理 + 💭 断言一致性 + 💭 工具两端语义统一 + 💭 trailing newline),全部修复
**依赖**Phase 5ProviderConfig from_env+ Phase 6ToolDef+ Phase 7SqliteStore
**优先级**P0
**状态**:✅ Phase 8 全部交付物已完成
---
#### Phase 9: 流式体验增强
**目标**:Agent 会话支持流式输出,开发者看到实时 token。
| Step | 内容 | 文件 | 验证标准 |
|------|------|-----|---------|
| **9.1** ✅ | `AgentSession::submit_turn_stream(user_input) -> impl Stream<Item=StreamEvent>` | `agent/session.rs` | 单元测试验证流事件序列:`TextDelta → ... → MessageComplete` |
**注意**:tool 自动循环时流中插入 `ToolExecutionStarted` 事件,用户端 UI 显示"正在调用工具..."。
**依赖**Phase 6ToolDef+ `LlmProvider.chat_stream`v0.1 已有)
**优先级**P1
**实际新增**2026-07-06 commit `212cfcc`,详见 `docs/16-phase9-streaming-experience.md`):
- 方案文档:`docs/16-phase9-streaming-experience.md`(821 行,含状态机设计推演与边界情况)
- 修改文件 3 个:`src/agent/session.rs`+208,含 `submit_turn_stream` / `finalize_turn`)、`src/llm/cycle.rs`+784,含 `submit_with_tools_stream` / `run_tool_loop` spawn + mpsc 状态机)、`src/llm/types/response_v2.rs`+21,含 `StreamEvent::ToolExecutionStarted`/`Completed` 变体 + `apply_to` 元事件)
- 关键设计:`CycleConfig``Clone` derive 以支持 spawn 跨 task`finalize_turn` 手动同步状态(`submit_turn_stream` 返回流前不落库,避免半成品被 hook 误读)
- 测试:新增 9 个单元测试 + 2 个集成测试(含 `submit_turn_stream_end_to_end` 端到端 mock provider 流消费 + `submit_turn_stream_triggers_turn_hooks` Hook 触发验证),全量 200 → 211(+11,0 失败)
- clippy 0 警告
- 无新增外部依赖
**状态**:✅ Phase 9 全部交付物已完成
---
#### Phase 10: ContextSlot 上下文管理
**目标**:支持多上下文分区管理,Agent 可在不同 slot 之间切换。
| Step | 内容 | 验证标准 |
|------|------|---------|
| **10.1** ✅ | `src/agent/context.rs``ContextSlot` + `SlotConfig` / `SlotMode` / `FocusedConfig` / `SlotSource` / `DeriveStrategy` / `ContextBudget` / `SlotMeta` 核心类型 | `cargo build --all-targets` |
| **10.2** ✅ | ContextSlot 持久化:基于 `MemoryStore` trait(不绑定 SqliteStore)实现 save/load/list/delete + slot 命名空间 key 策略 + `load_messages()` Focused 读时过滤 + `append_messages()` Readonly 阻断 + colon 注入防护 | 单元测试:持久化 roundtrip / session 隔离 / Focused 边界 / delete 保护 / 派生 / load_messages() |
| **10.3** ✅ | `AgentSession` 扩展:`create_slot` / `switch_slot` / `list_slots` / `derive_slot` / `delete_slot` + `new()` 自动创建 `"default"` slot + `submit_turn`/`finalize_turn` 改造为基于当前 slot 的增量追加写回 + 新示例 `context_slot_demo` | 集成测试 + `cargo run --example context_slot_demo` exit 0 |
**如何保证简单场景无感**`AgentSession::new()` 内部检查,自动创建 `"default"` slot → `submit_turn` 默认写到 default slot。
**实际新增**2026-07-07 commit `6359422`,详见 `docs/17-phase10-contextslot.md`):
- 方案文档:`docs/17-phase10-contextslot.md`(1227 行,含 §5 推荐方案、§6 实施建议、§9 实施计划,经过 4 轮方案/计划/实施审查 + 1 轮非阻塞建议修复)
- 新增文件 3 个:`src/agent/context.rs`~430 行 ContextSlot 核心类型 + 持久化方法 + 22 个测试)、`src/agent/context.rs` 中的 `ContextSlot::filter_focused` 静态方法(被 `load_messages``derive_slot` 复用,消除代码重复)、`examples/context_slot_demo.rs`(~160 行分支对话示例:法律咨询 → 派生两个方向 → 切换 → 隔离验证 → 删除保护)
- 修改文件 3 个:`src/agent.rs`+5 行 module 声明 + re-export)、`src/agent/error.rs`+56 行:3 个新变体 `SlotReadonly`/`SlotNotFound`/`SlotAlreadyExists` + 4 个测试)、`src/agent/session.rs`+825/-197 行:slots 字段 + 6 个管理方法 + submit_turn/finalize_turn 改造 + 17 个测试)
- 关键设计:
- **模块归属**`agent/context.rs`(零新依赖方向,遵循 `agent → memory` 已有依赖)
- **持久化**JSON blob 批次存储,每 slot 3-4 条 `MemoryItem``slot_data` / `slot_meta` / `slot_config` / `slot_rel`
- **submit_turn 签名不变**:方案 A(内部 `current_slot_id` 状态),向后兼容
- **Focused 模式读时过滤**`load_messages() -> Vec<Message>`,避免 Rust 借用检查问题
- **增量追加写回**`cycle.messages()[input_len..]` 提取本轮新增消息,确保 Focused 模式数据不丢失
- **delete_slot 双重保护**:禁止删 `"default"` + 至少保留一个 slot
- **colon 注入防护**`assert_no_colon` 在 key 构造时 panic
- **错误传播**`serde_json` / `MemoryStore` 所有错误用 `?` 传播,无静默吞掉
- 验证:211 → 254 测试(+43 新测试),clippy 0 警告,doc 0 warning10 + 1 示例全部 exit 0
- finalize_turn 签名变更(破坏性):新增 `new_messages_from_cycle: Vec<Message>` 参数,返回从 `()` 改为 `Result<(), AgentError>`——影响 Phase 9 的 `submit_turn_stream_triggers_turn_hooks``submit_turn_stream_end_to_end` 2 个测试,已适配
**依赖**Phase 5`#[non_exhaustive]` 预置 SlotMode 等枚举)、Phase 7SqliteStore 推荐持久化后端;`MemoryStore` trait 即可)
**优先级**P1
**状态**:✅ Phase 10 全部交付物已完成
---
#### Phase 11: 测试与检索补强
**目标**:补全测试覆盖 + 语义检索抽象。
| Step | 内容 | 验证标准 |
|------|------|---------|
| **11.1** ✅ | `VectorRetriever` trait`index(id, embeddings)` + `search(query, k)` | 编译 + mock 测试 |
| **11.2** ✅ | wiremock Provider roundtrip 测试:模拟 OpenAI/Anthropic HTTP 端点 | `cargo test` 新增 10+ roundtrip 测试 |
| **11.3** ✅ | 并发测试补强:InMemoryStore + SqliteStore 多线程写入验证 | 跑 100 轮无 race |
**实际新增**2026-07-06 commit `71abe88` / `b4e5c7d`,详见 `docs/18-phase11-testing-and-retrieval.md`):
- 方案文档:`docs/18-phase11-testing-and-retrieval.md`647 行,含 11.1/11.2/11.3 设计 + 10 项架构决策 + 实施后补充 2 条偏差记录 #6 mid-stream mock 模式 + #7 429 retry-after 修复)
- 新增文件 1 个:`src/memory/vector.rs`237 行 — `VectorRetriever` trait + `InMemoryVectorRetriever` 引用实现 + `dot()` 零依赖 + 6 个内联测试)
- 修改文件 5 个:
- `src/memory.rs`+2 行:module 声明 + re-export
- `src/llm/provider/openai.rs`+8 wiremock 测试 + `handle_error_response` 429 retry-after 解析修复 5 行)
- `src/llm/provider/anthropic.rs`+4 wiremock 测试)
- `src/memory/store/in_memory.rs`(+3 并发测试:100 并发写、5 写+5 读混合、15 写者容量淘汰)
- `src/memory/store/sqlite_store.rs`(+2 并发测试:100 并发写、5 写+5 读混合)
- 关键设计:
- **零依赖 dot()**:手写点积/范数,零新增 crate 依赖
- **Wiremock 测试自包含**:每个测试独立 `MockServer::start()`,沿用现有模式
- **429 retry-after 修复**`openai.rs``anthropic.rs` 行为对齐(5 行代码)
- **偏差记录**:方案文档「已否决的方案 #6/#7」记录两处实施偏差,便于后续审计追溯
- 验证:254 → 277 测试(+23 个新测试),clippy 0 警告,doc 0 warning;并发测试连续 3 次运行稳定无 flaky
- **依赖**:无(与方案一致)
- **状态**:✅ Phase 11 全部交付物已完成
---
#### Phase 12: P2 锦上添花(可选)
**目标**:时间允许时按优先级交付。
| 优先级 | 功能 | 实现量估计 | 备注 |
|--------|------|-----------|------|
| **12.1** | 文件系统 MemoryStoreJSON/JSONL | ~80 行 | 最简单,适合练手 |
| **12.2** | MCP StreamableHttp 传输 | ~150 行 | 协议还在演进 |
| **12.3** | Gemini Provider | ~300 行 | 协议差异大,建议推迟到 v0.3 |
**依赖**:无(独立交付)
---
### v0.2.0 Phase 依赖关系图
```mermaid
graph BT
P5["<b>Phase 5: 热身准备</b><br/>ProviderConfig::from_env<br/>Ollama Provider<br/>#[non_exhaustive] 标记"]:::done
P6["<b>Phase 6: ToolDef IR</b><br/>Provider 无关工具定义"]:::done
P7["<b>Phase 7: SqliteStore</b><br/>rusqlite + WAL<br/>9 个内联测试<br/>持久化 round-trip"]:::done
P8["<b>Phase 8: MVP 出口</b><br/>rc.1 标签<br/>14 枚举 #[non_exhaustive]<br/>StepStatus IR 迁移<br/>quick_start + end_to_end"]:::done
P9["<b>Phase 9: 流式体验增强</b><br/>submit_turn_stream<br/>submit_with_tools_stream<br/>9 单元测试 + 2 集成测试"]:::done
P10["<b>Phase 10: ContextSlot</b><br/>ContextSlot 类型<br/>JSON blob 持久化<br/>AgentSession 集成<br/>43 个新测试"]:::done
P11["<b>Phase 11: 测试与检索补强</b><br/>VectorRetriever trait<br/>12 wiremock tests<br/>5 并发测试"]:::done
P12["Phase 12<br/>P2 锦上添花"]:::p2
P8 --> P5
P8 --> P6
P8 --> P7
P9 --> P6
P10 --> P7
P10 --> P8
P11 -.-> P7
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
classDef warmup fill:#e2e8f0,stroke:#94a3b8
classDef core fill:#fbbf24,stroke:#d97706
classDef mvp fill:#4ade80,stroke:#16a34a
classDef p1 fill:#93c5fd,stroke:#2563eb
classDef p2 fill:#c4b5fd,stroke:#7c3aed
```
---
### 关键里程碑
| 里程碑 | Phase 完成条件 | 可验证指标 | 状态 |
|--------|---------------|-----------|------|
| **M1** | Phase 5 | 热身三项完成:`from_env()` 可用 / Ollama 类型存在 / `#[non_exhaustive]` 就位 | ✅ 2026-07-05 |
| **M2** | Phase 6 | `ToolDef` 全量切换,`cargo test --all-targets` 全绿 | ✅ 2026-07-05 |
| **M3** | Phase 7 | SqliteStore CRUD + 并发测试通过,进程重启数据不丢 | ✅ 2026-07-05 |
| **M4** | **Phase 8 (rc.1)** | P0 五项全部交付,`cargo run --example quick_start` 跑通 | ✅ 2026-07-05 |
| **M5** | Phase 9 | `submit_turn_stream` 流式事件序列验证通过 | ✅ 2026-07-06 |
| **M6** | Phase 10 | ContextSlot 创建/切换/派生集成测试通过 | ✅ 2026-07-07 |
| **M7** | Phase 11 | wiremock + 并发测试补强,测试总量 200+ | ✅ 2026-07-06 |
| **M8** | Phase 12(可选) | P2 功能按需交付 | ⏳ |
+367
View File
@@ -0,0 +1,367 @@
# AG Core Roadmap — v0.3.0
> 本文件聚焦 **v0.3.0 版本** 的规划与交付(Phase 1319)。Phase 13-19 全部完成,v0.3.0 交付完毕。
> 返回总入口:[`roadmap.md`](./roadmap.md)
## v0.3.0 愿景
从"LLM 调用工具箱"升级为"能构建多 Agent 协作、RAG、长记忆 Agent 产品的基础系统"。补齐 LangChain 7 大组件中缺失的 Document 和 VectorStore 能力,落地笔记设计中的 ContextSlot fork/merge、摘要自动生成、知识图谱,建立 engine 引擎层(会话树 + time-travel Checkpointer + SubAgent Dispatch + Agent Switch),为即将开发的多 Agent 产品提供完整基础。
## v0.3.0 总体范围
**总体规模**7 个增量 PhasePhase 1319),总新增代码约 2600 行,测试从 277 → 427。7 个 Phase 全部完成(M9-M15 已达成),v0.3.0 交付完毕。
---
## v0.3.0 — 多 Agent 基础系统(Multi-Agent Foundation
**目标**:从"LLM 调用工具箱"升级为"能构建多 Agent 协作、RAG、长记忆 Agent 产品的基础系统"。补齐 LangChain 7 大组件中缺失的 Document 和 VectorStore 能力,落地笔记设计中的 ContextSlot fork/merge、摘要自动生成、知识图谱,建立 engine 引擎层(会话树 + time-travel Checkpointer + SubAgent Dispatch + Agent Switch),为即将开发的多 Agent 产品提供完整基础。
**总体规模**7 个增量 PhasePhase 13-19),总新增代码约 2600 行,测试从 277 → 427。
### 功能清单
#### P0 — 必须交付
| # | 功能 | 模块 | 方案要点 |
|---|------|------|---------|
| 1 | 技术债清理(旧 types 文件) | `llm/types` | `request.rs` / `response.rs` / `old_stream.rs` 三个 Phase 0 旧文件删除;内部类型移入 `provider/openai.rs` |
| 2 | ContextSlot fork/merge | `agent/context` | `fork(child_id, strategy)` 别名 + `merge(child, MergeStrategy)` 三种策略(Append/Replace/Summarize |
| 3 | Document 系统 | `document/`(新模块) | `Document` 核心类型 + `RecursiveCharacterSplitter`(递归字符分割,支持 chunk_size/chunk_overlap/separators |
| 4 | Embedding 抽象 | `llm/embedding` | `Embedding` trait`embed` / `dim`+ `MockEmbedding` 测试实现 |
| 5 | 向量存储持久化 | `vector/`(新模块) | `VectorStore` trait + `InMemoryVectorStore`(读写)+ `PersistentVectorStore`SqliteStore 后端)+ `RagPipeline` 组合器 |
| 6 | 摘要自动生成 | `agent` / `llm/hooks` | `SummaryConfig` 配置 + `OnTurnEnd` Hook 自动检测 token 水位 → 调 LLM 生成摘要 → `SessionMemory::set("conversation_summary", ...)` |
| 7 | SessionManager + 会话树 | `engine/`(新模块) | Session 工厂(`create`/`create_child`+ 按 ID 恢复(`get`+ 子树管理(`children`/`parent`/`destroy_subtree`+ 元数据持久化(MemoryStore |
| 8 | Time-travel Checkpointer | `engine/checkpointer` | `checkpoint(session)` 全量序列化 + `rollback(session_id, ckpt_id)` 回滚 + `fork(session_id, ckpt_id, new_id)` 分支 + `list_checkpoints` |
| 9 | Agent Switch | `engine/switch` | 热切换 `session.agent`(替换 `Arc<dyn Agent>`),slot 历史 / turn_index / session_memory 全保留 |
| 10 | SubAgent Dispatch | `engine/sub_agent` | `dispatch(parent, sub_agent, task, config)` 单任务 + `dispatch_all(parent, tasks, config)` 并行派发(Semaphore 并发控制)+ 子 SessionMemory 继承 + `SubTaskResult` 结构化回传 |
| 11 | 知识图谱 | `memory/graph` | `KnowledgeGraph` trait`add_entity` / `add_relation` / `get_related` / `find_by_keywords`+ `InMemoryGraph` 实现 + `tag_index` 标签管理 |
| 12 | 双通道检索 | `memory/retriever` | `MemoryRetriever` 扩展为双通道(`KnowledgeStore` + `KnowledgeGraph`+ `RetrievalStrategy::Hybrid` |
### 实施计划 — 7 个增量 Phase
> **编号说明**Phase 13-19 接续 v0.2 的 Phase 5-12,按开发顺序排列。
#### Phase 13: 热身清理 + ContextSlot fork/merge
**目标**:清除 Phase 0 遗留的旧 types 文件,交付超低价功能建立节奏。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **13.1** | `OpenaiChatRequest` 移入 `provider/openai.rs``types/request.rs` 删除 | `llm/types/request.rs` + `llm/provider/openai.rs` | `cargo build --all-targets` |
| **13.2** | `OpenaiChatResponse/Chunk` 移入 `provider/openai.rs``types/response.rs` 删除 | `llm/types/response.rs` + `llm/provider/openai.rs` | `cargo build --all-targets` |
| **13.3** | `old_stream.rs` 删除 + `types/mod.rs``ChatResponse` 删除 | `llm/types/old_stream.rs` + `llm/types/mod.rs` | `cargo build` + 确认 3 个旧文件不存在 |
| **13.4** | `ToolChoice``request.rs` 搬到 `tool.rs` | `llm/types/tool.rs` + `llm/types/request_v2.rs` | `cargo test --all-targets` 全绿 |
| **13.5** | `ContextSlot::fork(child_id, strategy)` 别名 + `merge(child, MergeStrategy)` | `agent/context.rs` | 单元测试:fork → 子 slot 消息 = 父 slot 副本;merge(Append) → 消息按序追加 |
**依赖**:无
**优先级**P0
**预估规模**:约 200 行
**状态**:✅ Phase 13 全部交付物已完成(2026-07-08)
---
#### Phase 14: Document 系统 + Embedding 抽象
**目标**:补齐 LangChain 7 大组件中最明显的缺口——Document 类型和分割器。不搞 Loader 框架,用户用 `fs::read_to_string` 自行加载。
**交付物**
1. `src/document.rs` 新模块(`Document` 类型 + `RecursiveCharacterSplitter`
2. `src/llm/embedding.rs``Embedding` trait + `MockEmbedding`
**设计要点**
- `Document`id / content / metadataHashMap<String, String>/ mime_type
- `RecursiveCharacterSplitter`chunk_size(默认 1000/ chunk_overlap(默认 200/ separators`["\n\n", "\n", "。", "", "", ".", " ", ""]`,含 CJK 标点)
- 两阶段算法:按 separator 优先级递归分割(Phase 1+ 贪心合并 + overlap 滑动窗口(Phase 2
- 所有长度比较以 Unicode 字符数为单位(`chars_len()`),非字节数
- `Embedding` trait`async fn embed(&self, input: &[String]) -> Result<Vec<Vec<f32>>, LlmError>` + `fn dim()`
- 复用 `LlmError` 而非新错误类型
- `MockEmbedding`:sin-hash 零依赖伪随机向量 + L2 归一化
- 不引入 `DocumentLoader` trait(应用层职责)
**实际新增**2026-07-09 commit `d4c4d8f`,详见 `docs/20-phase14-document-and-embedding.md`):
- 新增文件 3 个:
- `src/document.rs`580 行)— `Document` 类型(4 字段 + `new`/`from_raw` 构造器,2 个 `new` 接受 `impl Into<String>` + `RecursiveCharacterSplitter`(两阶段算法:按 separator 优先级递归分割 + 贪心合并 overlap,所有长度比较 `chars_len()` 字符级,overlap 提取 `chars().rev().take().rev()` 字符级安全)+ 19 个内联测试
- `src/llm/embedding.rs`183 行)— `Embedding` traitasync + `LlmError`+ `MockEmbedding`(sin-hash:字节和+长度做种子,`f32::sin(seed + i) * 10000`,L2 归一化到单位长度,零向量防除零)+ 6 个内联测试
- `examples/document_demo.rs`(74 行)— 端到端演示 Document → RecursiveCharacterSplitter → MockEmbedding → InMemoryVectorRetriever → search
- 修改文件 2 个:
- `src/lib.rs`+3 行:`pub mod document` + `pub use document::Document` + 空行)
- `src/llm.rs`+1 行:`pub mod embedding`
- 关键设计:
- **早返回守卫**`split_text``chars_len(text) <= self.chunk_size` 时直接返回 `[text]`,避免短文本在 Phase 2 `join("")` 中丢失 separator 边界
- **`Document::new` 使用 `impl Into<String>`**:接受 `&str``String`,比规范示例的 `String` 更灵活
- **`new()` panic + `try_new()` Result 双路径**:与 Rust 库惯例一致
- **CJK 分隔符扩展**`DEFAULT_SEPARATORS` 包含 `"。"`/`""`/`""`,避免中文文本跳过句子级退化为空格分割
- **chunk_size = 0 校验**:构造器拒绝零值,避免字符级兜底死循环
- **tracing 埋点**`split()` 入口 `tracing::debug!` + 每文档/每 chunk `tracing::trace!`
- **debug_assert 溢出保护**:单文档 chunk 数 < 10000 时 `debug_assert!`
- **Metadata 键覆盖文档化**`HashMap::insert()` 静默覆盖 source_id/chunk_index/chunk_count 在 `split()` doc comment 注明
- 测试:19 个 Document 测试(含 1 个 split_multibyte_utf8_boundary CJK 边界测试)+ 6 个 Embedding 测试,全量 286 → 313(+27 新测试,但部分测试覆盖范围重叠计算约 25 个净增)
- 方案文档:`docs/20-phase14-document-and-embedding.md`(1417 行,含背景/调研/方案对比/实施计划(详细版)/3 轮审查修复记录),经过 3 轮 PM/SA 审查 + 1 轮实施后修复
- clippy 0 警告,doc 0 warning
- 无新增外部依赖(`Cargo.toml` 未修改)
**实施后调整**
- 实施发现方案算法中 Phase 1 累加器设计与测试期望冲突("para1\n\npara2" 在 chunk_size=100 时 1 chunk 更合理),简化为"按 separator 切分 + Phase 2 合并"两阶段分工
- 二次审查发现 `split_text` 缺少早返回守卫 + `current_sep_count` 虚增计数,全部已修复
**依赖**:无(纯数据结构 + 零新 crate 依赖)
**优先级**P0
**预估规模**:约 350 行
**状态**:✅ Phase 14 全部交付物已完成(2026-07-09)
---
#### Phase 15: 向量存储持久化(SqliteStore 后端)
**目标**:实现 VectorStore 持久化,让语义检索支持进程重启后数据恢复。
**设计决策**:不用 pgvector。基于已有 SqliteStore`rusqlite`)做持久化包装——运行时全量加载到 InMemory 索引做余弦搜索,写时同步到 SqliteStore。
**交付物**
1. 新增 `src/memory/vector_store.rs`937 行)—— `VectorStore` trait + `InMemoryVectorStore` + `PersistentVectorStore` + `RagPipeline`
2. `VectorStore` trait`add(&[Document], &[Vec<f32>])` 批量 / `search(query, k)` 返回 `(Document, f32)` / `remove(ids)` 幂等
3. `PersistentVectorStore`:构造时从 `MemoryStore` 全量加载已有索引;`add` 先写持久化后写内存(持久化失败时内存不污染,重启自动恢复);`search` 纯内存余弦搜索(快照 clone + 锁外计算)
4. `RagPipeline`:组合器封装 `split → embed → store.add`ingest)和 `embed → store.search`retrieve)两条管线
5. 存储格式:`vec:{namespace}:{doc_id}` → JSON `{doc_id, content, metadata, embedding, created_at}`,通过 `MemoryStore` 通用接口读写
6. `src/memory/vector.rs``VectorRetriever` trait + `InMemoryVectorRetriever` 标注 `#[deprecated(since = "0.3.0")]`,迁移路径指向 `VectorStore` / `InMemoryVectorStore`
**实际新增**2026-07-09 commit `32d886f`):
- 新增文件 1 个:`src/memory/vector_store.rs`937 行,含 19 个内联测试)
- 修改文件 3 个:`src/memory/vector.rs`+4 行 deprecated 标注);`src/memory.rs`+pub mod vector_store + 4 个 pub use re-export);`examples/document_demo.rs`(迁移到 RagPipeline ingest+retrieve
- 零新外部依赖(`Cargo.toml` 未修改)
- 全量测试 313 → 335+22Phase 15 新增 19 测试 + 部分重叠计数 22 净增);clippy 0 警告,doc 0 warning
- 设计文档:`docs/21-phase15-vector-store-persistence.md`(1570 行,经 3 轮审查 + 文档-代码不一致修复:`search_orthogonal_vectors` 返回 1 条 score≈0 而非空列表)
**依赖**Phase 14Document 类型 + Embedding trait
**优先级**P0
**预估规模**:约 400 行(实际约 937 行纯实现 + 测试)
**状态**:✅ Phase 15 全部交付物已完成
---
#### Phase 16: 摘要自动生成
**目标**:闭环长对话能力。v0.2 的 `inject_summary` 消费端(`FocusedConfig.summary_override`)已就绪,缺的是生产端。
**交付物**
1. `SummaryConfig` 结构体:`trigger_token_ratio`(默认 0.75 / `max_context_tokens`(默认 32_000/ `summary_prompt`(默认中文 `DEFAULT_SUMMARY_PROMPT``{messages}` / `debounce_turns`(默认 3 / `summary_model`(默认 `None` 沿用主模型) / `max_tool_result_chars`(默认 500
2. 在 `submit_turn` / `finalize_turn` 中 OnTurnEnd 之后插入**内联检查点**(非 Hook 扩展):`should_summarize`(水位 + 防抖,首次不受防抖约束)→ `generate_summary` 关联函数(新 `LlmCycle` + `submit_messages` 无工具调用)→ 更新 `FocusedConfig.summary_override` + `slot.save()` 持久化 + `SessionMemory::set("conversation_summary", summary)` 全局快照
3. `AgentBuilder` 扩展:`.summary_config(cfg)` 方法(不覆盖整个 `AgentConfig`
4. 公开 API`get_conversation_summary() -> Result<Option<String>, AgentError>`
5. `format_messages_as_text()` 简洁版消息格式化(System/User/Assistant + `[Tool: name]` + `Tool Result [id]:` 截断到 `max_tool_result_chars` 字符)
**设计决策**:内联于 `submit_turn` 流程而非 Hook 扩展(因为 HookContext 无法携带 `&mut self` 引用更新 slot config,且流式路径的 `finalize_turn``cycle` 已销毁)。`Option<SummaryConfig>` 的 opt-in 机制已足够提供可插拔性,不改变 Hook 系统签名。流式路径中 `submit_turn_stream` 已将 `turn_index` 提前 ++1,检查点使用 `saturating_sub(1)` 修正。
**依赖**:无(`submit_turn` 流程 + `CostTracker` + `SessionMemory` + `LlmProvider` 均已就绪)
**优先级**P0
**预估规模**:约 220 行(实际约 250 行,含 11 个内联测试)
**方案文档**`docs/22-phase16-summary-auto-generation.md`471 行,经 PM/SA 审查 11 项修复 + 实施后第二轮审查 9 项修复全部完成)
**状态**:✅ Phase 16 全部交付物已完成(含实施后 PM/SA/Code Reviewer 第二轮审查 PASS
**实施后审查修复记录**(共 9 项):
- 🔴 B1`generate_summary` 调用 `submit_messages(Vec::new(), vec![])` 发送空消息列表 → 移除 `with_messages()`,直接 `submit_messages(vec![Message::user_text(prompt)], vec![])`
- 🟡 W4`should_summarize` 使用 `self.turn_index` 而非 `current_turn` 参数 → 改签名接收 `current_turn`,流式路径防抖准确
- 🟡 W2`summary_model` 硬编码 `unwrap_or("gpt-4o")` → 改为条件赋值,`None` 时沿用 `CycleConfig::default()`
- 🟡 W5Full 模式 `slot.save()` 无谓调用 → 移入 `SlotMode::Focused` 分支内
- 🟡 W3`format_messages_as_text` 缺 30K 整体截断 → 新增 `MAX_TOTAL_CHARS=30_000` + `truncate_total_chars`,优先保留最新
- 🟡 W6:摘要成功无日志 → 添加 `tracing::info!(turn, summary_len, "摘要自动生成成功")`
- 🟡 W1/W7:缺 3 个测试 → 新增 `format_total_charset_truncation_keeps_recent` / `summary_written_to_focused_slot_config` / `summary_skipped_for_empty_messages` / `summary_not_generated_if_max_context_unreachable`
- 💭 `context.rs:78` 过时注释("v0.3 将支持 Hook 驱动")→ 更新为"v0.3 Phase 16 起 AgentBuilder 内联检查点自动生成摘要"
**第二轮审查门禁**:PASS(0 🔴 阻塞)。`cargo test --all-targets` **353 passed / 0 failed**clippy 0 警告,doc 0 warning。
---
#### Phase 17: Agent 执行引擎(会话树 + Time-travel Checkpointer
**目标**:建立 `engine/` 模块。解决 v0.2 中"session 在变量里、无法通过 ID 恢复、不支持父子关系"的空白。
**方案文档**`docs/23-phase17-agent-execution-engine.md`
**交付物**
1. `src/engine/` 新模块(`session_manager.rs` + `checkpointer.rs` + `snapshot.rs` + `error.rs`
2. `SessionManager`
- `create(agent, bundle) -> Result<String, EngineError>` — 创建根 sessionUUID v4 自动生成 ID
- `create_child(parent_id, agent) -> Result<String, EngineError>` — 创建子 session(继承父 `RuntimeBundle``Arc::clone` 共享引用)
- `get(session_id) -> Result<Arc<Mutex<AgentSession>>, EngineError>` — 按 ID 查找(仅查内存,不自动从存储恢复)
- `recover(session_id, agent, bundle) -> Result<Arc<Mutex<AgentSession>>, EngineError>` — 从存储恢复 session
- `replace(session_id, session) -> Result<(), EngineError>` — 替换已有 session 实例(用于 rollback 后切换)
- `children(parent_id)` / `parent(child_id)` — 树形查询
- `destroy(id)` — 生命周期管理(允许孤儿 session 存在,不递归删除子 session)
3. `Checkpointer`
- `checkpoint(session)` — 每个 `submit_turn` 末尾自动保存全量状态快照
- `rollback_load(session_id, ckpt_id) -> SessionSnapshot` — 读取 checkpoint JSON 为 snapshot(不重建 AgentSession
- `list_checkpoints(session_id)` — 列出 checkpoint 列表
- `delete_all(session_id)` — 清理某 session 所有 checkpoint
- `fork()` 推迟(底层可拆解为 `rollback` + `create_child`,作为高层 API 等价于约 30 行组合代码,已具备原始能力)
4. `SessionSnapshot` 独立 struct(位于 `engine/snapshot.rs`)—— 避开 `Arc<dyn Agent>` 不可序列化的限制,通过 `to_snapshot()` / `from_snapshot()` 双向转换实现 AgentSession 快照持久化
- `to_snapshot()`async,从 `SessionMemory` 读取完整数据)+ `from_snapshot()`(纯同步构造)+ `restore_memory()`async 写回持久层)
5. `EngineError` 枚举(含 `MemoryError` 透传变体 与项目既有 `AgentError` 风格一致)
**Checkpoint 存储格式**`ckpt:{session_id}:{ckpt_id}``SessionSnapshot` JSON(全量 session 状态,含所有 slot 消息列表)。Ponytail:全量 JSON 够用,等遇到存储效率问题时再改增量模式。
**会话树持久化**`session:{session_id}:meta``SessionMeta` JSON`{agent_name, parent_id, created_at, turn_count}`
**依赖**Phase 10ContextSlot 持久化 — 消息由 slot 自己管,Checkpointer 管执行状态)
**优先级**P0
**预估规模**:约 700 行(5 新增文件 + 5 修改文件)
**状态**:✅ Phase 17 全部交付物已完成
---
**实际新增**2026-07-153 commits + 实施审查修复一轮):
- **新增 5 文件(`src/engine/`**`mod.rs`19 行)+ `error.rs`43 行)+ `snapshot.rs`35 行)+ `checkpointer.rs`373 行)+ `session_manager.rs`910 行含测试)
- **修改 5 文件**
- `src/llm/types/usage.rs``CostTracker``Clone, Serialize, Deserialize`3 行)
- `src/agent/context.rs``ContextSlot` + `MergeStrategy``Serialize, Deserialize`4 行)
- `src/agent/session_memory.rs` — 新增 `list_entries()` + `set_with_meta()` 方法
- `src/agent/session.rs` — 新增 `to_snapshot()` (async) / `from_snapshot()` (sync) / `restore_memory()` (&mut self, async) / `has_pending_memory_restore()` + 公开 `bundle()` accessor
- `src/lib.rs``pub mod engine`
- **新增 1 示例**`examples/engine_demo.rs`~210 行,端到端演示 create → submit_turn → checkpoint → list → rollback_load → from_snapshot → restore_memory → replace → destroy 全链路,含 rollback 一致性 assert
- **依赖**:零新外部依赖(ponytail:ckpt_id 用纳秒+计数器生成,session_id 同理)
- **测试**353 → **374**+21 引擎内联测试:Checkpointer 6 个 + SessionManager 15 个)
- **质量基线**`cargo test --all-targets` 374 passed / 0 failed`cargo clippy --all-targets -- -D warnings` 0 警告;`cargo doc --no-deps` 0 warning`cargo run --example engine_demo` exit 0
- **关键设计决策落地**
- `SessionSnapshot` 独立 struct(避开 `Arc<dyn Agent>` 不可序列化)
- `to_snapshot` async + `from_snapshot` 纯同步 + `restore_memory` async 三段式分离
- `session_memory_data` 改用 `HashMap<String, SessionMemoryEntry>`(保留 metadata/created_at
- `EngineError::Memory(#[from] MemoryError)` 透传变体
- `EngineManager` 锁契约:所有写操作先 HashMap 再 I/O(或反之,destroy 反向)
- 自动 checkpoint 失败 `tracing::error!` 不阻断主流程(不提供强持久化保证)
- ckpt_id 时间戳+纳秒+计数器无外部依赖(`created_at_nanos` 字段确保同秒内精确排序)
- 孤儿策略:`destroy()` 不递归删除子 session;父被销毁后 `parent()` 返回 `Ok(None)`
- **实施审查通过**:经过 PM + SA + Code Reviewer 三方联合审查 → 1 轮修复 → 全部 🟡 警告关闭
- **M13 里程碑达成** — Phase 17 rc.1 标签可打(v0.3.0 第二个 Phase
---
#### Phase 18: Agent Switch + SubAgent Dispatch + Agent 间交互
**目标**:在 SessionManager 基础上,提供 Agent 角色热切换和子代理调度能力。
**交付物**
1. `engine/switch.rs``switch_agent(session_id, new_agent)`:替换 `Arc<dyn Agent>`slot 历史 / turn_index / session_memory 全保留
2. `engine/sub_agent.rs` — SubAgent Dispatch 核心:
- `DispatchConfig``max_concurrency`(默认 10/ `inherit_session_memory`(默认 true/ `bridge_keys`
- `dispatch(parent_id, sub_agent, task, config) -> SubTaskResult`:创建子 session → 继承父 SessionMemory → `submit_turn` → 返回结构化结果
- `dispatch_stream(parent_id, sub_agent, task, config) -> SubTaskStream`:流式版
- `dispatch_all(parent_id, tasks, config) -> Vec<SubTaskResult>`:并行派发,`tokio::sync::Semaphore` 控制并发数
3. `SubTaskResult``child_id` / `response` / `usage` / `summary` + `child_memory(sm)` 读取子 SessionMemory
**Agent 间交互三层级**
- 父→子:继承 SessionMemory 快照 + `bridge_keys` 指定 key 强制注入 system prompt
- 子→父:`SubTaskResult` 结构化回传 + `SessionMemory["result_summary"]` 结论摘要
- 子↔子(间接):通过公共 `MemoryStore` namespace`shared:{parent_session_id}`)共享数据
**依赖**Phase 17SessionManager + 会话树)
**优先级**P0
**预估规模**:约 500 行
**实际新增**2026-07-15 commit `46de111`,详见 `docs/24-phase18-agent-switch-and-dispatch.md`):
- 方案文档:`docs/24-phase18-agent-switch-and-dispatch.md`700 行,含 Agent Switch 与 SubAgent Dispatch 的设计推演)
- 新增文件 2 个:
- `src/engine/switch.rs`222 行)— `SessionManager::switch_agent()` 热切换:替换 `Arc<dyn Agent>`slot 历史 / turn_index / session_memory / cost_so_far 全部保留,同步更新 `SessionMeta.agent_name` 到持久层
- `src/engine/sub_agent.rs`1071 行)— SubAgent 调度完整实现:4 个公开方法 + 3 个公开类型
- 修改文件 4 个:
- `src/engine/mod.rs`+5 行:`pub mod switch; pub mod sub_agent;` + `pub use sub_agent::{DispatchConfig, SubTaskResult, SubTaskStreamEvent};`
- `src/engine/error.rs`+1 变体:`DispatchFailed(#[source] String)`
- `src/engine/session_manager.rs`+2 处可见性:`save_session_meta` / `load_session_meta``pub(crate)``switch.rs` 使用)
- `src/llm/types/usage.rs`+`From<Usage>` 实现供 `SubTaskResult.usage` 字段构造)
- 新增 4 个示例:
- `examples/agent_switch_demo.rs`(115 行)— Agent 热切换演示
- `examples/sub_agent_dispatch_demo.rs`141 行)— dispatch / dispatch_all 并行派发演示
- `examples/bridge_keys_demo.rs`197 行)— bridge_keys 过滤的 SessionMemory 继承演示
- `examples/dispatch_stream_demo.rs`121 行)— dispatch_stream 流式派发演示
- 关键设计:
- **`switch_agent` 锁契约**:先 `get` session → 锁 `Mutex` 替换 agent 并读取 turn_index → 释放 Mutex → 读/写 `SessionMeta`(无锁 IO),最大限度减少锁竞争
- **`SessionMeta` 保留原则**:切换 `agent_name` 字段,但 `created_at` / `parent_id` 保留原始(血缘不可变)
- **`switch_agent` 不自动 checkpoint**:与 `auto_checkpoint` 语义一致(仅 `submit_turn` / `finalize_turn` 触发),避免每次角色切换产生冗余 checkpoint
- **`inherit_session_memory` 快照语义**:捕获调用时刻的父 session_memory 快照,子 session 写回后即使父被并发写入也不传播(防止非确定性结果)
- **`bridge_keys` 三态语义**`None` = 不继承任何(安全默认)/ `Some(vec![])` = 继承全部 / `Some(keys)` = 仅继承指定 key
- **`shared_namespace` 约定式共享**:纯约定字段,不触发自动注入逻辑,子 agent 显式 `session.set_session_data("shared:{prefix}:{key}", value)` 写入
- **`dispatch_all` 部分成功语义**`Vec<Result<SubTaskResult, EngineError>>` 按输入顺序 indexed 收集,task panic 通过 `DispatchFailed` 哨兵占位(不破坏顺序一致性)
- **`dispatch_stream` 后台 finalize**spawn task 内部调 `finalize_turn()` 落库,明确不参与 `auto_checkpoint`(避免与流式 checkpoint 重复)
- **`SubTaskStreamEvent` 事件序列**`ChildCreated { child_id }``Stream(StreamEvent) × N``Completed(SubTaskResult)``Error { child_id, error }`
- **`SUBTASK_NAMESPACE` 防误注入**:子 session 注入到 SessionManager 时使用 `subtask:` prefix 避免与 SessionMeta 的 `session:{id}:meta` 冲突
- 测试:+17 内联测试(4 switch + 5 dispatch + 4 dispatch_all + 4 dispatch_stream),全量 374 → **391 passed / 0 failed**+170 失败)
- 质量基线:`cargo test --all-targets` 391 passed / 0 failed`cargo clippy --all-targets -- -D warnings` 0 警告;`cargo doc --no-deps` 0 warning4 个示例全部 exit 0
- 零新外部依赖(ponytail:与 Phase 17 一致)
**状态**:✅ Phase 18 全部交付物已完成
---
#### Phase 19: 知识图谱 + 双通道检索
**目标**:落地 `docs/note-knowledge-graph-design.md` 中记录的知识图谱设计,提供实体-关系图检索能力。扩展 `MemoryRetriever` 为双通道。
**交付物**
1. `src/memory/graph.rs`(新文件):
- `GraphEntity` / `GraphRelation` / `ScoredEntity` 核心类型
- `RelationDirection` 枚举(Outgoing / Incoming / Both
- `KnowledgeGraph` trait`add_entity` / `get_entity` / `remove_entity` / `add_relation` / `remove_relation` / `get_related` / `find_by_keywords` / `find_tags` / `set_entity_tags`
- `InMemoryGraph` 实现:`HashMap<String, GraphEntity>` + `Vec<GraphRelation>` + BFS 图遍历
- `TagConstraints``max_tags_per_entity` 默认 8
2. `src/memory/retriever.rs` 扩展:
- `MemoryRetriever` 增加 `knowledge_graph` 可选字段
- `RetrievalStrategy` 枚举:`Hybrid`(默认)/ `KnowledgeOnly` / `GraphOnly`
**与 Document 系统的关系**:知识图谱提供实体级检索("这个实体和什么相关"),VectorStore 提供语义相似度检索("哪些文档最相似"),两者互补。
**依赖**MemoryStore 持久化(v0.1 Phase 3
**优先级**P0
**预估规模**:约 400 行(实际约 720 行核心 + 200 行测试)
**方案文档**`docs/25-phase19-knowledge-graph-and-retrieval.md`(652 行,经 PM/SA 双轮审查 PASS
**状态**:✅ Phase 19 全部交付物已完成(2026-07-17)
**实际新增**2026-07-17):
- 新增文件 2 个:
- `src/memory/graph.rs`~580 行)- `GraphEntity`id/name/entity_type/description/tags/properties+ `GraphRelation`(无 id 字段,`composite_key()` 派生)+ `RelationDirection``#[derive(Default)]` + `#[default]` Outgoing+ `ScoredEntity`(含 path 路径)+ `TagConstraints`max_tags_per_entity 默认 8+ `KnowledgeGraph` trait10 个 async 方法)+ `InMemoryGraph``Mutex<GraphInner>` 单一锁结构,避免嵌套锁死锁)+ BFS 图遍历(visited 防环 + 权重乘积衰减 + 多路径先到先得 + depth=0 返回空)+ 标签管理(tag_index 反向索引)+ 23 个内联测试
- `examples/knowledge_graph_demo.rs`(~140 行)- 端到端演示:构建图谱 -> BFS 遍历 -> 标签管理 -> Hybrid/GraphOnly 双通道检索
- 修改文件 3 个:
- `src/memory/retriever.rs` - `RetrievalStrategy` 枚举(Hybrid 默认 / KnowledgeOnly / GraphOnly+ `RetrievalItem` enum(统一列表,`score()` 方法)+ `RetrievalResult` 新增 `strategy` 字段(反映实际执行策略)+ `MemoryRetriever` 双通道(`with_knowledge_graph` / `with_strategy` 链式构造)+ `search_knowledge_store` / `search_graph` 私有方法 + `tokio::join!` 并行 + 旧 `ScoredItem` 标注 `#[deprecated]` + `RetrieverConfig` 新增 `graph_depth`(默认 2+ 13 个内联测试
- `src/memory.rs` - `pub mod graph` + 重导出 7 个图类型 + 更新 retriever 重导出
- `examples/knowledge_search_demo.rs` - 适配新 API`RetrievalItem` enum match + `RetrieverConfig.graph_depth`
- 关键设计:
- **`Mutex<GraphInner>` 单一锁结构** - 避免 `set_entity_tags` 嵌套锁死锁风险(审查修复)
- **`RetrievalResult.strategy` 反映实际执行策略** - graph 未注入时退化为 `KnowledgeOnly`(审查修复)
- **`GraphRelation` 无 id 字段** + `composite_key()` 派生方法
- **BFS**`visited` 防环 + 权重乘积衰减 + 多路径先到先得 + `depth=0` 返回空
- **零新外部依赖**ponytail 风格)
- 测试:391 -> **427 passed / 0 failed**+36 新测试:23 graph + 13 retriever
- 质量基线:`cargo test --all-targets` 427 passed / 0 failed`cargo clippy --all-targets -- -D warnings` 0 警告;`cargo doc --no-deps` 0 warning`cargo run --example knowledge_graph_demo` exit 0
---
### v0.3.0 Phase 依赖关系图
```mermaid
graph BT
P13["<b>Phase 13: 热身清理</b><br/>旧 types 文件删除<br/>ContextSlot fork/merge"]:::done
P14["<b>Phase 14: Document + Embedding</b><br/>Document 类型<br/>RecursiveCharacterSplitter<br/>Embedding trait"]:::done
P15["<b>Phase 15: 向量存储持久化</b><br/>VectorStore trait<br/>PersistentVectorStore<br/>RagPipeline<br/>19 新测试"]:::done
P16["<b>Phase 16: 摘要自动生成</b><br/>SummaryConfig<br/>内联检查点<br/>首次防抖跳过<br/>18 新测试"]:::done
P17["<b>Phase 17: 执行引擎</b><br/>SessionManager<br/>会话树<br/>Time-travel Checkpointer<br/>21 新测试"]:::done
P18["<b>Phase 18: 切换与调度</b><br/>Agent Switch<br/>SubAgent Dispatch<br/>dispatch_all 并发控制<br/>17 新测试"]:::done
P19["<b>Phase 19: 知识图谱</b><br/>KnowledgeGraph trait<br/>InMemoryGraph<br/>双通道检索"]:::done
P15 --> P14
P18 --> P17
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
classDef pending fill:#fbbf24,stroke:#d97706,color:#1a1a1a
```
### 关键里程碑
| 里程碑 | Phase 完成条件 | 可验证指标 | 状态 |
|--------|---------------|-----------|------|
| **M9** | Phase 13 | 旧 types 文件删除、`cargo test --all-targets` 全绿、`fork`/`merge` 测试通过 | ✅ 2026-07-08 |
| **M10** | Phase 14 | `Document` + `RecursiveCharacterSplitter` 分割结果验证、`MockEmbedding` 测试通过 | ✅ 2026-07-09 |
| **M11** | Phase 15 | `PersistentVectorStore` 持久化 roundtrip、`RagPipeline::ingest → retrieve` 端到端验证 | ✅ 2026-07-09 |
| **M12** | Phase 16 | 多轮对话后摘要自动写入 SessionMemory、派生 slot 时摘要正确注入 + 第二轮实施审查 PASS | ✅ 2026-07-10 |
| **M13** | **Phase 17 (rc.1)** | `SessionManager` 创建/recover/replace/子树/销毁集成测试通过、`Checkpointer` checkpoint/rollback/list_checkpoints 验证(`fork` 推迟,按需时引入)| ✅ 2026-07-15 |
| **M14** | Phase 18 | `switch_agent` 热切换验证(slot / turn_index / session_memory 保留)、`dispatch` / `dispatch_all` 并行派发 + Semaphore 顺序、`dispatch_stream` 流事件序列验证 | ✅ 2026-07-15 |
| **M15** | Phase 19 | `KnowledgeGraph` 实体-关系 CRUD + `get_related` BFS 验证、双通道检索 Hybrid 策略验证 | ✅ 2026-07-17 |
+476
View File
@@ -0,0 +1,476 @@
# AG Core Roadmap — v0.3.2
**状态**:✅ Phase 20-27 全部交付(v0.3.2 交付完毕)
> 本文件聚焦 **v0.3.2 版本** 的规划与交付(Phase 20–27)。
> 返回总入口:[`roadmap.md`](./roadmap.md)
> **进度更新(2026-07-19**v0.3.2 全部交付。Step 1 完成 Phase 20-25Cargo features 定义 + 依赖 optional 化 + 全模块 cfg 门控);Step 3 完成 Phase 26-27CI 测试矩阵固化 + LlmProvider trait 归属修正 + examples required-features + 文档更新)。验证矩阵 6 种组合全部通过(427/416/363/369/401/407 passed),clippy 0 警告,18 个 example 单独编译通过。
>
> **Step 3 实施中的关键调整**(超出原方案的发现):
> - `LlmProvider` trait + `ProviderCapabilities` + `ProviderFeatures``provider.rs` 移至新建的 `provider_trait.rs`,归属 `#[cfg(feature = "llm")]`(ADR-1,纯 Mock 场景不再需要 provider feature
> - `llm` feature 补充 imply `futures-util`(修复 `cycle.rs` 隐式依赖)
> - `bundle()` 方法加 `#[cfg(feature = "engine")]` 门控修复 dead_code 警告
> - `prompt_composer` / `custom_tool` 的 required-features 需额外 `llm``response_v2.rs` 依赖 `LlmError`,预存耦合)
> - cargo fmt 全量格式化(修复预存格式问题,CI format job 可通过)
>
> 实施方案见 [`docs/26-step1-phase20-cargo-features-implementation.md`](./26-step1-phase20-cargo-features-implementation.md) 和 [`docs/27-step3-phase26-ci-verification.md`](./27-step3-phase26-ci-verification.md)。
## v0.3.2 愿景
通过 Cargo features 拆分,让下游按需选择模块,跳过不需要的编译单元和重型依赖。
## v0.3.2 总体范围
**版本等级**patchv0.3.2),`default = ["full"]` 保持向后兼容,非破坏性变更。
**改造基线**v0.3.0 已交付 23,718 行 Rust 代码,66 个源文件。当前所有依赖全量编译——引用 agcore 就意味着拉入 rusqlite bundled、reqwest、tokio full 等全部重型依赖。
**改造目标**16 个 features10 模块级 + 5 provider + 1 工具)+ 4 个快捷组合。下游可只选 `chat` 组合跳过 SQLite 和 MCP 的编译,或只选 `document` 实现纯文档分割零外部依赖。
**工作性质**:纯 cfg 门控 + Cargo.toml 配置变更,不新增功能代码。
**总体规模**8 个增量 PhasePhase 2027),预计新增/修改约 330 行配置与条件编译代码。
---
## 功能清单
### 模块级 features10 个)
| Feature | 覆盖内容 | imply | 外部依赖成本 |
|---------|---------|-------|-------------|
| `document` | Document + RecursiveCharacterSplitter | — | 无 |
| `llm-types` | Message, ToolDef, Usage, ToolChoice 等 IR 类型 | — | 无(只 serde + thiserror |
| `prompt` | PromptTemplate + PromptComposer | `llm-types` | 无 |
| `llm` | Provider trait + LlmCycle + hooks + compact + embedding + mock | `llm-types` | tokio, async-stream, futures-core, futures-util, tokio-stream |
| `tools` | BaseTool + ToolRegistry | `llm-types` | futures, tokio-util, tokio |
| `tools-mcp` | McpClientStdio/StreamableHttp | `tools` | reqwest |
| `memory` | MemoryStore(InMemory) + Conversation + VectorStore(InMemory) + KnowledgeGraph + Retriever | `document` + `llm` | tokio, time(继承 llm 的依赖) |
| `memory-sqlite` | SqliteStore | `memory` | rusqlite (bundled), time |
| `agent` | Agent + Builder + Session + ContextSlot + Summary | `llm` + `tools` + `memory` | 继承下层 |
| `engine` | SessionManager + Checkpointer + SubAgent + Switch | `agent` | 继承下层 |
### Provider features5 个,各自独立)
| Feature | imply | 外部依赖 |
|---------|-------|---------|
| `provider-openai` | `llm` | reqwest + bytes + futures-util |
| `provider-anthropic` | `llm` | reqwest + bytes + futures-util |
| `provider-deepseek` | `llm` | reqwest |
| `provider-qwen` | `llm` | reqwest |
| `provider-ollama` | `llm` | reqwest |
### 工具 features1 个)
| Feature | 控制 | 依赖 |
|---------|------|------|
| `tracing-init` | `init_tracing()` 函数 | tracing-subscriber |
### 快捷组合(4 个)
| 组合 | 定义 | 场景 |
|------|------|------|
| `full`default | 全部 16 个 feature | 全栈(兼容 v0.3 |
| `light` | llm + provider-openai + tools + tools-mcp + memory + agent + engine + prompt + document | 生产常用 |
| `chat` | agent + provider-openai | 纯对话(context+session+轻量记忆,跳过 SQLiteMCP 按需加 `tools-mcp` |
| `multi` | engine + provider-openai | 多 Agent 复合(chat + subagent + switch + checkpointerMCP 按需加 `tools-mcp` |
---
## 实施计划 — 8 个增量 Phase
> **编号说明**Phase 20-27 接续 v0.3.0 的 Phase 13-19。
### 实施节奏:4 个 Step
将 8 个 Phase 合并为 4 个实施步骤,平衡变更风险与执行效率。
| Step | Phase | 内容 | 验证方式 | 预估行数 |
|------|-------|------|---------|---------|
| **Step 1** ✅ | Phase 20-25 | Cargo.toml features 定义 + 依赖 optional 化 + 全模块 cfg 门控(合并实施) | 14 条编译验证全通过 + `cargo test -F full` 427 passed | ~100 |
| **Step 2** | (已合并至 Step 1 | — | — | — |
| **Step 3** ✅ | Phase 26 | 测试矩阵验证 + 修复 cfg 遗漏 + LlmProvider trait 归属修正 + examples required-features | 6 种组合全部测试通过 + clippy 0 警告 + 18 个 example 单独编译通过 | ~80 |
| **Step 4** ✅ | Phase 27 | README + 示例标注 + 总入口同步 | review 通过 | ~100 |
**Step 1 单独成步**Cargo.toml 是基础设施变更,编译通过后打 checkpoint,后续都是纯源文件变更。
**Step 2 合并 Phase 2125**:全是 `#[cfg(feature = "...")]` 公式化插门控,按依赖顺序(底层模块 → LLM/Provider → Tools/MCP → Memory → Agent/Engine)实施,每插一个 feature 门控就验证。按子模块分批 commit 控制粒度。
---
### Phase 20: Cargo.toml 基础设施改造
**目标**:定义完整的 [features] 表,重型依赖改为 optional,建立 imply 链。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **20.1** | 定义 16 个 features + 4 个快捷组合,`default = ["full"]` | `Cargo.toml` | `cargo build --features "full"` 编译通过,行为与原版一致 |
| **20.2** | tokio / reqwest / rusqlite / tracing-subscriber 改为 optional | `Cargo.toml` | `cargo build --no-default-features` 成功(空 crate |
| **20.3** | tokio-stream / futures / futures-util / futures-core / bytes / async-stream / tokio-util / time 改为 optional | `Cargo.toml` | `cargo build --features "full"` 全量依赖正确拉取 |
| **20.4** | tokio features 拆细:从 `["full"]` 改为 `["rt", "sync", "time", "macros", "process", "io-util"]`,仅保留实际使用的子模块 | `Cargo.toml` | `cargo build --features "llm,provider-openai"` 不拉入 tokio net/http 等无关子模块 |
| **20.5** | feature imply 链配置:`prompt → llm-types``llm → llm-types``tools → llm-types``memory → document``agent → llm + tools + memory`(不含 tools-mcp),`engine → agent` | `Cargo.toml` | `cargo build --features "agent,provider-openai"` transitive 依赖自动拉取 |
**依赖**:无(Cargo.toml 独立改造)
**优先级**P0
**预估规模**:约 40 行
**状态**:✅ 已交付(2026-07-19)— features 定义 + 依赖 optional 化 + tokio features 拆细(含 `rt-multi-thread` 修正)
---
### Phase 21: 底层模块 cfg 门控注入
**目标**:为 llm-types、document、prompt 三个零/低外部依赖模块添加条件编译门控。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **21.1** | `src/lib.rs` 中所有 `pub mod` 声明加 `#[cfg(feature = "...")]` | `src/lib.rs` | `cargo build --no-default-features` 无模块引入 |
| **21.2** | llm-types 模块条件编译 + 公共类型条件导出 | `src/llm/types/` | `cargo build --no-default-features --features "llm-types"` 编译通过 |
| **21.3** | document 模块条件编译 + `pub use Document` 条件导出 | `src/document.rs` | `cargo build --no-default-features --features "document"` 编译通过 |
| **21.4** | prompt 模块条件编译 | `src/prompt.rs` | `cargo build --no-default-features --features "prompt"` 编译通过 |
**依赖**Phase 20(需 feature 定义就绪)
**优先级**P0
**预估规模**:约 30 行
**状态**:✅ 已交付(2026-07-19Step 1 合并)— `src/lib.rs` 全部 `pub mod` + `pub use Document` 门控完成
---
### Phase 22: LLM + Provider 门控注入
**目标**llm 模块整体门控 + 5 个 Provider 独立条件编译 + cycle.rs 中 ToolRegistry 引用的 `#[cfg]` 隔离。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **22.1** | llm 模块 cfg + embedding 子模块条件导出 + MockProvider 条件编译 | `src/llm.rs` | `cargo build --no-default-features --features "llm"` 编译通过 |
| **22.2** | `create_provider()` + `build_client_*` 条件编译,按 feature 分别暴露 | `src/llm/provider.rs` | 各 provider feature 单独启用 |
| **22.3** | OpenAI provider `#[cfg(feature = "provider-openai")]` | `src/llm/provider/openai.rs` | `--features "llm,provider-openai"` 编译通过;不含时不编译 |
| **22.4** | Anthropic provider 条件编译 | `src/llm/provider/anthropic.rs` | `--features "llm,provider-anthropic"` 编译通过 |
| **22.5** | DeepSeek + Qwen 共享 `openai_compat.rs``any(feature = "provider-deepseek", feature = "provider-qwen")` 条件 | `src/llm/provider/openai_compat.rs` | 各自单独编译通过 |
| **22.6** | Ollama provider 条件编译 | `src/llm/provider/ollama.rs` | `--features "llm,provider-ollama"` 编译通过 |
| **22.7** | `cycle.rs` 中 ToolRegistry 引用 + `submit_with_tools` 系列方法 `#[cfg(feature = "tools")]` | `src/llm/cycle.rs` | `--features "llm,provider-openai"` 不含 tools 编译通过 |
**依赖**Phase 20 + Phase 21
**优先级**P0
**预估规模**:约 80 行(中复杂度,cycle.rs 门控需精确隔离)
**状态**:✅ 已交付(2026-07-19Step 1 合并)— `src/llm.rs` 子模块按 llm-types/llm/provider 三类门控;`cycle.rs``ToolRegistry` import + `submit_with_tools` / `submit_with_tools_stream` / `run_tool_loop``#[cfg(feature = "tools")]`Phase 22.7 提前)
---
### Phase 23: Tools + MCP 门控注入
**目标**tools 模块整体门控 + mcp 子模块条件编译。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **23.1** | tools 模块 cfg + pub use 条件导出 | `src/tools.rs` | `--features "tools"` 编译通过;不含时不编译 |
| **23.2** | `mcp.rs` 整个文件 `#[cfg(feature = "tools-mcp")]` | `src/tools/mcp.rs` | `--features "tools"` 不含 mcp 时编译通过;加 `tools-mcp` 时引入 |
| **23.3** | ToolRegistry 中 McpClient 引用的条件导出 | `src/tools/registry.rs` | `--features "tools"` 不含 mcp 编译通过 |
**依赖**Phase 20 + Phase 21
**优先级**P0
**预估规模**:约 20 行
**状态**:✅ 已交付(2026-07-19Step 1 合并)— `src/tools.rs``pub mod mcp` + `pub use mcp::*``#[cfg(feature = "tools-mcp")]`
---
### Phase 24: Memory 门控注入
**目标**memory 模块门控 + vector_store 中 Embedding 引用隔离 + SqliteStore 可选化。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **24.1** | memory 模块 cfg + pub use 条件导出 | `src/memory.rs` | `--features "memory"` imply document 编译通过 |
| **24.2** | vector_store 中 Embedding trait 引用 `#[cfg(feature = "llm")]` | `src/memory/vector_store.rs` | `--features "memory"` 不含 `llm` 编译通过 |
| **24.3** | `sqlite_store.rs` 整个文件 `#[cfg(feature = "memory-sqlite")]` | `src/memory/store/sqlite_store.rs` | `--features "memory"` 不含 sqlite 编译通过 |
| **24.4** | `memory.rs``pub use SqliteStore` 条件导出 | `src/memory.rs` | `--features "memory-sqlite"` 正确导出 SqliteStore |
**依赖**Phase 20 + Phase 21
**优先级**P0
**预估规模**:约 30 行
**状态**:✅ 已交付(2026-07-19Step 1 合并)— Phase 24.3`sqlite_store` 模块门控)+ Phase 24.4`pub use SqliteStore` 门控)已完成;Phase 24.1memory 模块 pub use)由 `src/lib.rs``#[cfg(feature = "memory")]` 覆盖;Phase 24.2vector_store 中 Embedding 引用隔离)经 Phase 26 验证无需补充——`memory` feature imply `llm``Embedding` trait 在 `memory` 启用时一定可用
---
### Phase 25: Agent + Engine 门控注入
**目标**agent 和 engine 两个高层模块的条件编译门控。注意 agent 不再 imply tools-mcp——MCP 作为可选工具层由用户显式启用。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **25.1** | agent 模块 cfg + pub use 条件导出 | `src/agent.rs` | `--features "agent,provider-openai"` 编译通过 |
| **25.2** | engine 模块 cfg + 子模块条件导出(switch / sub_agent / checkpointer | `src/engine/` | `--features "engine,provider-openai"` 编译通过 |
| **25.3** | `lib.rs` 中 agent / engine 模块声明 cfg + 条件重导出 | `src/lib.rs` | 验证 `engine` imply `agent` 链正确,transitive 依赖完整 |
**依赖**Phase 20-24(全链路依赖就绪后操作)
**优先级**P0
**预估规模**:约 20 行
**状态**:✅ 已交付(2026-07-19Step 1 合并)— Phase 25.3`src/lib.rs` 中 agent/engine 模块声明 cfg)已完成;Phase 25.1/25.2agent/engine 内部子模块条件导出)由 `src/lib.rs` 顶层门控覆盖;`src/agent/session.rs` 中 engine 相关 import + `to_snapshot` / `from_snapshot` / `restore_memory` / `has_pending_memory_restore` + `pending_memory_restore` 字段加 `#[cfg(feature = "engine")]`Phase 26 验证 `bundle()` 方法加 `#[cfg(feature = "engine")]` 门控修复 dead_code 警告
---
### Phase 26: 快捷组合验证 + 测试矩阵
**目标**:验证 4 个快捷组合 + clippy 完整性检查。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **26.1** | `default = ["full"]` 回归验证 | CI | `cargo test --features "full"` 全绿(427 passed |
| **26.2** | light 组合编译 + 单元测试 | CI | `cargo test --no-default-features --features "light"` 通过 |
| **26.3** | chat 组合(无 MCP)编译 + 单元测试 | CI | `cargo test --no-default-features --features "chat,provider-openai"` 通过 |
| **26.4** | chat + MCP 组合编译 + 单元测试 | CI | `cargo test --no-default-features --features "chat,provider-openai,tools-mcp"` 通过 |
| **26.5** | multi 组合(无 MCP)编译 + 单元测试 | CI | `cargo test --no-default-features --features "multi,provider-openai"` 通过 |
| **26.6** | multi + MCP 组合编译 + 单元测试 | CI | `cargo test --no-default-features --features "multi,provider-openai,tools-mcp"` 通过 |
| **26.7** | clippy `--all-features` 无警告 | CI | `cargo clippy --all-features -- -D warnings` 0 警告 |
| **26.8** | 修复各组合编译中发现的 cfg 遗漏 | 全量 | 7 种组合全部编译 + 测试通过 |
**依赖**Phase 20-25(所有门控就绪)
**优先级**P0
**预估规模**:约 10 行(CI 配置)
**状态**:✅ 已交付(2026-07-19)— 6 种 feature 组合测试矩阵 + clippy + format + examples 验证 job 全部通过;`RUSTFLAGS=-D warnings` 强制零警告;LlmProvider trait 归属修正 + bundle() 门控 + llm feature 补充 imply futures-util
---
### Phase 27: 文档更新 + 示例标注 + README feature 表
**目标**:让下游使用者能快速理解 feature 体系并选择合适组合。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **27.1** | README.md 添加 feature 表格 + `Cargo.toml` 使用示例 + 各组合推荐场景 | `README.md` | review 通过 |
| **27.2** | 各示例文件顶部添加所需的 feature 组合标注注释 | `examples/*.rs` | review 通过 |
| **27.3** | 更新 `docs/roadmap.md` 总入口添加 v0.3.2 链接和简要状态 | `docs/roadmap.md` | review 通过 |
**依赖**Phase 20-26
**优先级**P0
**预估规模**:约 100 行
**状态**:✅ 已交付(2026-07-19)— README 添加 feature 表格 + 快捷组合 + 模块级 features 清单 + 升级指南(LlmProvider 路径迁移);18 个 example 顶部添加 Required features 注释;roadmap 总入口同步
---
## Feature 依赖关系图
```mermaid
graph TD
subgraph "快捷组合"
FULL["full (default)"]
LIGHT["light"]
CHAT["chat"]
MULTI["multi"]
end
subgraph "模块级"
ENGINE["engine"]
AGENT["agent"]
LLM["llm"]
TOOLS["tools"]
TOOLS_MCP["tools-mcp"]
MEMORY["memory"]
MEMORY_SQLITE["memory-sqlite"]
PROMPT["prompt"]
LLM_TYPES["llm-types"]
DOCUMENT["document"]
end
subgraph "Provider"
P_OPENAI["provider-openai"]
P_ANTHROPIC["provider-anthropic"]
P_DEEPSEEK["provider-deepseek"]
P_QWEN["provider-qwen"]
P_OLLAMA["provider-ollama"]
end
FULL --> LIGHT & CHAT & MULTI
ENGINE --> AGENT
AGENT --> LLM & TOOLS & MEMORY
CHAT -.-> TOOLS_MCP
MULTI -.-> TOOLS_MCP
MEMORY_SQLITE --> MEMORY
TOOLS_MCP --> TOOLS
MEMORY --> DOCUMENT
LLM --> LLM_TYPES
TOOLS --> LLM_TYPES
PROMPT --> LLM_TYPES
P_OPENAI --> LLM
P_ANTHROPIC --> LLM
P_DEEPSEEK --> LLM
P_QWEN --> LLM
P_OLLAMA --> LLM
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
classDef pending fill:#fbbf24,stroke:#d97706,color:#1a1a1a
classDef provider fill:#93c5fd,stroke:#2563eb,color:#1a1a1a
class P_OPENAI,P_ANTHROPIC,P_DEEPSEEK,P_QWEN,P_OLLAMA provider
class FULL,ENGINE,AGENT,LLM,TOOLS,TOOLS_MCP,MEMORY,MEMORY_SQLITE,PROMPT,LLM_TYPES,DOCUMENT,LIGHT,CHAT,MULTI done
```
## 关键里程碑
| 里程碑 | Phase 完成条件 | 可验证指标 | 状态 |
|--------|---------------|-----------|------|
| **M16** | Phase 20 | `cargo build --no-default-features` 成功;`cargo build --features "full"` 与原行为一致 | ✅ 2026-07-19 |
| **M17** | Phase 21 | 三种零依赖模块各自独立编译通过 | ✅ 2026-07-19Step 1 合并) |
| **M18** | Phase 22 | 5 个 provider 各自单独编译;cycle.rs 无 tools 时编译通过 | ✅ 2026-07-19Step 1 合并) |
| **M19** | Phase 23 | tools 不含 mcp 编译通过;加 tools-mcp 引入 McpClient | ✅ 2026-07-19Step 1 合并) |
| **M20** | Phase 24 | memory imply document+llm 编译通过;不含 sqlite 编译通过;加 memory-sqlite 引入 SqliteStore | ✅ 2026-07-19Step 1 合并) |
| **M21** | Phase 25 | agent + engine 全链路门控编译通过 | ✅ 2026-07-19Step 1 合并) |
| **M22** | Phase 26 | 7 种 CI 组合全部编译 + 测试通过;clippy --all-features 0 警告 | ✅ 2026-07-19 |
| **M23** | Phase 27 | 文档 review 通过 | ✅ 2026-07-19 |
## Cargo.toml [features] 草案
```toml
[dependencies]
# 轻量核心依赖(始终编译)
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
async-trait = "0.1"
tracing = "0.1"
# 按 feature 可选的重依赖
tokio = { version = "1", features = ["rt", "sync", "time", "macros", "process", "io-util"], optional = true }
reqwest = { version = "0.12", features = ["json", "stream"], optional = true }
rusqlite = { version = "0.32", features = ["bundled"], optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
tokio-stream = { version = "0.1", optional = true }
futures = { version = "0.3", optional = true }
futures-util = { version = "0.3", optional = true }
futures-core = { version = "0.3", optional = true }
bytes = { version = "1", optional = true }
async-stream = { version = "0.3", optional = true }
tokio-util = { version = "0.7", features = ["rt"], optional = true }
time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"], optional = true }
```
```toml
[features]
default = ["full"]
# === 模块级 features ===
document = []
llm-types = []
prompt = ["llm-types"]
llm = ["llm-types", "tokio", "async-stream", "futures-core", "futures-util", "tokio-stream"]
tools = ["llm-types", "futures", "tokio-util", "tokio"]
tools-mcp = ["tools", "reqwest"]
memory = ["document", "llm", "tokio", "time"]
memory-sqlite = ["memory", "rusqlite", "time"]
agent = ["llm", "tools", "memory", "futures-util"]
engine = ["agent"]
# === Provider features ===
provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
provider-deepseek = ["llm", "reqwest"]
provider-qwen = ["llm", "reqwest"]
provider-ollama = ["llm", "reqwest"]
# === 工具 features ===
tracing-init = ["tracing-subscriber"]
# === 快捷组合 ===
full = [
"document", "llm-types", "prompt", "llm",
"tools", "tools-mcp",
"memory", "memory-sqlite",
"agent", "engine",
"provider-openai", "provider-anthropic", "provider-deepseek",
"provider-qwen", "provider-ollama",
"tracing-init",
]
light = [
"llm", "provider-openai", "tools", "tools-mcp",
"memory", "agent", "engine",
"prompt", "document",
]
chat = ["agent", "provider-openai"]
multi = ["engine", "provider-openai"]
```
### 依赖 optional 化对照
| 依赖 | 启用者 | 当前声明 |
|------|--------|---------|
| `tokio`features = `rt, rt-multi-thread, sync, time, macros, process, io-util` | llm, tools, memory | `optional = true` |
| `reqwest`features = ["json", "stream"] | provider-*, tools-mcp | `optional = true` |
| `rusqlite`features = ["bundled"] | memory-sqlite | `optional = true` |
| `tracing-subscriber`features = ["env-filter"] | tracing-init | `optional = true` |
| `tokio-stream` | llm | `optional = true` |
| `futures` | tools | `optional = true` |
| `futures-util` | llm, provider-*, agent | `optional = true` |
| `futures-core` | llm | `optional = true` |
| `bytes` | provider-openai, provider-anthropic | `optional = true` |
| `async-stream` | llm | `optional = true` |
| `tokio-util`features = ["rt"] | tools | `optional = true` |
| `time`features = ["serde","parsing","formatting","macros"] | memory, memory-sqlite | `optional = true` |
**始终编译**(轻量依赖,不参与 feature 门控):`serde``serde_json``thiserror``async-trait``tracing`
### CI 测试矩阵(已实施)
```yaml
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
env:
RUSTFLAGS: "-D warnings"
jobs:
test-matrix:
strategy:
fail-fast: false
matrix:
features:
- "full"
- "light"
- "chat,provider-openai"
- "chat,provider-openai,tools-mcp"
- "multi,provider-openai"
- "multi,provider-openai,tools-mcp"
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo test --no-default-features --features "${{ matrix.features }}" --lib
clippy:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo clippy --all-features --lib -- -D warnings
format:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
- run: cargo fmt --check
examples:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo test --features "full"
```
**关键设计决策**
- 矩阵使用 `--lib` 避免 examples 编译干扰模块测试验证
- `RUSTFLAGS=-D warnings` 强制零警告
- format job 使用 stable toolchain`cargo fmt --check` 无需 nightly
- 独立 `examples` job 验证所有 example 在完整 features 下编译
- 每个 job 设置 `timeout-minutes` 兜底
---
返回总入口:[`roadmap.md`](./roadmap.md)
+233
View File
@@ -0,0 +1,233 @@
# AG Core Roadmap — v0.4.0
> 本文件聚焦 **v0.4.0 版本** 的规划。Phase A-E 计划中,覆盖多 Agent 编排、Human-in-the-loop 与 Steering、语义压缩、自动校正。
> 返回总入口:[`roadmap.md`](./roadmap.md)
## v0.4.0 愿景
从 v0.3 的"多 Agent 基础系统"升级为"多 Agent 多职责编排系统"。补齐高层编排抽象(Swarm/Supervisor/Subgraph)、生产级人工干预能力(HITL + Steering)、工具与消息的语义压缩(TokenJuice),以及自动质量校正(Reflection)。为即将开发的多 Agent 协作产品提供完整的编排、干预与质量保证层。
## v0.4.0 总体范围
**总体规模**5 个增量 PhasePhase A-E),总新增代码约 1,950 行,零强制新外部依赖,零破坏性变更。
### 架构决策
**路线选择**:采用轻量编排模式(路线 A),不引入通用有向图引擎。通过 `Swarm::star()` / `Swarm::sequential()` / `Swarm::hierarchical()` 等具名模式提供编排能力,底层复用现有 `dispatch` / `create_child` / `SessionManager` 基础设施。预留路线 B(StateGraph 抽象)作为未来版本的升级路径。
**模块位置**
- 编排逻辑 → `src/engine/supervisor.rs`(新增)
- 内建工具 → `src/tools/builtin.rs`(新增)
- TokenJuice 压缩 → `src/llm/compress.rs`(新增)
- Steering 机制 → `src/engine/steer.rs`(新增,或并入 supervisor.rs
### 功能清单
#### P0 — 必须交付
| # | 功能 | 模块 | 方案要点 |
|---|------|------|---------|
| 1 | Swarm 编排(Star/Sequential/Hierarchical + Subgraph | `engine/supervisor` | `Swarm::star().supervisor(A).worker(B)` 声明式 API`Swarm::sequential().link(A).link(B)` 串联;`Swarm::hierarchical().supervisor(root).group("sub", ...)` 层次嵌套 |
| 2 | 结果聚合 | `engine/supervisor` | `aggregation_prompt` 模板将子 Agent 结果合并到 Supervisor 上下文;`DispatchConfig` 扩展 `result_key` 字段 |
| 3 | Human-in-the-loop 审批 | `engine/steer` | `interrupt()` 暂停执行 + `Command(resume=bool)` 恢复;`HookEvent::OnInterrupt` 新变体 |
| 4 | 用户 Steering(运行中校正) | `engine/steer` | `Command(resume=Correction{...})` 结构化校正;Steer 消息在工具批处理边界注入 |
| 5 | TokenJuice 语义压缩 | `llm/compress` | `Compressor` trait 统一抽象;覆盖工具结果、对话历史、跨 Agent 消息三层;LLM 摘要压缩 + 确定性兜底 |
#### P1 — 推荐交付
| # | 功能 | 模块 | 方案要点 |
|---|------|------|---------|
| 6 | 自动校正 / Reflection | `engine/reflect` | Evaluator-Optimizer 循环;Producer-Critic 角色分离;上限 2-3 轮迭代 |
### 实施计划 — 5 个增量 Phase
> **编号说明**Phase A-E 为 v0.4.0 专属编号,接续已完成的 Phase 30。
---
#### Phase A: Swarm 编排抽象(Star / Sequential / Hierarchical + Subgraph
**目标**:在现有 `dispatch` 原语基础上,提供声明式多 Agent 编排 API。Supervisor 作为 `Arc<dyn Agent>`,通过内建工具 `dispatch_sub_agent` 驱动子 Agent 执行。
**交付物**
1. `src/engine/supervisor.rs` 新文件:
- `Swarm` 枚举/结构体:`Swarm::star()`(星型,一个 Supervisor + N 个 Worker)、`Swarm::sequential()`(顺序链 A→B→C)、`Swarm::hierarchical()`(层次嵌套,Supervisor 下的 Sub-Supervisor
- 各模式的 `build()``run(input)` 方法
- 底层通过 `SessionManager::dispatch()` / `dispatch_all()` 实现
2. `src/tools/builtin.rs` 新文件:
- `dispatch_sub_agent(name, task, config)` 内建工具 — 从 Agent 注册表查找 Agent 工厂 → `SessionManager::dispatch()`
3. `AgentRegistry``HashMap<String, Box<dyn Fn() -> Arc<dyn Agent>>>` 轻量工厂注册表(约 50 行)
4. Subgraph 嵌套:`Swarm::hierarchical()` 支持 `group(name, inner_swarm)`,内层 Swarm 作为子节点编译后嵌入
**设计要点**
- Supervisor 就是 `Arc<dyn Agent>`,不新增 `SupervisorAgent` trait
- 路由逻辑写在 Supervisor 的 system prompt 中(LLM 决定的动态路由)
- 三种模式覆盖常见编排拓扑,不引入通用图引擎(路线 B 留作未来)
- Subgraph 编译为独立的 `SessionManager` 子树(复用 `create_child` 的父子关系)
**依赖**Phase 18SubAgent dispatch / SessionManager
**优先级**P0
**预估规模**:约 500 行
**状态**:📋 待实施
---
#### Phase B: 结果聚合 + 编排模式完善
**目标**:让 Supervisor 能智能地合并 Worker 结果。完善三种编排模式的容错性和易用性。
**交付物**
1. `aggregation_prompt` 模板系统 — 内建 `DEFAULT_AGGREGATION_PROMPT`,用户可自定义聚合逻辑
2. `DispatchConfig` 扩展:
- `result_key: Option<String>` — 将子结果存入 `session_memory` 的指定 key,供后续阶段使用
- `aggregate_strategy: AggregateStrategy``Concatenate` / `Summarize` / `Custom(Value)`
3. 编排模式增强:
- `Swarm::sequential()` 支持失败时停止 / 跳过 / 重试策略
- `Swarm::star()` 支持 Worker 超时
4. 端到端示例 3 个:
- `swarm_star_demo.rs` — 星型编排 + 并发派发 + 结果聚合
- `swarm_sequential_demo.rs` — 串联流水线
- `swarm_hierarchical_demo.rs` — 层次嵌套(Supervisor → Sub-Supervisor → Worker
**依赖**Phase A
**优先级**P0
**预估规模**:约 200 行
**状态**:📋 待实施
---
#### Phase C: Human-in-the-loop + 用户 Steering
**目标**:生产级多 Agent 系统的关键门禁。提供执行中暂停-审批-恢复机制,以及用户运行中校正方向的能力。
**交付物**
1. `src/engine/steer.rs` 新文件:
- `interrupt(value)` 函数 — 在工具循环中插入暂停点,持久化当前状态后返回控制权
- `Command` 枚举:
- `Command::Resume(bool)` — 二元审批(批准/拒绝)
- `Command::ResumeWith(Correction)` — 结构化校正(修改工具参数 / 调整方向)
2. `LlmCycle` 扩展:可中断工具循环模式
- `submit_with_tools_interruptible()` — 支持在工具批处理边界检查中断信号
- 中断时保存当前 `LlmCycle` 状态到 checkpoint
3. `HookEvent::OnInterrupt` / `OnSteer` 新变体 — 监听中断和校正事件
4. `SessionManager::resume_turn(session_id, resume_data)` — 从 checkpoint 恢复并注入审批结果
5. `tools/builtin.rs` 扩展:
- `request_approval(question, context)` — 请求用户审批
- `emit_steer(correction)` — 用户校正
6. Steering 生命周期:
- `interrupt` → 用户收到提示 → 用户决定方向 → `Command::ResumeWith(correction)` → Agent 在新方向上继续
**设计要点**
- User Steering 不是简单的"批准/拒绝",而是 `Correction { action, reason, amended_params }` 结构化指令
- Steering 消息在工具批处理边界(Worker 返回后、Supervisor 决策前)注入,不中断正在执行的工具
- 继承 `ContextSlot::fork/merge` 模式,steer 前 fork 快照,允许用户回退到 steer 前状态
**依赖**Phase ASwarm 编排)
**优先级**P0
**预估规模**:约 500 行
**状态**:📋 待实施
---
#### Phase D: TokenJuice 语义压缩
**目标**:替代当前字节级截断(`microcompact``[pruned]`),提供语义级别的压缩。在三层管道中接入:工具结果压缩、对话历史压缩、跨 Agent 消息压缩。
**交付物**
1. `src/llm/compress.rs` 新文件:
- `Compressor` trait`async fn compress(&self, input: &str, ctx: &CompressionContext) -> Result<String>`
- `CompressionContext``target_tokens` / `preserve_keys` / `strategy`
- `CompressionStrategy` 枚举:`Semantic { model }`LLM 摘要)、`Extractive { ratio }`(抽取式)、`Hybrid { semantic_first }`(混合)
- `SemanticCompressor` 实现(复用已有 provider 做 LLM 摘要压缩)
- `ExtractiveCompressor` 实现(确定性关键句提取,零 LLM 调用)
2. 三层接入点:
- **工具结果压缩**:在 `run_tool_loop` 中,`tool.execute()` 后插入 `compress_result()`,压缩结果再 `push ToolResult`
- **对话历史压缩**:在 `load_messages()` 后插入 `compress_history()`,替代/补充 `microcompact`
- **跨 Agent 消息压缩**:在 `inherit_session_memory` 的子 memory 写入前压缩(减少子 Agent 的 context 水位)
3. `CycleConfig` / `CompactConfig` 扩展:
- `token_compression: Option<CompressionConfig>` — 可选语义压缩配置
- `fallback_to_microcompact: bool`(默认 `true`)— LLM 压缩失败时退化为字节截断
4. TokenJuice 与现有 `microcompact` 的关系:
- `microcompact` 保留为最轻量级兜底(零 LLM 调用)
- TokenJuice 是可选增强层(默认关闭,用户 opt-in)
**设计要点**
- 零新外部依赖:LLM 摘要压缩复用已有 provider,抽取式压缩纯 Rust 实现
- 与现有 `CompactState` 断路器模式兼容(LLM 压缩失败 3 次后自动降级到 `microcompact`
- `preserve_keys` 确保关键数据(数字、ID、SQL、代码片段)不被压缩掉
**依赖**Phase 14Embedding trait 可选参考)
**优先级**P0
**预估规模**:约 400 行
**状态**:📋 待实施
---
#### Phase E: 自动校正 / Reflection
**目标**:实现 Agent 输出后的自我质量评估与自动修正循环。基于 `interrupt/resume` 基础设施,构建 Producer-Critic 闭环。
**交付物**
1. `src/engine/reflect.rs` 新文件:
- `ReflectionConfig``max_cycles`(默认 2/ `critic_agent`(可选不同模型)/ `criteria: Vec<String>`(评估标准)
- `Reflectable` trait`fn reflection_criteria(&self) -> Vec<String>` + `fn needs_refinement(&self, critique: &Critique) -> bool`
- `ReflectionLoop``evaluate(output) → Critique``should_refine? → yes: refine(output, critique) → 循环 / no: 返回`
2. Swarm 内建 Reflection 模式:
- `Swarm::reflect(producer_agent, critic_agent)` — 专用 Reflection Swarm
- 可在 Supervisor 流程中嵌入 `reflect_on(worker_result)` — 对 Worker 结果自动过一遍质量检查
3. `Critique` 结构体:`issues: Vec<Issue>` / `score: f32` / `should_refine: bool` / `suggestions: Vec<String>`
4. `tools/builtin.rs` 扩展:`verify_output(claim, evidence)` 工具 — 让 Agent 自行验证输出真实性
**设计要点**
- Producer 和 Critic 使用**不同模型**(避免同一模型的自我审查盲区 bias)
- 上限 2-3 轮(第一轮修正捕获 70–80% 改善空间,第 4+ 轮收益递减)
- 基于已有 `HookEvent::OnTurnEnd` 或扩展 `HookEvent::OnOutputGenerated` 触发反思
- 失败静默:Reflection 失败不阻断主流程(`tracing::warn!` 后继续交付原始输出)
**依赖**Phase Cinterrupt/resume 基础设施)
**优先级**P0
**预估规模**:约 350 行
**状态**:📋 待实施
---
### v0.4.0 Phase 依赖关系图
```mermaid
graph BT
PA["<b>Phase A: Swarm 编排</b><br/>Swarm::star/sequential/hierarchical<br/>Subgraph 嵌套<br/>内建 dispatch_sub_agent 工具<br/>~500 行"]:::pending
PB["<b>Phase B: 结果聚合</b><br/>aggregation_prompt 模板<br/>DispatchConfig result_key<br/>编排模式完善<br/>3 个端到端示例<br/>~200 行"]:::pending
PC["<b>Phase C: HITL + Steering</b><br/>interrupt/resume<br/>Command(ResumeWith Correction)<br/>HookEvent::OnInterrupt<br/>~500 行"]:::pending
PD["<b>Phase D: TokenJuice</b><br/>Compressor trait<br/>工具结果/历史/跨 Agent 压缩<br/>Semantic + Extractive 策略<br/>~400 行"]:::pending
PE["<b>Phase E: 自动校正</b><br/>ReflectionLoop<br/>Producer-Critic<br/>上限 2-3 轮<br/>~350 行"]:::pending
PB --> PA
PC --> PA
PE --> PC
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
classDef pending fill:#fbbf24,stroke:#d97706,color:#1a1a1a
classDef future fill:#94a3b8,stroke:#64748b,color:#1a1a1a
```
### 关键里程碑
| 里程碑 | Phase 完成条件 | 可验证指标 | 状态 |
|--------|---------------|-----------|------|
| **M16** | Phase A | `Swarm::star().supervisor(A).worker(B).run(input)` 端到端验证;`dispatch_sub_agent` 内建工具注册并可用;2 个示例 exit 0 | 📋 待启动 |
| **M17** | Phase B | `Swarm::sequential()` 串联执行验证;`Swarm::hierarchical()` 层次嵌套验证;结果聚合正确合并;3 个新示例 exit 0 | 📋 待启动 |
| **M18** | Phase C | `interrupt()` 暂停 + `Command::Resume(bool)` 恢复全链路验证;`Command::ResumeWith(Correction)` 结构化校正验证;HookEvent 触发验证 | 📋 待启动 |
| **M19** | Phase D | 工具结果经语义压缩后保留关键信息(验证压缩比 ≥ 3:1);`microcompact` 降级路径验证;对话历史压缩验证 | 📋 待启动 |
| **M20** | Phase E | ReflectionLoop 正确性验证:已知缺陷的输出被修复、无缺陷的输出不被修改(不变性保证);2 轮迭代上限验证;Critic 不同模型配置验证 | 📋 待启动 |
### 不做(v0.5+
| 功能 | 原因 |
|------|------|
| Agent 自动创生(LLM 驱动动态分派) | 设计复杂且不确定性高,v0.4 专注显式声明式编排 |
| 分布式 Session 共享(Redis 后端) | 与编排正交,大多数用户单进程即可 |
| 精确 tokenizer 计数(tiktoken-rs | 依赖引入,v0.4 专注编排与压缩能力本身 |
| 增量 Checkpoint | 存储优化,当前全量 JSON 够用 |
| 路线 BStateGraph 通用图引擎) | 当前编排需求在路线 A 范围内,图引擎留给未来版本 |
| RL 轨迹导出 | 专项需求,非通用 |
| Markdown 技能按需加载 | 独立功能 |
+24
View File
@@ -0,0 +1,24 @@
# AG Core Roadmap
> 拆分式 roadmap:按版本归档 + 未归类内容
> 最后更新:2026-07-21v0.4.0 规划完成 — Phase A-E 多 Agent 编排路线图制定)
## 文件索引
| 文件 | 范围 | 状态 |
|------|------|------|
| [`roadmap-v0.1.0.md`](./roadmap-v0.1.0.md) | v0.1.0 计划与交付 — Phase 04c + v0.1.0 Release | ✅ 已发布 2026-07-04 |
| [`roadmap-v0.2.0.md`](./roadmap-v0.2.0.md) | v0.2.0 计划与交付 — Phase 512 + v0.2.0-rc.1 | 🟡 Phase 5-11 已完成;Phase 12 P2 锦上添花可选 |
| [`roadmap-v0.3.0.md`](./roadmap-v0.3.0.md) | v0.3.0 计划与交付 - Phase 1319 | ✅ Phase 13-19 全部完成,v0.3.0 交付完毕 |
| [`roadmap-v0.3.2.md`](./roadmap-v0.3.2.md) | v0.3.2 计划与交付 — Phase 2027Cargo features 拆分) | ✅ Phase 20-27 全部完成,v0.3.2 交付完毕 |
| [`28-phase28-openai-response-api-provider.md`](./28-phase28-openai-response-api-provider.md) | Phase 28-30 OpenAI Response API Provider 实施方案(独立 feature `provider-openai-response` | ✅ Phase 28-30 已交付 |
| [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md) | v0.4.0 计划 — Phase A-ESwarm 编排、HITL + Steering、TokenJuice 语义压缩、自动校正) | 📋 计划中 |
| [`roadmap-unsorted.md`](./roadmap-unsorted.md) | 未归到任何版本的内容 — 全局愿景、当前状态、模块完整性、v0.4+ 展望、风险与建议、下一步行动、阶段总回顾 | — |
## 阅读建议
- **按版本顺序追溯历史**v0.1.0 → v0.2.0 → v0.3.0 → v0.4.0
- **了解产品演进全貌**:从 `roadmap-unsorted.md` 顶部开始读
- **查找特定 Phase**:每个版本文件内按 Phase 编号顺序排列
- **了解项目当前关注点**:从 `roadmap-unsorted.md` 的「下一步行动」开始
- **未来规划视野**:从 `roadmap-unsorted.md` 的「v0.4+ 展望」开始