Compare commits
37
Commits
32d886f870
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb7d23de3d | ||
|
|
7a215272a9 | ||
|
|
25238fc357 | ||
|
|
b7d0a7335f | ||
|
|
c5afa4b31e | ||
|
|
28ca43ccb2 | ||
|
|
528a17f5fa | ||
|
|
d48286a942 | ||
|
|
eeae943727 | ||
|
|
40e4b3d8fe | ||
|
|
8ea01d373e | ||
|
|
5e475e1303 | ||
|
|
76bbeed596 | ||
|
|
77321db8f6 | ||
|
|
939dcf0f9a | ||
|
|
b895616dd0 | ||
|
|
f6cf583cd7 | ||
|
|
0cfd401579 | ||
|
|
5baa170508 | ||
|
|
bc4eac72e1 | ||
|
|
61e6d219dd | ||
|
|
932a06f512 | ||
|
|
249fba8aaf | ||
|
|
703151e363 | ||
|
|
385560a1dd | ||
|
|
28d6a1c166 | ||
|
|
6676322666 | ||
|
|
9d73f525d0 | ||
|
|
7e72e102a2 | ||
|
|
5bb349d177 | ||
|
|
46de111965 | ||
|
|
cb922b03de | ||
|
|
34eec9f546 | ||
|
|
1d51dcdfe0 | ||
|
|
fbbf8bf6e5 | ||
|
|
cc1c68b69d | ||
|
|
209932e3b5 |
@@ -0,0 +1,5 @@
|
||||
# CodeGraph data files — local to each machine, not for committing.
|
||||
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,62 @@
|
||||
name: CI
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
env:
|
||||
RUSTFLAGS: "-D warnings"
|
||||
|
||||
jobs:
|
||||
test-matrix:
|
||||
name: test (${{ matrix.features }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
features:
|
||||
- "full"
|
||||
- "light"
|
||||
- "chat,provider-openai"
|
||||
- "chat,provider-openai,tools-mcp"
|
||||
- "multi,provider-openai"
|
||||
- "multi,provider-openai,tools-mcp"
|
||||
- "chat,provider-openai,provider-openai-response"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
- run: cargo test --no-default-features --features "${{ matrix.features }}" --lib
|
||||
|
||||
clippy:
|
||||
name: clippy
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
- run: cargo clippy --all-features --lib -- -D warnings
|
||||
|
||||
format:
|
||||
name: fmt
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- run: cargo fmt --check
|
||||
|
||||
examples:
|
||||
name: examples
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
- run: cargo test --features "full"
|
||||
@@ -186,29 +186,64 @@ pub use vector_store::VectorStore;
|
||||
|
||||
## 文档规范
|
||||
|
||||
### 方案规范 (docs/)
|
||||
### 文档编号规范(design/pdd/ + design/prd/)
|
||||
|
||||
**编号规则**:创建新方案前必须先通过 shell 命令确认当前实际最大编号(Unix: `ls docs/` / Windows: `dir docs\`),禁止使用上下文中缓存的编号,如遇冲突自动递增
|
||||
`design/pdd/`(方案文档)和 `design/prd/`(需求文档)使用相同的命名格式,但**各自独立编号**:
|
||||
|
||||
**方案文档结构**(6 项):
|
||||
1. **背景与目标** - 问题描述、预期目标
|
||||
2. **需求分析** - 功能需求、非功能需求
|
||||
3. **方案设计** - 架构设计、模块划分、接口定义
|
||||
4. **实现计划** - 任务拆解、优先级、时间估算
|
||||
5. **风险评估** - 潜在风险、缓解措施
|
||||
6. **验收标准** - 可验证的完成条件
|
||||
```
|
||||
<序号>-<简短描述>.md
|
||||
```
|
||||
|
||||
### 进度同步规范 (docs/roadmap.md)
|
||||
规则:
|
||||
- 序号使用数字,从 1 开始递增。**创建前必须通过 shell 命令确认目标目录当前实际最大编号再加 1:**
|
||||
```bash
|
||||
# 查 design/pdd/ 的最大编号
|
||||
ls design/pdd/ 2>/dev/null | grep -E '^\d+-' | sort -t- -k1 -n | tail -1 | cut -d- -f1
|
||||
# 查 design/prd/ 的最大编号
|
||||
ls design/prd/ 2>/dev/null | grep -E '^\d+-' | sort -t- -k1 -n | tail -1 | cut -d- -f1
|
||||
# 无输出则从 1 开始
|
||||
```
|
||||
禁止使用上下文中缓存的编号。
|
||||
- 描述:中文,简短概括主题
|
||||
- 两个目录各自独立编号——`design/pdd/` 已有 `3-` 时,`design/prd/` 的新文件仍从当前最大号 +1 开始,互不影响
|
||||
|
||||
完成一项实施后,必须检查 `docs/roadmap.md` 是否存在对应内容;若存在,必须同步标记为完成:
|
||||
方案文档(`design/pdd/`)应包含:
|
||||
- 背景与目标
|
||||
- 需求推演概要(需求拆解、边界识别、关键假设的简要推演)
|
||||
- 当前问题分析
|
||||
- 架构决策记录(重大技术选型、架构变更的决策过程与理由)
|
||||
- 设计方案(含架构图/流程图)
|
||||
- 实施步骤
|
||||
- 验证标准
|
||||
- 回滚方案(如适用)
|
||||
|
||||
- **Step / Phase 状态行**:对应 Step 加 ✅ 标记;Phase 章节末尾「状态」行从 ⏳ 改为 ✅ Phase X 全部交付物已完成
|
||||
- **里程碑表**:更新对应里程碑状态从 ⏳ 改为 ✅ + 完成日期
|
||||
- **依赖关系图(Mermaid)**:节点 `class` 从 `pending` / `core` 改为 `done`,必要时更新节点摘要
|
||||
- **文末「已完成 / 进行中阶段」列表**:追加一行 `- ✅ Phase X — 一句话要点`
|
||||
- **顶部「当前状态」**:补充新完成 Phase,更新「下一步」指向
|
||||
示例:
|
||||
- `design/pdd/1-ui-components重构方案.md`
|
||||
- `design/prd/1-用户认证需求.md`
|
||||
- `design/pdd/2-数据库迁移方案.md`
|
||||
|
||||
参考案例:2026-07-05 完成 Phase 7 SqliteStore 时同步更新 6 处(顶部状态 / Phase 章节 / 依赖图 / M3 / 下一步行动 / 已完成列表)。
|
||||
---
|
||||
|
||||
### 设计目录(design/)
|
||||
|
||||
项目根下的 `design/` 目录集中管理所有设计相关的文件,供人类和 agent 共同读写。
|
||||
|
||||
| 子目录 | 内容 | 谁写 | 谁读 |
|
||||
|--------|------|------|------|
|
||||
| `design/pdd/` | 方案设计文档(PDD)→ 架构方案、设计决策、转换方案 | proposal→writer pipeline | Think 参考、Build 实现、Vet 审查 |
|
||||
| `design/prd/` | 需求文档(PRD)→ 功能需求、用户故事、验收标准 | 人写 | Think 分析、Proposal 写方案时参考 |
|
||||
| `design/prototype/` | **OD 导出的原型 HTML** → 视觉稿、交互原型、页面 layout | OD 桌面版导出 | Think 分析结构、Build 对照实现 |
|
||||
| `design/notes/` | 笔记记录 → 零散想法、会议纪要、调研速记 | 人写 | 各 agent 参考 |
|
||||
| `design/roadmap/` | 路线图 → 里程碑规划、版本计划、优先级列表 | 人写 | Proposal 排期参考 |
|
||||
| `design/DESIGN.md` | 设计系统(品牌规范)→ 色板、字体、间距、语气 | OD 导出 / 人维护 | Think 提取 token、Build 同步到 `src/` |
|
||||
| `design/tokens.css` | 设计 Token CSS → 从 DESIGN.md 提取的 CSS 变量 | 人同步 / agent 同步 | 所有 Svelte 组件引用 |
|
||||
|
||||
**访问规则:**
|
||||
- 读:所有 agent 默认可读(`read_file` 不需要额外权限)
|
||||
- 写:writer agent 可通过 `"design/**": allow` 写入 `design/` 下任意子目录
|
||||
- 注意:`prototype/` 由 OD 桌面版导出,agent 只读不写;`DESIGN.md` 和 `tokens.css` 建议手动维护或 agent 写入时确认后再改
|
||||
|
||||
**兜底规则:** 文档类型不在上表时(如教程、接口文档、临时记录),或目标目录不存在时 → **向用户提问确认路径**。不允许自行推断存放位置。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+140
-13
@@ -1,29 +1,156 @@
|
||||
[package]
|
||||
name = "agcore"
|
||||
version = "0.2.0-rc.1"
|
||||
version = "0.3.7"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
default = ["full"]
|
||||
|
||||
# === 模块级 features ===
|
||||
document = []
|
||||
llm-types = []
|
||||
prompt = ["llm-types"]
|
||||
llm = ["llm-types", "tokio", "async-stream", "futures-core", "futures-util", "tokio-stream"]
|
||||
tools = ["llm-types", "futures", "tokio-util", "tokio"]
|
||||
tools-mcp = ["tools", "reqwest"]
|
||||
# memory 模块依赖 llm(conversation/vector_store 使用 compact/embedding)、tokio(knowledge.rs 使用 Mutex)、time(types.rs 使用 OffsetDateTime)
|
||||
memory = ["document", "llm", "tokio", "time"]
|
||||
memory-sqlite = ["memory", "rusqlite", "time"]
|
||||
agent = ["llm", "tools", "memory", "futures-util"]
|
||||
engine = ["agent"]
|
||||
|
||||
# === Provider features ===
|
||||
# Provider features — openai/anthropic/openai-response 额外依赖 bytes(流式解析)和 futures-util(Stream 组合)
|
||||
provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
|
||||
provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
|
||||
# OpenAI Response API(POST /responses)—— 与 Chat Completions 协议独立,独立 feature
|
||||
provider-openai-response = ["llm", "reqwest", "bytes", "futures-util"]
|
||||
# deepseek/qwen 使用 openai_compat 适配层,不需要 bytes 和 futures-util
|
||||
provider-deepseek = ["llm", "reqwest"]
|
||||
provider-qwen = ["llm", "reqwest"]
|
||||
provider-ollama = ["llm", "reqwest"]
|
||||
|
||||
# === 工具 features ===
|
||||
tracing-init = ["tracing-subscriber"]
|
||||
|
||||
# === 快捷组合 ===
|
||||
full = [
|
||||
"document", "llm-types", "prompt", "llm",
|
||||
"tools", "tools-mcp",
|
||||
"memory", "memory-sqlite",
|
||||
"agent", "engine",
|
||||
"provider-openai", "provider-anthropic", "provider-openai-response",
|
||||
"provider-deepseek", "provider-qwen", "provider-ollama",
|
||||
"tracing-init",
|
||||
]
|
||||
light = ["llm", "provider-openai", "tools", "tools-mcp", "memory", "agent", "engine", "prompt", "document"]
|
||||
chat = ["agent", "provider-openai"]
|
||||
multi = ["engine", "provider-openai"]
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
# 始终编译的轻量依赖(5 个,不参与门控)
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
async-trait = "0.1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tokio-stream = "0.1"
|
||||
futures = "0.3"
|
||||
futures-util = "0.3"
|
||||
futures-core = "0.3"
|
||||
bytes = "1"
|
||||
async-stream = "0.3"
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
|
||||
# 12 个重型依赖(全部 optional)
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process", "io-util"], optional = true }
|
||||
reqwest = { version = "0.12", features = ["json", "stream"], optional = true }
|
||||
rusqlite = { version = "0.32", features = ["bundled"], optional = true }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||
tokio-stream = { version = "0.1", optional = true }
|
||||
futures = { version = "0.3", optional = true }
|
||||
futures-util = { version = "0.3", optional = true }
|
||||
futures-core = { version = "0.3", optional = true }
|
||||
bytes = { version = "1", optional = true }
|
||||
async-stream = { version = "0.3", optional = true }
|
||||
tokio-util = { version = "0.7", features = ["rt"], optional = true }
|
||||
time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
|
||||
dotenvy = "0.15.7"
|
||||
wiremock = "0.6"
|
||||
temp-env = "0.3"
|
||||
tempfile = "3"
|
||||
|
||||
# === Examples required-features ===
|
||||
# 每个 example 声明最小 feature 集合,`cargo test --features "full"` 时全部编译;
|
||||
# 其他组合下不兼容的 example 自动跳过。
|
||||
[[example]]
|
||||
name = "prompt_composer"
|
||||
required-features = ["prompt", "llm"]
|
||||
|
||||
[[example]]
|
||||
name = "custom_tool"
|
||||
required-features = ["tools", "llm"]
|
||||
|
||||
[[example]]
|
||||
name = "conversation_memory_demo"
|
||||
required-features = ["memory"]
|
||||
|
||||
[[example]]
|
||||
name = "knowledge_graph_demo"
|
||||
required-features = ["memory"]
|
||||
|
||||
[[example]]
|
||||
name = "knowledge_search_demo"
|
||||
required-features = ["memory"]
|
||||
|
||||
[[example]]
|
||||
name = "agent_session_demo"
|
||||
required-features = ["agent"]
|
||||
|
||||
[[example]]
|
||||
name = "task_agent_demo"
|
||||
required-features = ["agent"]
|
||||
|
||||
[[example]]
|
||||
name = "context_slot_demo"
|
||||
required-features = ["agent"]
|
||||
|
||||
[[example]]
|
||||
name = "quick_start"
|
||||
required-features = ["agent"]
|
||||
|
||||
[[example]]
|
||||
name = "simple_visit"
|
||||
required-features = ["llm", "provider-openai", "tracing-init"]
|
||||
|
||||
[[example]]
|
||||
name = "streaming_events_demo"
|
||||
required-features = ["llm", "provider-openai"]
|
||||
|
||||
[[example]]
|
||||
name = "agent_switch_demo"
|
||||
required-features = ["engine"]
|
||||
|
||||
[[example]]
|
||||
name = "bridge_keys_demo"
|
||||
required-features = ["engine"]
|
||||
|
||||
[[example]]
|
||||
name = "dispatch_stream_demo"
|
||||
required-features = ["engine"]
|
||||
|
||||
[[example]]
|
||||
name = "engine_demo"
|
||||
required-features = ["engine"]
|
||||
|
||||
[[example]]
|
||||
name = "sub_agent_dispatch_demo"
|
||||
required-features = ["engine"]
|
||||
|
||||
[[example]]
|
||||
name = "document_demo"
|
||||
required-features = ["memory", "tracing-init"]
|
||||
|
||||
[[example]]
|
||||
name = "end_to_end"
|
||||
required-features = ["agent", "memory-sqlite", "provider-openai"]
|
||||
|
||||
[[example]]
|
||||
name = "response_api_demo"
|
||||
required-features = ["llm", "provider-openai-response"]
|
||||
|
||||
@@ -26,7 +26,7 @@ AG Core 不是 Agent 产品,而是 Agent 的**底层依赖库**:上层应用
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
agcore = "0.2"
|
||||
agcore = "0.3"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
```
|
||||
|
||||
@@ -38,7 +38,7 @@ use std::sync::Arc;
|
||||
use agcore::agent::{Agent, AgentBuilder, AgentSession};
|
||||
use agcore::llm::hooks::HookExecutor;
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::provider::LlmProvider;
|
||||
use agcore::llm::LlmProvider;
|
||||
use agcore::llm::types::message::{ContentBlock, Message};
|
||||
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
|
||||
use agcore::llm::types::Usage;
|
||||
@@ -110,11 +110,11 @@ let provider = create_provider(
|
||||
).expect("创建 Provider 失败");
|
||||
```
|
||||
|
||||
更多端到端示例见 [`examples/`](./examples/) 目录(共 10 个,全部可 `cargo run --example <name>`):
|
||||
更多端到端示例见 [`examples/`](./examples/) 目录(全部可 `cargo run --example <name>`):
|
||||
|
||||
| 示例 | 说明 |
|
||||
|------|------|
|
||||
| `quick_start` | **30 行最小示例**:MockProvider + EchoTool + submit_turn,新用户 5 分钟上手 |
|
||||
| `quick_start` | **最短可运行示例**:MockProvider + EchoTool + submit_turn,新用户 5 分钟上手 |
|
||||
| `end_to_end` | **完整集成示例**:3 工具 + 3 轮对话 + SqliteStore 持久化跨连接验证 |
|
||||
| `agent_session_demo` | Agent + 会话 + SessionMemory 完整链路(MockProvider 离线) |
|
||||
| `custom_tool` | 自定义工具注册、单次 / 并行调用、权限检查 |
|
||||
@@ -124,16 +124,111 @@ let provider = create_provider(
|
||||
| `knowledge_search_demo` | 知识页面关键词检索 |
|
||||
| `streaming_events_demo` | LLM 流式响应事件消费(含错误路径) |
|
||||
| `simple_visit` | 真实 LLM 调用(OpenAI / Anthropic,设置 `OPENAI_*` / `ANTHROPIC_*` 环境变量) |
|
||||
| `document_demo` | 文档分割:RecursiveCharacterSplitter 将长文本切分为可嵌入片段 |
|
||||
| `knowledge_graph_demo` | 知识图谱:实体-关系 CRUD、BFS 遍历、关键词/标签检索 |
|
||||
| `context_slot_demo` | 多上下文分区:ContextSlot 分区管理、FocusedConfig 聚焦策略 |
|
||||
| `sub_agent_dispatch_demo` | 子任务分发:SubTask 异步执行与结果汇聚 |
|
||||
| `dispatch_stream_demo` | 分发流式输出:SubTaskStreamEvent 实时消费 |
|
||||
| `engine_demo` | Agent 执行引擎:SessionManager 会话树 + Checkpointer 快照恢复 |
|
||||
| `bridge_keys_demo` | 桥接键:Agent 间上下文键值透传 |
|
||||
| `agent_switch_demo` | Agent 热切换:会话中动态切换 Agent 角色 |
|
||||
| `response_api_demo` | OpenAI Response API(`POST /responses`)真实调用 |
|
||||
|
||||
## Feature 组合
|
||||
|
||||
AG Core 通过 Cargo features 让下游按需选择模块,跳过不需要的编译单元和重型依赖。`default = ["full"]` 保持向后兼容——不指定 features 时行为与 v0.3.0 一致。
|
||||
|
||||
### 快捷组合
|
||||
|
||||
| 组合 | 场景 | 包含的 features |
|
||||
|------|------|----------------|
|
||||
| `full`(default) | 全栈使用,兼容 v0.3.0 | 全部 16 个 feature |
|
||||
| `light` | 生产常用,跳过 Anthropic/DeepSeek/Qwen/Ollama | llm + provider-openai + tools + tools-mcp + memory + agent + engine + prompt + document |
|
||||
| `chat` | 纯对话(跳过 SQLite 和 MCP) | agent + provider-openai |
|
||||
| `multi` | 多 Agent 复合(chat + subagent + switch + checkpointer) | engine + provider-openai |
|
||||
|
||||
### Cargo.toml 配置示例
|
||||
|
||||
```toml
|
||||
# 默认全栈(兼容 v0.3.0)
|
||||
[dependencies]
|
||||
agcore = "0.3"
|
||||
|
||||
# 纯对话场景:跳过 SQLite 和 MCP,编译更快
|
||||
[dependencies]
|
||||
agcore = { version = "0.3", default-features = false, features = ["chat", "provider-openai"] }
|
||||
|
||||
# 生产常用:OpenAI + 工具 + 记忆 + Agent
|
||||
[dependencies]
|
||||
agcore = { version = "0.3", default-features = false, features = ["light"] }
|
||||
|
||||
# 多 Agent 复合 + MCP 工具
|
||||
[dependencies]
|
||||
agcore = { version = "0.3", default-features = false, features = ["multi", "provider-openai", "tools-mcp"] }
|
||||
```
|
||||
|
||||
### 模块级 features
|
||||
|
||||
如需更细粒度控制,可单独启用模块级 features:
|
||||
|
||||
| Feature | 覆盖内容 | imply |
|
||||
|---------|---------|-------|
|
||||
| `document` | Document + RecursiveCharacterSplitter | — |
|
||||
| `llm-types` | Message / ToolDef / Usage 等 IR 类型 | — |
|
||||
| `prompt` | PromptTemplate + PromptComposer | `llm-types` |
|
||||
| `llm` | Provider trait + LlmCycle + hooks + compact + embedding + mock | `llm-types` |
|
||||
| `tools` | BaseTool + ToolRegistry | `llm-types` |
|
||||
| `tools-mcp` | McpClient(Stdio/StreamableHttp) | `tools` |
|
||||
| `memory` | MemoryStore + Conversation + VectorStore + KnowledgeGraph + Retriever | `document` + `llm` |
|
||||
| `memory-sqlite` | SqliteStore | `memory` |
|
||||
| `agent` | Agent + Builder + Session + ContextSlot + Summary | `llm` + `tools` + `memory` |
|
||||
| `engine` | SessionManager + Checkpointer + SubAgent + Switch | `agent` |
|
||||
| `provider-openai` | OpenAI Provider 实现 | `llm` |
|
||||
| `provider-anthropic` | Anthropic Provider 实现 | `llm` |
|
||||
| `provider-openai-response` | OpenAI Response API(`POST /responses`)Provider 实现 | `llm` |
|
||||
| `provider-deepseek` | DeepSeek Provider 实现 | `llm` |
|
||||
| `provider-qwen` | Qwen Provider 实现 | `llm` |
|
||||
| `provider-ollama` | Ollama Provider 实现 | `llm` |
|
||||
| `tracing-init` | `init_tracing()` 函数 | — |
|
||||
|
||||
## 升级指南(v0.3.0 → v0.3.2)
|
||||
|
||||
### LlmProvider trait 路径变更
|
||||
|
||||
v0.3.2 起,`LlmProvider` trait 及其关联类型 `ProviderCapabilities` / `ProviderFeatures` 从 `provider` 模块移至 `llm` 模块根级别,归属 `#[cfg(feature = "llm")]` 而非 `any(provider-*)`。纯 Mock 场景不再需要引入任何 provider feature。
|
||||
|
||||
| 旧路径(v0.3.0) | 新路径(v0.3.2) |
|
||||
|------------------|------------------|
|
||||
| `agcore::llm::provider::LlmProvider` | `agcore::llm::LlmProvider` |
|
||||
| `agcore::llm::provider::ProviderCapabilities` | `agcore::llm::ProviderCapabilities` |
|
||||
| `agcore::llm::provider::ProviderFeatures` | `agcore::llm::ProviderFeatures` |
|
||||
|
||||
**向后兼容**:`provider` 模块中保留了 `pub use` 重导出,老路径仍可编译。但推荐迁移至新路径,未来版本可能移除重导出。
|
||||
|
||||
`ProviderConfig` / `ProviderType` / `create_provider()` 等 provider 创建逻辑仍在 `agcore::llm::provider` 下,无需迁移。
|
||||
|
||||
### 迁移步骤
|
||||
|
||||
```bash
|
||||
# 1. 全局替换 use 路径
|
||||
sed -i 's/agcore::llm::provider::LlmProvider/agcore::llm::LlmProvider/g' src/**/*.rs
|
||||
sed -i 's/agcore::llm::provider::{LlmProvider/agcore::llm::{LlmProvider/g' src/**/*.rs
|
||||
|
||||
# 2. 验证编译
|
||||
cargo build --features "full"
|
||||
```
|
||||
|
||||
## 核心模块
|
||||
|
||||
| 模块 | 一句话说明 |
|
||||
|------|----------|
|
||||
| `agcore::llm` | LLM 调用周期(`LlmProvider` trait + `LlmCycle` 重试/用量 + 流式事件 + auto-compaction + Hook + 公开 `MockProvider`) |
|
||||
| `agcore::llm` | LLM 调用周期(`LlmProvider` trait + `LlmCycle` 重试/用量 + 流式工具循环 + auto-compaction + Hook + `Embedding` trait + 公开 `MockProvider`) |
|
||||
| `agcore::prompt` | 提示词工程(`PromptTemplate` 变量插值 + `PromptTemplateRegistry` + `PromptComposer` 多角色消息构造 + `validate_messages`) |
|
||||
| `agcore::tools` | 工具系统(`BaseTool` trait + `ToolRegistry` 注册/调用 + `PermissionChecker` 黑白名单 + MCP stdio 客户端) |
|
||||
| `agcore::memory` | 记忆系统(`MemoryStore` trait + `InMemoryStore` 默认实现 + `ConversationMemory` 滑动窗口 + `KnowledgeStore` + `MemoryRetriever`) |
|
||||
| `agcore::agent` | Agent 运行时(`Agent` trait 角色定义 + `AgentBuilder` + `RuntimeBundle` 依赖注入 + `AgentSession` 会话 + `SessionMemory` + `Plan`/`Step` 任务编排) |
|
||||
| `agcore::memory` | 记忆系统(`MemoryStore` trait + `InMemoryStore` / `SqliteStore` + `ConversationMemory` 滑动窗口 + `KnowledgeStore` + `KnowledgeGraph` 图谱 + `VectorStore` 向量 + `RagPipeline` + `MemoryRetriever` 混合检索) |
|
||||
| `agcore::agent` | Agent 运行时(`Agent` trait + `AgentBuilder` + `RuntimeBundle` + `AgentSession` + 多上下文分区 + 摘要自动生成 + 快照恢复 + `Plan`/`Step` 任务编排) |
|
||||
| `agcore::engine` | Agent 执行引擎(`SessionManager` 会话树 + `Checkpointer` time-travel 快照 + 子任务分发 + Agent 热切换) |
|
||||
| `agcore::document` | 文档分割(`Document` 类型 + `RecursiveCharacterSplitter` 递归字符级分割) |
|
||||
|
||||
## 架构关系图
|
||||
|
||||
@@ -143,9 +238,14 @@ let provider = create_provider(
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│ 使用
|
||||
┌───────────────────────────▼─────────────────────────────────┐
|
||||
│ Agent Runtime (agcore::agent) │
|
||||
│ Agent Engine (agcore::engine) │
|
||||
│ SessionManager / Checkpointer / SubTask 分发 / Agent 切换 │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│ 编排
|
||||
┌───────────────────────────▼─────────────────────────────────┐
|
||||
│ Agent Runtime (agcore::agent) │
|
||||
│ Agent / AgentBuilder / RuntimeBundle / AgentSession / │
|
||||
│ SessionMemory / Plan / Step │
|
||||
│ ContextSlot / SummaryConfig / Plan / Step │
|
||||
└─────┬───────────────┬───────────────┬───────────────┬───────┘
|
||||
│ │ │ │
|
||||
┌─────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||
@@ -154,10 +254,15 @@ let provider = create_provider(
|
||||
│ llm │ │ prompt │ │ tools │ │ memory │
|
||||
└─────┬─────┘ └─────────────┘ └─────┬───────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
└──────────────┬────────────────┘ │
|
||||
▼ │
|
||||
┌─────────────────┐ │
|
||||
│ Mock Provider │◄──────────────────────┘
|
||||
│ ┌──────────┐ │ ┌───────────┴──────┐
|
||||
│ │ Document │ │ │ Graph / Vector │
|
||||
│ │ agcore:: │ │ │ RagPipeline │
|
||||
│ │ document │ │ │ Retriever │
|
||||
│ └──────────┘ │ └──────────────────┘
|
||||
└──────────────┬────────────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Mock Provider │
|
||||
│ 公开 API │ 离线测试 / 示例
|
||||
└─────────────────┘
|
||||
```
|
||||
@@ -166,28 +271,35 @@ let provider = create_provider(
|
||||
|
||||
```mermaid
|
||||
graph BT
|
||||
LLM["<b>llm</b><br/>Provider / Cycle /<br/>Hooks / Stream /<br/>Compact / Mock"]:::core
|
||||
LLM["<b>llm</b><br/>Provider / Cycle /<br/>Hooks / Stream /<br/>Compact / Embedding /<br/>Mock"]:::core
|
||||
Prompt["<b>prompt</b><br/>Template / Composer"]:::core
|
||||
Tool["<b>tools</b><br/>BaseTool / Registry /<br/>Permission / MCP"]:::core
|
||||
Memory["<b>memory</b><br/>Store / Conversation /<br/>Knowledge / Retriever"]:::core
|
||||
Agent["<b>agent</b><br/>Agent / Builder /<br/>Session / Plan"]:::core
|
||||
Tool["<b>tools</b><br/>BaseTool / Registry /<br/>MCP"]:::core
|
||||
Memory["<b>memory</b><br/>Store / Conversation /<br/>Knowledge / Graph /<br/>VectorStore / RagPipeline /<br/>Retriever"]:::core
|
||||
Agent["<b>agent</b><br/>Agent / Builder /<br/>Session / ContextSlot /<br/>Summary / Plan"]:::core
|
||||
Engine["<b>engine</b><br/>SessionManager /<br/>Checkpointer /<br/>SubTask / Switch"]:::phase
|
||||
Document["<b>document</b><br/>Splitter"]:::phase
|
||||
|
||||
Prompt --> LLM
|
||||
Tool --> LLM
|
||||
Memory --> LLM
|
||||
Memory --> Document
|
||||
Document --> LLM
|
||||
Agent --> LLM
|
||||
Agent --> Tool
|
||||
Agent --> Memory
|
||||
Engine --> Agent
|
||||
|
||||
classDef core fill:#60a5fa,stroke:#2563eb,color:#fff
|
||||
classDef phase fill:#a78bfa,stroke:#7c3aed,color:#fff
|
||||
```
|
||||
|
||||
**依赖规则**:
|
||||
|
||||
- `llm` 是叶子,被其他四个模块使用
|
||||
- `prompt` / `tools` / `memory` 互相不依赖,可独立使用
|
||||
- `agent` 编译期依赖 `llm` / `tools` / `memory`,与 `prompt` 无直接编译依赖(system prompt 以 `&str` 形式传入)
|
||||
- 上层应用只应依赖 `agent` + 必要的子模块,不应跨层直接 `use`
|
||||
- `llm` 被 `agent` / `engine` 使用,同时 `llm::cycle` 依赖 `tools::ToolRegistry`(用于工具调用循环)
|
||||
- `prompt` / `tools` 互不依赖,可独立使用;`memory` 依赖 `document`(向量存储的分割器)和 `llm`(embedding trait)
|
||||
- `agent` 编译期依赖 `llm` / `tools` / `memory`,与 `prompt` / `document` 无直接编译依赖(system prompt 以 `&str` 形式传入,文档分割由应用层处理)
|
||||
- `engine` 编译期依赖 `agent`,提供会话树管理和快照恢复能力
|
||||
- 上层应用通常依赖 `engine`(完整能力)或 `agent`(轻量场景),不应跨层直接 `use`
|
||||
|
||||
## 环境变量
|
||||
|
||||
@@ -201,7 +313,7 @@ graph BT
|
||||
| `ANTHROPIC_API_KEY` | 用 Anthropic 时 | — | Anthropic Claude API key |
|
||||
| `ANTHROPIC_BASE_URL` | 是 | `https://api.anthropic.com` | Anthropic 兼容端点 base URL |
|
||||
| `ANTHROPIC_MODEL` | 是 | `claude-3-5-sonnet-latest` | Claude 模型名 |
|
||||
| `PROVIDER` | 否 | `openai` | Provider 类型:`openai` / `openai-response` / `anthropic` / `deepseek` / `qwen` |
|
||||
| `PROVIDER` | 否 | `openai` | Provider 类型:`openai` / `anthropic` / `deepseek` / `qwen` / `ollama` |
|
||||
| `RUST_LOG` | 否 | `agcore=info` | tracing 日志级别(其他 crate 可加 `=debug`) |
|
||||
|
||||
示例(运行 `examples/simple_visit.rs` 真实调用 OpenAI):
|
||||
@@ -226,7 +338,7 @@ AG Core 在 Phase 4 设计阶段调研了 4 个 2026 年公开的 AI Agent 项
|
||||
| [OpenHuman](https://github.com/tinyhumansai/openhuman) | 桌面助手 | Rust + Tauri | 记忆树与 Token 压缩 |
|
||||
| [OpenHarness](https://github.com/HKUDS/OpenHarness) | Agent Harness 框架 | Python | 显式依赖注入容器 + 三级权限 |
|
||||
|
||||
AG Core 不"抄代码",只参考架构模式。当前实现已经采纳:
|
||||
AG Core 借鉴了以下架构模式(不直接复用代码):
|
||||
|
||||
- **OpenHarness 风格** —— 显式 `RuntimeBundle` 依赖注入容器
|
||||
- **Hermes 风格** —— `Agent` trait(角色)与 `AgentSession`(会话)解耦
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
# Phase 16 — 摘要自动生成
|
||||
|
||||
## 背景与目标
|
||||
|
||||
### 问题
|
||||
|
||||
长对话场景中,用户与 Agent 交互 30+ 轮后,消息历史长度远超模型上下文窗口,导致:
|
||||
|
||||
- LLM 被迫丢弃早期上下文,对话丧失连贯性
|
||||
- 开发者需要手动管理摘要逻辑(调 LLM → 写 SessionMemory → 注入 FocusedConfig)
|
||||
- v0.2 的 `FocusedConfig.summary_override` 消费端已就绪,但生产端是空的——用户只能手动设字符串
|
||||
|
||||
### 目标
|
||||
|
||||
闭环长对话的"上下文压缩"链路:
|
||||
|
||||
```
|
||||
[消费端 v0.2 已就绪] FocusedConfig.summary_override → filter_focused() 注入摘要
|
||||
[生产端 v0.3 补齐] token 水位检测 → LLM 摘要生成 → 自动写入 summary_override
|
||||
```
|
||||
|
||||
### 成功标准
|
||||
|
||||
1. 开发者只需在 `AgentBuilder` 中链式调用 `.summary_config(cfg)` 即可启用
|
||||
2. 长对话(如 30+ 轮或 token 水位超过 `max_context_tokens * trigger_token_ratio`)自动触发摘要,下轮 `load_messages()` 返回值包含 `[上下文摘要] {summary}`
|
||||
3. 摘要生成不改变 `submit_turn` 行为(opt-in、静默失败、不阻断主流程)
|
||||
4. 零新外部依赖
|
||||
|
||||
---
|
||||
|
||||
## 需求分析
|
||||
|
||||
### 功能需求
|
||||
|
||||
| # | 需求 | 优先级 | 说明 |
|
||||
|---|------|--------|------|
|
||||
| F1 | `SummaryConfig` 配置结构体 | P0 | `trigger_token_ratio` / `max_context_tokens` / `summary_prompt` / `debounce_turns` / `summary_model` / `max_tool_result_chars` |
|
||||
| F2 | Token 水位自动检测 | P0 | 每轮 OnTurnEnd 之后检查 `cost_so_far` 是否超过 `max * ratio` |
|
||||
| F3 | LLM 摘要生成 | P0 | 复用 `self.bundle.provider`,单次无工具 LLM 调用 |
|
||||
| F4 | 摘要写入 FocusedConfig | P0 | 更新 `summary_override` + `slot.save()` 持久化 |
|
||||
| F5 | 摘要全局快照 | P0 | 同步写入 `SessionMemory::set("conversation_summary", summary)` |
|
||||
| F6 | 防抖机制 | P0 | 两次摘要之间至少间隔 `debounce_turns` 轮(默认 3) |
|
||||
| F7 | 流式路径对称支持 | P0 | `finalize_turn` 中插入相同检查点 |
|
||||
| F8 | 公开 API:`get_conversation_summary()` | P1 | 读取 SessionMemory 中最新的摘要 |
|
||||
|
||||
### 非功能需求
|
||||
|
||||
| # | 需求 | 指标 |
|
||||
|---|------|------|
|
||||
| N1 | 零外部依赖 | 不修改 `Cargo.toml` |
|
||||
| N2 | 向后兼容 | 未设置 `SummaryConfig` 时行为零变化 |
|
||||
| N3 | 静默失败 | 摘要 LLM 调用失败不阻断 `submit_turn` |
|
||||
| N4 | 摘要延迟 | 首次摘要 LLM 调用 ≤ 3s(依赖 provider 响应速度) |
|
||||
|
||||
---
|
||||
|
||||
## 当前状态分析
|
||||
|
||||
### 消费端已就绪
|
||||
|
||||
`FocusedConfig.summary_override`(`src/agent/context.rs`)已在 Phase 10 实现,当前消费逻辑:
|
||||
|
||||
```
|
||||
filter_focused() → 若 cfg.summary_override = Some(text) → 在消息列表末尾插入
|
||||
Message::system("[上下文摘要] {text}")
|
||||
```
|
||||
|
||||
文档注释明确标注:`// v0.3 将支持 Hook 驱动的自动摘要生成`
|
||||
|
||||
### 代码上下文
|
||||
|
||||
| 模块 | 文件 | 状态 | 与 Phase 16 的关系 |
|
||||
|------|------|------|-------------------|
|
||||
| FocusedConfig | `agent/context.rs` | ✅ 消费端 | 摘要写入 `summary_override` 即生效 |
|
||||
| OnTurnEnd | `agent/session.rs:345` | ✅ 触发点 | 摘要检查点插在此之后 |
|
||||
| CostTracker | `llm/cycle/usage.rs` | ✅ 累计 token | 水位检测的数据源 |
|
||||
| SessionMemory | `agent/session_memory.rs` | ✅ set/get | 摘要全局快照存储 |
|
||||
| AgentBuilder | `agent/builder.rs` | ✅ 链式构造 | 新增 `.summary_config()` |
|
||||
| AgentConfig | `agent/runtime.rs` | ✅ 配置结构 | 新增 `summary_config` 字段 |
|
||||
| LlmProvider | `llm/provider.rs` | ✅ Trait | 摘要 LLM 调用复用 provider |
|
||||
| ContextSlot.save | `agent/context.rs:251` | ✅ 持久化 | 更新 config 后写回 |
|
||||
|
||||
---
|
||||
|
||||
## 可选方案推演
|
||||
|
||||
### 方案 A(推荐):内联检查点
|
||||
|
||||
**做法**:在 `submit_turn` 和 `finalize_turn` 中,OnTurnEnd 触发之后、`turn_index` 递增之前,插入以下逻辑:
|
||||
|
||||
```rust
|
||||
if let Some(ref sc) = self.bundle.config.summary_config
|
||||
&& self.should_summarize(sc)
|
||||
{
|
||||
// clone 所需数据(释放 &self 借用)
|
||||
let provider = Arc::clone(&self.bundle.provider);
|
||||
let messages = self.slots.get(&self.current_slot_id)
|
||||
.map(|s| s.messages.clone()).unwrap_or_default();
|
||||
let prompt = sc.summary_prompt.clone();
|
||||
let model = sc.summary_model.clone();
|
||||
|
||||
// 调关联函数(不持有 &self)
|
||||
match Self::generate_summary(&provider, &messages, &prompt, model.as_deref(), sc.max_tool_result_chars).await {
|
||||
Ok(text) => {
|
||||
// 更新 FocusedConfig + 持久化
|
||||
if let Some(slot) = self.slots.get_mut(&self.current_slot_id) {
|
||||
if let SlotMode::Focused(ref mut cfg) = slot.config.mode {
|
||||
cfg.summary_override = Some(text.clone());
|
||||
}
|
||||
let _ = slot.save(&*self.resolve_store()).await;
|
||||
}
|
||||
// 全局快照
|
||||
let _ = self.session_memory.set("conversation_summary", &text).await;
|
||||
self.last_summary_turn = self.turn_index;
|
||||
}
|
||||
Err(e) => tracing::error!("摘要自动生成失败 (turn={}): {}", self.turn_index, e),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 代码路径最短最清晰(~50 行核心逻辑)
|
||||
- 直接访问所有需要的数据(`cost_so_far`、`slots`、`provider`、`session_memory`)
|
||||
- 流式和同步版本统一处理
|
||||
- `Option<SummaryConfig>` 本身已提供 opt-in/opt-out
|
||||
- 不改变 Hook 系统签名
|
||||
|
||||
**缺点**:
|
||||
- 摘要 LLM 调用延长了 `submit_turn` 的延迟(约 1-3s)
|
||||
- 违反"Hook 哲学"(但 `Option` 配置已足够提供可插拔性)
|
||||
|
||||
### 方案 B(否决):扩展 HookContext
|
||||
|
||||
**做法**:在 `HookContext` 中增加 `messages: &[Message]`、`usage: &Usage`、`provider: Arc<dyn LlmProvider>` 字段,让 OnTurnEnd Hook 实现者自行做摘要。
|
||||
|
||||
**否决原因**:
|
||||
1. **生命周期冲突**:`&[Message]` 要求 Hook 调用点消息已就绪但未被 `&mut self` 借用——在 `submit_turn` 第 7 步(slot.save)后消息已就绪,但 to pass `&[Message]` 到 HookContext 需要与 `slot.messages` 的不可变引用共存,而 `submit_turn` 流程中后续步骤需要 `&mut self`
|
||||
2. **流式路径不可行**:`finalize_turn` 触发 OnTurnEnd 时 cycle 已销毁,消息只能从 slot 获取,但 slot 在 `append_messages` 后已被 `&mut` 借用
|
||||
3. **`Arc<dyn LlmProvider>` 的 `'static` 需求**与 `HookContext<'a>` 的设计冲突
|
||||
|
||||
### 方案 C(否决):后台 spawn 异步摘要
|
||||
|
||||
**做法**:token 检测通过后,`tokio::spawn` 后台任务做摘要生成和写入。
|
||||
|
||||
**否决原因**:
|
||||
1. **写入冲突**:后台任务无法获取 `&mut AgentSession` 来更新 slot config
|
||||
2. **绕过方式增加复杂度**:后台任务需要直接操作 `Arc<dyn MemoryStore>` 的原始 key(`slot_config:{session_id}:{slot_id}`),绕过了 `ContextSlot::save()` 的封装
|
||||
3. **并发风险**:如果前一轮摘要尚未完成而下一轮 `finalize_turn` 又触发,可能导致覆盖写
|
||||
|
||||
---
|
||||
|
||||
## 推荐方案(内联检查点)
|
||||
|
||||
### 架构图
|
||||
|
||||
```
|
||||
submit_turn(user_input)
|
||||
│
|
||||
├─ 1. Readonly 检查
|
||||
├─ 2. OnTurnStart hook
|
||||
├─ 3. slot.load_messages() ← 历史摘要已注入(如有)
|
||||
├─ 4. LlmCycle.submit_with_tools
|
||||
├─ 5. cost_so_far.add(usage)
|
||||
├─ 6. slot.append_messages + save
|
||||
├─ 7. OnTurnEnd hook ← 纯通知,不做摘要
|
||||
│
|
||||
├─ [8.5] 摘要检查点 ──────────────────────────────┐
|
||||
│ ├─ should_summarize(cfg) │
|
||||
│ │ ├─ cost_so_far >= max * ratio? │
|
||||
│ │ └─ turn - last_summary >= debounce? │
|
||||
│ │ │
|
||||
│ ├─ generate_summary() ← 新 LlmCycle │
|
||||
│ │ ├─ format_messages_as_text() │
|
||||
│ │ ├─ replace {messages} │
|
||||
│ │ └─ submit_messages(无 tools) │
|
||||
│ │ │
|
||||
│ └─ 成功 → 更新 summary_override + save │
|
||||
│ → SessionMemory.set() │
|
||||
│ → last_summary_turn = turn_index │
|
||||
│ (流式路径用 saturating_sub(1) 修正) │
|
||||
│ 失败 → tracing::error! 静默 │
|
||||
│ │
|
||||
├─ 9. turn_index++
|
||||
└─ 10. return Ok(response)
|
||||
```
|
||||
|
||||
### 模块划分
|
||||
|
||||
**新增文件**:`src/agent/summary.rs`
|
||||
|
||||
```
|
||||
src/agent/summary.rs
|
||||
├── SummaryConfig // 摘要自动生成配置
|
||||
├── format_messages_as_text() // 消息 → 纯文本(简洁版)
|
||||
└── DEFAULT_SUMMARY_PROMPT // 默认 prompt 模板
|
||||
```
|
||||
|
||||
**修改文件**:
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `agent/runtime.rs` | `AgentConfig` 新增 `summary_config: Option<SummaryConfig>` |
|
||||
| `agent/builder.rs` | 新增 `summary_config(cfg)` 方法 |
|
||||
| `agent/session.rs` | 新增 `last_summary_turn` 字段;`submit_turn` / `finalize_turn` 插入检查点;关联函数 `generate_summary`;`get_conversation_summary()` |
|
||||
| `agent.rs` | `pub mod summary` + re-export |
|
||||
|
||||
**不变的文件**(无需改动):
|
||||
|
||||
| 文件 | 原因 |
|
||||
|------|------|
|
||||
| `llm/hooks.rs` | 内联方案不扩展 HookContext |
|
||||
| `llm/cycle.rs` | 摘要调用通过 `submit_messages` 独立使用 |
|
||||
| `agent/context.rs` | `FocusedConfig` 消费端已在 Phase 10 就绪 |
|
||||
| `Cargo.toml` | 零新外部依赖 |
|
||||
|
||||
### 核心接口定义
|
||||
|
||||
**`SummaryConfig`**(`agent/summary.rs`):
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SummaryConfig {
|
||||
/// Token 水位触发比例(0.0 ~ 1.0)。默认 0.75。
|
||||
pub trigger_token_ratio: f64,
|
||||
/// 模型上下文窗口大小(token)。默认 32_000,覆盖大部分开源模型。
|
||||
/// 修改为匹配实际使用模型的上下文窗口。
|
||||
/// ⚠️ 设置为超过模型窗口的值会导致摘要永远不触发。
|
||||
pub max_context_tokens: u32,
|
||||
/// 摘要 prompt 模板。`{messages}` 将被替换为对话历史文本。
|
||||
pub summary_prompt: String,
|
||||
/// 防抖轮次。默认 3。
|
||||
pub debounce_turns: u32,
|
||||
/// 摘要生成模型(None = 沿用主 provider 默认模型)。
|
||||
/// 默认 None。推荐设为便宜模型(如 "gpt-4o-mini")以节省成本。
|
||||
pub summary_model: Option<String>,
|
||||
/// 单个 ToolResult 在格式化时保留的最大字符数。默认 500。
|
||||
/// 超过此值从尾部截断。字符级安全(`chars().take()`)。
|
||||
pub max_tool_result_chars: usize,
|
||||
}
|
||||
```
|
||||
|
||||
**`generate_summary`**(`AgentSession` 关联函数):
|
||||
|
||||
```rust
|
||||
impl AgentSession {
|
||||
async fn generate_summary(
|
||||
provider: &Arc<dyn LlmProvider>,
|
||||
messages: &[Message],
|
||||
prompt_template: &str,
|
||||
summary_model: Option<&str>,
|
||||
max_tool_result_chars: usize,
|
||||
) -> Result<String, LlmError> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**`should_summarize`**(`AgentSession` 方法):
|
||||
|
||||
```rust
|
||||
fn should_summarize(&self, cfg: &SummaryConfig) -> bool {
|
||||
self.turn_index - self.last_summary_turn >= cfg.debounce_turns
|
||||
&& self.cost_so_far.total().total_tokens as f64
|
||||
>= cfg.max_context_tokens as f64 * cfg.trigger_token_ratio
|
||||
}
|
||||
```
|
||||
|
||||
### 消息格式化(简洁版)
|
||||
|
||||
`format_messages_as_text` 输出格式:
|
||||
|
||||
```
|
||||
System: 你是一个翻译助手
|
||||
User: 把这段英文翻译成中文
|
||||
Assistant: 请提供英文文本 [Tool: translate]
|
||||
Tool Result: 这是中文翻译
|
||||
User: 谢谢
|
||||
Assistant: 不客气
|
||||
```
|
||||
|
||||
处理规则:
|
||||
- `ContentBlock::Text { text }` → 直接拼接
|
||||
- `ContentBlock::ToolUse { name, .. }` → `[Tool: {name}]`(不显示参数 JSON)
|
||||
- `Message::ToolResult { content, is_error, tool_call_id }` → `Tool Result [{tool_call_id}]:` / `Tool Error [{tool_call_id}]:`,便于多工具场景下关联调用的返回
|
||||
- ToolResult 文本截断到前 `max_tool_result_chars` 个 Unicode 字符(`chars().take(n)`,字符级安全,避免多字节截断)
|
||||
- 整段对话若超过 30K 字符,从前面截断(优先保留最新消息)
|
||||
- `Message::UserImage { .. }` → `User: [image]`
|
||||
- 非 Text block(Image / Audio / File 等)统一标记为 `[{kind}]`
|
||||
- 每条消息一行,空行分隔
|
||||
|
||||
---
|
||||
|
||||
## 实现计划
|
||||
|
||||
### Step 16.1 — `SummaryConfig` 结构体
|
||||
|
||||
**文件**:新增 `src/agent/summary.rs`
|
||||
|
||||
**内容**:
|
||||
- `SummaryConfig` 结构体定义(6 个字段 + doc comments)
|
||||
- `DEFAULT_SUMMARY_PROMPT` 常量(约 100 字中文 prompt,含 `{messages}` 占位符)
|
||||
- `impl Default for SummaryConfig`
|
||||
- `format_messages_as_text(messages: &[Message]) -> String` 辅助函数
|
||||
|
||||
**验证**:`cargo build`
|
||||
|
||||
### Step 16.2 — `AgentConfig` 扩展 + `AgentBuilder` 方法
|
||||
|
||||
**文件**:`src/agent/runtime.rs` + `src/agent/builder.rs`
|
||||
|
||||
**改动**:
|
||||
- `AgentConfig` 新增字段:`pub summary_config: Option<SummaryConfig>`
|
||||
- `AgentBuilder` 新增方法:
|
||||
```rust
|
||||
pub fn summary_config(mut self, cfg: SummaryConfig) -> Self {
|
||||
let mut config = self.config.take().unwrap_or_default();
|
||||
config.summary_config = Some(cfg);
|
||||
self.config = Some(config);
|
||||
self
|
||||
}
|
||||
```
|
||||
|
||||
**验证**:`AgentBuilder` 单元测试 + `cargo test`
|
||||
|
||||
### Step 16.3 — `AgentSession` 新字段 + 检查点
|
||||
|
||||
**文件**:`src/agent/session.rs`
|
||||
|
||||
**改动**:
|
||||
|
||||
1. `AgentSession` 新增字段:`last_summary_turn: u32`(初始化 0)
|
||||
2. `submit_turn` 中 OnTurnEnd 之后、turn_index 之前插入检查点
|
||||
3. `finalize_turn` 中 OnTurnEnd 之后插入对称检查点。注意:流式路径中 `turn_index` 已在 `submit_turn_stream` 中递增,检查点赋值使用 `self.turn_index.saturating_sub(1)`(与 `OnTurnEnd` hook 保持一致)。
|
||||
4. 关联函数 `generate_summary`:
|
||||
- 接收 `provider`、`messages`、`prompt_template`、`summary_model`、`max_tool_result_chars`
|
||||
- 入口守卫:`messages.is_empty()` 时直接返回 `Ok(String::new())`
|
||||
- 构造 `LlmCycle`(`max_tokens = Some(1024)`)
|
||||
- 调 `cycle.submit_messages(vec![Message::user_text(prompt)], vec![])`
|
||||
- 提取 text 返回
|
||||
5. 公开 API:`get_conversation_summary()` → `self.session_memory.get("conversation_summary")`
|
||||
|
||||
**验证**:`cargo build --all-targets`
|
||||
|
||||
### Step 16.4 — re-export
|
||||
|
||||
**文件**:`src/agent.rs`
|
||||
|
||||
**改动**:
|
||||
```rust
|
||||
pub mod summary;
|
||||
pub use summary::SummaryConfig;
|
||||
```
|
||||
|
||||
**验证**:`cargo test --all-targets`
|
||||
|
||||
### Step 16.5 — 测试
|
||||
|
||||
| 测试 | 验证点 | 方式 |
|
||||
|------|--------|------|
|
||||
| `summary_config_defaults` | 默认值正确 | 单元测试 |
|
||||
| `summary_not_generated_below_threshold` | token < 阈值时不触发 | `MockProvider` + `Usage::from_input_output(10, 5)` |
|
||||
| `summary_generated_above_threshold` | token ≥ 阈值时触发 | 设置 `max_context_tokens=20` + `trigger_token_ratio=0.5` |
|
||||
| `summary_debounce_works` | debounce 内不重复 | 强行触发摘要后验证 3 轮内不触发 |
|
||||
| `summary_injected_into_focused` | Focused 模式 `load_messages()` 含 `[上下文摘要]` | 检查 Message 内容 |
|
||||
| `summary_written_to_session_memory` | `get_session_data("conversation_summary")` 有值 | 集成测试 |
|
||||
| `summary_not_injected_in_full_mode` | Full 模式不改 slot config | 验证 `summary_override` 为 None |
|
||||
| `summary_failure_does_not_block` | LLM error 不阻断 `submit_turn` | MockProvider 返回错误 |
|
||||
| `summary_stream_path` | 流式路径 `finalize_turn` 正确触发 | `submit_turn_stream` 端到端 |
|
||||
| `summary_format_messages` | 格式化输出结构正确 | 单元测试验证格式 |
|
||||
| `summary_skipped_for_empty_messages` | 空消息不调用 LLM | `generate_summary` 直接返回 `""` |
|
||||
| `summary_not_generated_if_max_context_unreachable` | `max_context_tokens` 过大时不触发 | 验证条件不满足 |
|
||||
|
||||
**验证**:`cargo test --all-targets` 全绿
|
||||
|
||||
---
|
||||
|
||||
## 规模估算
|
||||
|
||||
| 组件 | 纯实现 | 测试 | 合计 |
|
||||
|------|--------|------|------|
|
||||
| `agent/summary.rs`(SummaryConfig + format_messages + 默认 prompt + 截断守卫) | 60 | 10 | 70 |
|
||||
| `agent/runtime.rs`(1 个字段) | 3 | — | 3 |
|
||||
| `agent/builder.rs`(1 个方法) | 8 | 3 | 11 |
|
||||
| `agent/session.rs`(检查点 + generate_summary + get_conversation_summary) | 40 | 100 | 140 |
|
||||
| `agent.rs`(module 声明 + re-export) | 3 | — | 3 |
|
||||
| **合计** | **109** | **113** | **~222** |
|
||||
|
||||
---
|
||||
|
||||
## 风险评估
|
||||
|
||||
### 已知风险
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| **同步阻塞**:摘要 LLM 调用延长 submit_turn 延迟 | 高 | 长对话用户多等 1-3s | 对于已达 75% 水位的长对话,用户感知可接受;所有错误静默处理 |
|
||||
| **默认模型不兼容**:非 OpenAI 用户未设置 `summary_model` 但默认 `None` 沿用主模型 | 低 | 无影响 | `summary_model` 默认 `None`,沿用主 provider 默认模型,零兼容问题 |
|
||||
| **无限循环**:摘要不减少 cost_so_far,每轮都超阈值 | 中 | 频繁 LLM 调用浪费 token | `debounce_turns=3` 强制隔断;`last_summary_turn` 记录确保了间隔。注意:摘要 token 不计入 `cost_so_far`(独立 LlmCycle),阈值不会因摘要本身加速膨胀 |
|
||||
| **Focusd 模式摘要位置**:注入为 `system` 消息排在列表末尾 | 低 | LLM 近因效应,摘要可能过度受关注 | 这是 v0.2 消费端的设计选择,Phase 16 不改变 |
|
||||
| **SessionMemory key 冲突**:用户手动写入 `"conversation_summary"` 会被覆盖 | 低 | 数据被摘要覆盖 | 文档建议用户自定义 key;或未来使用 namespaced key |
|
||||
| **可观测性盲区**:`tracing::warn!` 依赖用户配置了 tracing subscriber | 中 | 失败静默不可见 | 提升到 `tracing::error!` 级别,或加 `eprintln!` fallback |
|
||||
|
||||
### 不做的事
|
||||
|
||||
- ❌ 不扩展 `HookContext`
|
||||
- ❌ 不引入 `tokio::spawn` 后台摘要
|
||||
- ❌ 不做增量摘要(`SummaryStrategy::Incremental` 留待 v0.4)
|
||||
- ❌ 不改 `filter_focused()` 的摘要注入位置
|
||||
- ❌ 不追踪摘要 token 消耗(`summary_cost_so_far`)
|
||||
- ❌ 不添加运行时 prompt 校验(不检查 `{messages}` 是否存在)
|
||||
- ❌ 不添加 `MergeStrategy::Summarize` 变体(`context.rs:108` 预占注释将在实施时同步移除或更新)
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
### 编译与测试
|
||||
|
||||
| 检查项 | 指标 |
|
||||
|--------|------|
|
||||
| `cargo build --all-targets` | ✅ 通过 |
|
||||
| `cargo test --all-targets` | ✅ 全量通过(预计 335 → ~345,新增 ~10 测试) |
|
||||
| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 |
|
||||
| 测试覆盖范围 | F1-F8、N1-N4 |
|
||||
|
||||
### 功能验收场景
|
||||
|
||||
**场景 1:启用摘要后的长对话**
|
||||
|
||||
```rust
|
||||
let session = AgentSession::new(agent, "session-1", Arc::new(
|
||||
AgentBuilder::new()
|
||||
.provider(provider)
|
||||
.tool_registry(registry)
|
||||
.hook_executor(executor)
|
||||
.summary_config(SummaryConfig {
|
||||
max_context_tokens: 100,
|
||||
trigger_token_ratio: 0.5,
|
||||
debounce_turns: 2,
|
||||
..Default::default()
|
||||
})
|
||||
.build()?
|
||||
));
|
||||
session.submit_turn("msg 1").await?;
|
||||
// ... submit_turn 多次直到 token 超 50 ...
|
||||
// 第 N 轮:摘要自动生成
|
||||
let summary = session.get_session_data("conversation_summary").await?;
|
||||
assert!(summary.is_some());
|
||||
// Focused 模式下 load_messages 包含摘要
|
||||
```
|
||||
|
||||
**场景 2:不启用时零影响**
|
||||
|
||||
```rust
|
||||
let session = AgentSession::new(agent, "session-2", bundle); // 无 summary_config
|
||||
for i in 0..50 {
|
||||
session.submit_turn(&format!("msg {}", i)).await?;
|
||||
}
|
||||
// 没有摘要产生,没有额外的 LLM 调用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 参考来源
|
||||
|
||||
- Phase 10 方案文档:`docs/17-phase10-contextslot.md`(§5 FocusedConfig 消费端设计)
|
||||
- Phase 14 方案文档:`docs/20-phase14-document-and-embedding.md`(Provider 复用模式)
|
||||
- 当前代码:`src/agent/session.rs`(submit_turn 流程,OnTurnEnd 位置)
|
||||
- 当前代码:`src/llm/cycle.rs`(submit_messages 签名)
|
||||
- 当前代码:`src/agent/context.rs`(FocusedConfig.summary_override + filter_focused 消费逻辑)
|
||||
- 当前代码:`src/agent/runtime.rs`(AgentConfig 结构)
|
||||
- 当前代码:`src/agent/builder.rs`(Builder 链式模式)
|
||||
- 当前代码:`src/agent/session_memory.rs`(set/get API)
|
||||
@@ -0,0 +1,775 @@
|
||||
# Phase 17 — Agent 执行引擎
|
||||
|
||||
- **文档编号**:23
|
||||
- **标题**:Phase 17 — Agent 执行引擎(Engine)
|
||||
- **日期**:2026-07-15
|
||||
- **状态**:**审查修复完成,待第二轮复审**
|
||||
- **涉及模块**:`engine/`(新建,含 `session_manager` / `checkpointer` / `snapshot` / `error`)、`agent/session`、`agent/context`、`llm/types/usage`
|
||||
- **关联文档**:`docs/17-phase10-contextslot.md`、`docs/22-phase16-summary-auto-generation.md`、`docs/roadmap.md`
|
||||
- **审查记录**:第 1 轮 PM Director + SA Director 审查 → 6 🔴 阻塞问题,全部修复。详见 §变更记录。
|
||||
|
||||
---
|
||||
|
||||
## 背景与目标
|
||||
|
||||
### 问题
|
||||
|
||||
agcore v0.3.0 开发中,已完成 Phase 13-16(Phase 0-12 全部完成)。当前测试 353 个,全部通过,clippy 0 警告。
|
||||
|
||||
当前 `AgentSession` 存在以下空白:
|
||||
|
||||
1. **Session 在变量中**:`AgentSession` 实例仅在内存中存在,无法通过 session ID 从存储恢复
|
||||
2. **无父子关系**:session 之间相互独立,无法表达"子会话继承父会话"的树形关系
|
||||
3. **无检查点**:无法在任意时刻给 session 拍快照,出错后无法回滚到历史状态
|
||||
4. **不可序列化**:`AgentSession` 持有 `Arc<dyn Agent>` 和 `Arc<RuntimeBundle>`,无法直接序列化持久化
|
||||
|
||||
### 目标
|
||||
|
||||
建立 `engine/` 模块,补齐 session 生命周期的管理能力。具体包括:
|
||||
|
||||
1. **Session 工厂 + 按 ID 恢复**:`SessionManager::create()` / `get()`,session 创建后可通过 ID 从存储重建
|
||||
2. **父子 session 树形关系**:`create_child()` / `children()` / `parent()`,支持树形会话拓扑
|
||||
3. **生命周期管理**:`destroy()` 清理 session 及其存储记录
|
||||
4. **Time-travel Checkpointer**:`checkpoint()` / `rollback()` / `list_checkpoints()`,支持任意时刻状态快照与回滚
|
||||
5. **序列化支持**:通过 `SessionSnapshot` 独立 struct 间接实现 `AgentSession` 的快照持久化
|
||||
|
||||
### 成功标准
|
||||
|
||||
1. Session 创建后可通 ID 从存储恢复(`get()` 返回完整状态的 `AgentSession`)
|
||||
2. 父子 session 关系可查询(`children()` / `parent()`),数据正确隔离
|
||||
3. Checkpoint 拍快照后可完全恢复到该时刻状态(turn_index、cost_so_far、slots 一致)
|
||||
4. 零新外部依赖,全量测试 353 → ~385-390
|
||||
5. `cargo test --all-targets` 全绿,`cargo clippy` 0 警告
|
||||
|
||||
---
|
||||
|
||||
## 当前状态分析
|
||||
|
||||
### 模块现状
|
||||
|
||||
| 模块 | 文件 | 状态 | 与 Phase 17 的关系 |
|
||||
|------|------|------|-------------------|
|
||||
| `AgentSession` | `agent/session.rs` | ✅ 已实现 | 需扩展 `to_snapshot()` / `from_snapshot()` |
|
||||
| `ContextSlot` | `agent/context.rs` | ✅ 已实现(持久化、fork/merge/save/load) | 需加 `Serialize` / `Deserialize` derive |
|
||||
| `CostTracker` | `llm/types/usage.rs` | ✅ 已实现 | 需加 `Clone` + `Serialize` / `Deserialize` derive |
|
||||
| `MergeStrategy` | `agent/context.rs` | ✅ 已实现 | 需加 `Serialize` / `Deserialize` derive |
|
||||
| `MemoryStore` trait | `memory/store.rs` | ✅ 已实现 | Checkpointer 的存储后端 |
|
||||
| `RuntimeBundle` | `agent/runtime.rs` | ✅ 已实现(依赖注入容器) | `from_snapshot()` 需注入 `agent` 和 `bundle` |
|
||||
| `InMemoryStore` | `memory/store.rs` | ✅ 已实现 | 测试用存储后端 |
|
||||
| `SqliteStore` | `memory/sqlite_store.rs` | ✅ 已实现(Phase 7) | 生产环境存储后端 |
|
||||
| `Message` | `llm/types/message.rs` | ✅ 已有 `Serialize` / `Deserialize` | 可直接序列化 |
|
||||
|
||||
### AgentSession 关键字段
|
||||
|
||||
```rust
|
||||
pub struct AgentSession {
|
||||
pub session_id: String,
|
||||
pub agent: Arc<dyn Agent>, // ❌ 不可序列化
|
||||
bundle: Arc<RuntimeBundle>, // ❌ 不可序列化
|
||||
turn_index: u32, // ✅ 可序列化
|
||||
cost_so_far: CostTracker, // ⚠️ 需加 derive
|
||||
pub session_memory: SessionMemory, // ⚠️ 间接序列化
|
||||
slots: HashMap<String, ContextSlot>, // ⚠️ 需加 derive
|
||||
current_slot_id: String, // ✅ 可序列化
|
||||
last_summary_turn: Option<u32>, // ✅ 可序列化
|
||||
}
|
||||
```
|
||||
|
||||
核心制约:`Arc<dyn Agent>` 和 `Arc<RuntimeBundle>` 无法 `Serialize` / `Deserialize`,必须通过独立 snapshot struct + 外部注入重建。
|
||||
|
||||
### 关键假设(设计分析 — 需实施后验证)
|
||||
|
||||
以下假设在方案设计中做出,标注验证方式。实施 Step 1-3 后应逐项确认。
|
||||
|
||||
| # | 假设 | 验证方式 |
|
||||
|---|------|---------|
|
||||
| 1 | `submit_turn_stream` 内部 `tokio::spawn` 不持有 `&mut self` → 可通过 `Arc<Mutex<AgentSession>>` 安全共享 | 代码审查覆盖 `submit_with_tools_stream` → `run_tool_loop` 的 spawn 捕获列表;确认所有捕获变量为 owned 数据 |
|
||||
| 2 | `CostTracker` 加 `Clone` 不破坏现有代码 | 编译验证(`cargo build --all-targets`);检查 `CostTracker` 的所有消费方(`session.rs` 中只读引用) |
|
||||
| 3 | `ContextSlot` 加 `Serialize` / `Deserialize` 不影响现有 `save` / `load` 路径 | 现有 `save()` 直接序列化 `self.messages` / `self.meta` / `self.config`,不走 `ContextSlot` 整体 serde → 两组路径可共存 |
|
||||
| 4 | `Message` 已有 `Serialize` / `Deserialize` → 可直接嵌套序列化 | 代码确认(`message.rs` L21 已有 derive) |
|
||||
| 5 | `EngineError` 不需要 `derive Serialize` → 纯运行时错误类型 | Checkpoint 只存 `SessionSnapshot`,不存错误枚举 |
|
||||
| 6 | `MemoryStore` 操作是可靠的——失败时返回 `EngineError::Memory` 透传错误 | 当前不内置 store 重试逻辑;调用方负责 retry 或 failover |
|
||||
| 7 | session_id 使用 UUID v4 自动生成,冲突概率可忽略 | 实施确定 ID 生成方案(`uuid::Uuid::new_v4()` 或 时间戳+计数器无依赖方案) |
|
||||
| 8 | session_memory 当前只支持字符串值;未来支持复杂类型时 `SessionMemoryEntry` 的 `value` 字段需改用 `serde_json::Value` | 已预留在注释中 |
|
||||
|
||||
---
|
||||
|
||||
## 调研发现
|
||||
|
||||
### 可选方案对比
|
||||
|
||||
#### 方案 A(推荐):SessionSnapshot + 组合式架构
|
||||
|
||||
**做法**:用一个独立 `SessionSnapshot` struct 存储可序列化状态,避开 `Arc<dyn Agent>` 的序列化限制。`Checkpointer` 作为独立 struct,`SessionManager` 组合持有 `Checkpointer`。
|
||||
|
||||
**优点**:
|
||||
- 不污染 `AgentSession` 主类型,序列化逻辑与运行逻辑分离
|
||||
- `Checkpointer` 独立可测,不依赖 `SessionManager`
|
||||
- 组合关系清晰:`SessionManager` 持有 `Checkpointer`
|
||||
- 所有字段使用 `#[serde(default)]` 宽松反序列化,前向兼容
|
||||
|
||||
**缺点**:
|
||||
- 需要额外同步逻辑:`to_snapshot()` / `from_snapshot()` 双向转换
|
||||
|
||||
#### 方案 B(已否决):直接给 AgentSession derive Serialize
|
||||
|
||||
**做法**:给 `AgentSession` 加 `#[derive(Serialize)]`,用 `#[serde(skip)]` 跳过 `agent` 和 `bundle`。
|
||||
|
||||
**否决原因**:
|
||||
1. `#[serde(skip)]` 跳过了 2 个核心字段,序列化后的结果名不副实
|
||||
2. 技术债重:主类型获得"跳过一半字段"的诡异 serde 行为,未来维护者可能误以为 `AgentSession` 可整体序列化/反序列化
|
||||
3. 反序列化时 `agent` 和 `bundle` 缺失,仍需外部注入 → 不如直接使用独立的 snapshot struct
|
||||
|
||||
#### 方案 C(已否决):Checkpointer 作为 SessionManager 内部方法
|
||||
|
||||
**做法**:将 `checkpoint` / `rollback` 直接作为 `SessionManager` 的方法。
|
||||
|
||||
**否决原因**:
|
||||
1. 违反单一职责原则(SRP):`SessionManager` 承担 session 生命周期 + 检查点管理双重责任
|
||||
2. 破坏独立可测试性:检查点逻辑与 `SessionManager` 耦合
|
||||
3. `rollback` 返回后自动注册到 `SessionManager`,但调用方可能不需要注册
|
||||
4. 应返回 `AgentSession` 让调用方决定如何处理
|
||||
|
||||
### 技术决策清单
|
||||
|
||||
| 编号 | 决策项 | 选择 | 理由 |
|
||||
|------|--------|------|------|
|
||||
| D1 | 序列化方式 | `SessionSnapshot` 独立 struct | 不污染 `AgentSession`,序列化逻辑与运行逻辑分离 |
|
||||
| D2 | 并发模型 | `tokio::sync::Mutex` | 安全跨 `.await`,与 `AgentSession` 现有模式一致 |
|
||||
| D3 | 模块拆分 | `Checkpointer` 独立 + `SessionManager` 组合 | 独立可测,SRP 合规 |
|
||||
| D4 | 存储格式 | 全量 JSON | 简洁可靠,ponytail:>500 轮再优化为增量 |
|
||||
| D5 | Key 命名 | `session:{id}:meta` / `ckpt:{id}:{ckpt_id}` | 与 `slot_data:` 风格一致,prefix 查询友好 |
|
||||
| D6 | Checkpoint 触发 | `SessionManager` 封装方法中自动;同步写入 + `tracing::error!` 记录失败 | `AgentSession` 保持纯净;不提供强持久化保证(显式调 `checkpointer.checkpoint()` 确认) |
|
||||
| D7 | 序列化兼容 | `#[serde(default)]` 宽松 | 防前向破坏,新增字段自动兼容旧快照 |
|
||||
| D8 | 流式 checkpoint 时序 | 仅在 `finalize_turn` 时创建 checkpoint | `submit_turn_stream` 返回流时不做 checkpoint;客户端断开后不留下半成品 checkpoint 污染 |
|
||||
| D9 | `SessionManager` trait | 不需要 | YAGNI,无多后端需求 |
|
||||
| D10 | `CostTracker` / `ContextSlot` / `MergeStrategy` derive | 加 `Clone` + `Serialize` / `Deserialize` | 共约 7 行改动,支持快照序列化 |
|
||||
|
||||
### MVP 范围
|
||||
|
||||
| 做(Phase 17 首批) | 推迟 |
|
||||
|---------------------|------|
|
||||
| ① `SessionManager`: `create` / `get` / `create_child` / `children` / `parent` / `destroy` / `replace` / `recover` | ① `destroy_subtree` — 首次只做单节点 `destroy`。父被销毁后子 session 的 `parent()` 返回 `None`(允许孤儿)。调用方如需级联删除应自行遍历。 |
|
||||
| ② `Checkpointer`: `checkpoint` / `rollback` / `list_checkpoints` / `delete_all` | ② `tree()` — `children()` + `parent()` 组合查询在 v0.3 够用;Phase 18 SubAgent Dispatch 需要全量树快照时再补。 |
|
||||
| ③ `SessionSnapshot` + `to_snapshot()` / `from_snapshot()`(位于 `engine/snapshot.rs`)+ `restore_memory()` | ③ `Checkpointer::fork` — 推迟理由:`fork` 底层可拆解为 `rollback` + `create_child`,当前 Checkpointer + SessionManager 已提供原始能力。`fork` 作为高层 API 等价于约 30 行组合代码,风险可控延后到 Phase 18。若产品认为 fork 是 time-travel MVP 的必要项,可重新划入 Phase 17。 |
|
||||
| ④ `EngineError`(含 `MemoryError` 透传) | |
|
||||
| ⑤ 涉及的 derive 改动(`CostTracker` + `ContextSlot` + `MergeStrategy`) | |
|
||||
|
||||
**变更记录**(审查修复):
|
||||
- `create()` / `create_child()` 返回类型改为 `Result<String, EngineError>`
|
||||
- `get()` 改为仅内存查询,新增 `recover()` 显式恢复方法
|
||||
- 新增 `replace()` 方法支持 rollback 后无缝切换
|
||||
- MVP 推迟列补充 `tree()`(含推迟理由)、完善 `destroy_subtree`(定义孤儿语义)、
|
||||
补充 `fork` 推迟理由(含技术拆解和产品权衡)
|
||||
|
||||
---
|
||||
|
||||
## 推荐方案
|
||||
|
||||
### 架构概览
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Engine │
|
||||
│ ┌────────────────┐ ┌──────────────────┐ │
|
||||
│ │ SessionManager │──│ Checkpointer │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ create() │ │ checkpoint() │ │
|
||||
│ │ get() │ │ rollback() │ │
|
||||
│ │ create_child() │ │ list_checkpoints│ │
|
||||
│ │ children() │ │ │ │
|
||||
│ │ parent() │ └──────────────────┘ │
|
||||
│ │ destroy() │ │
|
||||
│ └────────┬───────┘ │
|
||||
│ │ 组合 │
|
||||
│ │ 持有 │
|
||||
│ ▼ │
|
||||
│ ┌────────────────┐ │
|
||||
│ │ MemoryStore │ ── 存储后端 │
|
||||
│ └────────────────┘ │
|
||||
└──────────────────────────────────────────────┘
|
||||
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ SessionSnapshot │ ── 可序列化的状态快照
|
||||
│ (to/from │
|
||||
│ AgentSession) │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
### 模块划分
|
||||
|
||||
**新增文件**(5 个):
|
||||
|
||||
```
|
||||
src/engine/
|
||||
├── mod.rs # 约 30 行:模块根 + pub use 重导出
|
||||
├── session_manager.rs # 约 300 行:SessionManager 实现(含 replace/recover)
|
||||
├── checkpointer.rs # 约 220 行:Checkpointer 实现
|
||||
├── snapshot.rs # 约 50 行:SessionSnapshot + SessionMemoryEntry 定义
|
||||
└── error.rs # 约 70 行:EngineError 枚举
|
||||
```
|
||||
|
||||
**修改文件**(5 个):
|
||||
|
||||
| 文件 | 改动量 | 内容 |
|
||||
|------|--------|------|
|
||||
| `src/agent/session.rs` | +~80 行 | `to_snapshot()` / `from_snapshot()` / `restore_memory()` |
|
||||
| `src/agent/context.rs` | +4 行 | `ContextSlot` + `MergeStrategy` 加 `Serialize` / `Deserialize` |
|
||||
| `src/llm/types/usage.rs` | +3 行 | `CostTracker` 加 `Clone` + `Serialize` / `Deserialize` |
|
||||
| `src/lib.rs` | +2 行 | `pub mod engine` 声明 |
|
||||
| `examples/engine_demo.rs` | +~100 行(新增) | 端到端示例(含 rollback + replace 流程) |
|
||||
|
||||
### SessionSnapshot(位于 `engine/snapshot.rs`)
|
||||
|
||||
设计决策:`SessionSnapshot` 是 engine 层为持久化引入的序列化 DTO,定义在 `engine/snapshot.rs` 而非 `agent/session.rs`,保持依赖方向为 `engine → agent`。
|
||||
|
||||
```rust
|
||||
/// SessionMemory 条目的可序列化形式(保留元数据与时间戳)。
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct SessionMemoryEntry {
|
||||
pub value: String,
|
||||
#[serde(default)]
|
||||
pub metadata: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<i64>, // Unix 时间戳秒;Option 兼容旧快照
|
||||
}
|
||||
|
||||
/// AgentSession 的可序列化快照。
|
||||
///
|
||||
/// 不持有 `Arc<dyn Agent>` 和 `Arc<RuntimeBundle>` —— 这两个由调用方在
|
||||
/// `from_snapshot()` 时注入。所有字段使用 `#[serde(default)]` 确保前向兼容。
|
||||
///
|
||||
/// **变更记录**(审查修复):
|
||||
/// - 位置从 `agent/session.rs` 移至 `engine/snapshot.rs`
|
||||
/// - `session_memory_data` 从 `HashMap<String, String>` 改为 `HashMap<String, SessionMemoryEntry>`
|
||||
/// 保留 metadata 和 created_at,避免恢复后时间戳丢失
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub(crate) struct SessionSnapshot {
|
||||
pub session_id: String,
|
||||
pub agent_name: String,
|
||||
pub turn_index: u32,
|
||||
#[serde(default)]
|
||||
pub cost_so_far: CostTracker,
|
||||
#[serde(default)]
|
||||
pub slots: HashMap<String, ContextSlot>,
|
||||
pub current_slot_id: String,
|
||||
pub last_summary_turn: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub session_memory_data: HashMap<String, SessionMemoryEntry>,
|
||||
}
|
||||
```
|
||||
|
||||
### AgentSession 扩展方法
|
||||
|
||||
```rust
|
||||
impl AgentSession {
|
||||
/// 将当前状态拍平为 SessionSnapshot。
|
||||
///
|
||||
/// **需要 async**:因为 session_memory 的数据存储在 `MemoryStore` 中,读取需要异步 I/O。
|
||||
/// 可通过 `SessionMemory::list_entries()` 获取完整条目(含 metadata/created_at):
|
||||
///
|
||||
/// ```ignore
|
||||
/// let entries = self.session_memory.list_entries().await?;
|
||||
/// for (key, value, metadata, created_at) in entries {
|
||||
/// map.insert(key, SessionMemoryEntry { value, metadata, created_at: Some(created_at) });
|
||||
/// }
|
||||
/// ```
|
||||
/// `from_snapshot` 保持同步(构造器不应做 I/O),`to_snapshot` 做 async(快照输出可 I/O)—
|
||||
/// 两个方向不矛盾,设计上各自成立。
|
||||
pub async fn to_snapshot(&self) -> SessionSnapshot {
|
||||
// 拍平 session_memory → HashMap<String, SessionMemoryEntry>(通过 list_entries)
|
||||
// 复制 slots / cost_so_far / turn_index 等可序列化字段
|
||||
}
|
||||
|
||||
/// 从 SessionSnapshot + agent + bundle 重建 AgentSession。
|
||||
///
|
||||
/// **纯同步重建**:只做内存数据结构恢复(slots/turn_index/cost_so_far 等),
|
||||
/// 不执行任何 I/O。session_memory 的持久层恢复由 `restore_memory()` 完成。
|
||||
///
|
||||
/// 调用方负责:
|
||||
/// - 提供与 `agent_name` 对应的 `Arc<dyn Agent>`
|
||||
/// - 提供合法的 `Arc<RuntimeBundle>`
|
||||
///
|
||||
/// 返回 `Result` 以传播序列化反序列化错误(如 JSON 格式不兼容)。
|
||||
pub fn from_snapshot(
|
||||
snapshot: SessionSnapshot,
|
||||
agent: Arc<dyn Agent>,
|
||||
bundle: Arc<RuntimeBundle>,
|
||||
) -> Result<Self, EngineError> {
|
||||
// session_memory_data 存入临时字段(不写 store)
|
||||
// 重建 slots HashMap
|
||||
// 恢复 turn_index / cost_so_far / last_summary_turn
|
||||
}
|
||||
|
||||
/// 将 snapshot 中的 session_memory_data 写回持久层。
|
||||
/// 从 `from_snapshot()` 中剥离的异步操作,调用方显式 await。
|
||||
/// 放置在 `restore_memory` 而非构造函数中,确保构造函数是纯同步的。
|
||||
///
|
||||
/// **错误处理**:逐条写入,某条失败时返回 Err 但不回滚已写入的条目。
|
||||
/// 调用方可选择重试或忽略(不影响 AgentSession 内存状态)。
|
||||
pub async fn restore_memory(&self) -> Result<(), EngineError>;
|
||||
}
|
||||
```
|
||||
|
||||
**标准使用流程**:
|
||||
```rust
|
||||
// rollback:四步走
|
||||
let snapshot = cp.rollback_load(session_id, ckpt_id).await?; // ① 从存储读
|
||||
let session = AgentSession::from_snapshot(snapshot, agent, bundle)?; // ② 同步重建
|
||||
session.restore_memory().await?; // ③ 恢复持久层
|
||||
sm.replace(session_id, session).await?; // ④ 注册到 Manager
|
||||
```
|
||||
|
||||
**checkpoint 流程**(自动或显式调用):
|
||||
```rust
|
||||
// checkpoint 内部:
|
||||
let snapshot = session.to_snapshot().await; // async:从 MemoryStore 读取 session_memory
|
||||
cp.save(snapshot).await?;
|
||||
```
|
||||
|
||||
### Checkpointer 公开 API
|
||||
|
||||
```rust
|
||||
/// 检查点元数据。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CkptMeta {
|
||||
pub ckpt_id: String,
|
||||
pub session_id: String,
|
||||
pub turn_index: u32,
|
||||
pub created_at: u64, // Unix 时间戳,秒
|
||||
}
|
||||
|
||||
/// Time-travel 检查点管理器。
|
||||
///
|
||||
/// **不依赖 SessionManager**,可独立使用。直接操作 MemoryStore。
|
||||
/// 存储 key 格式:`ckpt:{session_id}:{ckpt_id}` → SessionSnapshot JSON
|
||||
pub struct Checkpointer {
|
||||
store: Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
impl Checkpointer {
|
||||
/// 创建新检查点。返回 ckpt_id。
|
||||
pub async fn checkpoint(&self, session: &AgentSession) -> Result<String, EngineError>;
|
||||
|
||||
/// 回滚到指定检查点。返回恢复后的 AgentSession。
|
||||
///
|
||||
/// 调用方需提供 `agent` 和 `bundle`(与 SessionSnapshot 反序列化的要求一致)。
|
||||
/// rollback 不自动注册到任何 SessionManager——调用方决定如何处理返回的 session。
|
||||
pub async fn rollback(
|
||||
&self,
|
||||
session_id: &str,
|
||||
ckpt_id: &str,
|
||||
agent: Arc<dyn Agent>,
|
||||
bundle: Arc<RuntimeBundle>,
|
||||
) -> Result<AgentSession, EngineError>;
|
||||
|
||||
/// 列出某 session 的所有检查点(按创建时间降序)。
|
||||
pub async fn list_checkpoints(&self, session_id: &str)
|
||||
-> Result<Vec<CkptMeta>, EngineError>;
|
||||
|
||||
/// 删除某 session 的所有检查点(session 被 destroy 时调用)。
|
||||
pub async fn delete_all(&self, session_id: &str) -> Result<(), EngineError>;
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:`Checkpointer::fork()` 推迟到 Phase 18(详见 MVP 范围表)。
|
||||
|
||||
**关于 Checkpointer 的独立可用性**:Snapshot 数据的读写(`checkpoint` / `list_checkpoints`)不依赖 SessionManager,可直接用 `Checkpointer` 操作 MemoryStore。但 `rollback()` 重建 AgentSession 需要调用方提供与 session_id 匹配的 `Arc<dyn Agent>` 和 `Arc<RuntimeBundle>`——调用方需自行管理 agent→session 的映射(或通过 `SessionMeta.agent_name` 查询注册表)。
|
||||
|
||||
### SessionManager 公开 API
|
||||
|
||||
```rust
|
||||
/// SessionManager 配置。
|
||||
pub struct SessionManagerConfig {
|
||||
/// 每次 submit_turn 后是否自动 checkpoint(默认 true)。
|
||||
pub auto_checkpoint: bool,
|
||||
/// 默认 RuntimeBundle,用于从存储重建 session 时的 bundle 注入。
|
||||
/// 如果为 None,`recover()` 需要调用方手动传入 bundle。
|
||||
pub default_bundle: Option<Arc<RuntimeBundle>>,
|
||||
}
|
||||
|
||||
impl Default for SessionManagerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_checkpoint: true,
|
||||
default_bundle: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session 生命周期管理器。
|
||||
///
|
||||
/// 组合持有 Checkpointer,提供 session 的 CRUD、树形关系查询和自动检查点。
|
||||
/// 内部用 `HashMap<String, Arc<tokio::sync::Mutex<AgentSession>>>` 管理活跃 session。
|
||||
/// 存储 key 格式:`session:{session_id}:meta` → SessionMeta JSON
|
||||
///
|
||||
/// **锁契约**:
|
||||
/// - 所有写操作(create/destroy/replace)内部先完成 HashMap 操作,释放 RwLock 后再调用
|
||||
/// Checkpointer/MemoryStore 的异步 I/O。调用方不应假设某个操作持有跨 .await 点的锁。
|
||||
/// - `get()` 返回 `Arc<Mutex<AgentSession>>` 后立即释放 RwLock 读锁,调用方持有的是
|
||||
/// session 级别的 Mutex 锁而非管理器级别的锁。
|
||||
pub struct SessionManager {
|
||||
sessions: RwLock<HashMap<String, Arc<tokio::sync::Mutex<AgentSession>>>>,
|
||||
checkpointer: Checkpointer,
|
||||
store: Arc<dyn MemoryStore>,
|
||||
config: SessionManagerConfig,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
/// 创建新 session。session_id 由内部自动生成(UUID v4)。
|
||||
/// 持久化 SessionMeta 后注册到 sessions HashMap。
|
||||
pub async fn create(
|
||||
&self,
|
||||
agent: Arc<dyn Agent>,
|
||||
bundle: Arc<RuntimeBundle>,
|
||||
) -> Result<String, EngineError>;
|
||||
|
||||
/// 从父 session 创建子 session(继承父的 RuntimeBundle,Arc::clone 共享引用)。
|
||||
/// session_id 由内部自动生成(UUID v4)。
|
||||
/// 如果 `parent_id` 不存在,返回 `EngineError::SessionNotFound(parent_id)`。
|
||||
pub async fn create_child(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
agent: Arc<dyn Agent>,
|
||||
) -> Result<String, EngineError>;
|
||||
|
||||
/// 按 ID 获取 session(仅查内存,不自动从存储恢复)。
|
||||
/// 冷启动时 `get()` 未命中返回 `EngineError::SessionNotFound`。
|
||||
/// 如需从存储恢复,使用 `recover()` 方法。
|
||||
pub async fn get(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<Arc<tokio::sync::Mutex<AgentSession>>, EngineError>;
|
||||
|
||||
/// 从存储恢复 session。需要调用方提供 agent 和 bundle(与 SessionSnapshot
|
||||
/// 反序列化的要求一致)。
|
||||
/// 恢复后自动注册到 sessions HashMap(与 create 的行为一致)。
|
||||
pub async fn recover(
|
||||
&self,
|
||||
session_id: &str,
|
||||
agent: Arc<dyn Agent>,
|
||||
bundle: Arc<RuntimeBundle>,
|
||||
) -> Result<Arc<tokio::sync::Mutex<AgentSession>>, EngineError>;
|
||||
|
||||
/// 替换 SessionManager 中指定 session_id 的 AgentSession 实例。
|
||||
/// 用于 Checkpointer::rollback() 后的无缝切换:
|
||||
/// ```ignore
|
||||
/// let rolled_back = cp.rollback(sid, ckpt_id, agent.clone(), bundle.clone()).await?;
|
||||
/// sm.replace(sid, rolled_back).await?;
|
||||
/// ```
|
||||
/// 内部执行:内存替换 + 写回 SessionMeta。
|
||||
pub async fn replace(
|
||||
&self,
|
||||
session_id: &str,
|
||||
session: AgentSession,
|
||||
) -> Result<(), EngineError>;
|
||||
|
||||
/// 查询某 parent 的所有直接子 session 的 ID 列表。
|
||||
pub async fn children(&self, parent_id: &str) -> Result<Vec<String>, EngineError>;
|
||||
|
||||
/// 查询某 child session 的 parent ID。
|
||||
/// 如果 parent 已被销毁,返回 `Ok(None)`(允许孤儿 session 存在)。
|
||||
pub async fn parent(&self, child_id: &str) -> Result<Option<String>, EngineError>;
|
||||
|
||||
/// 销毁 session:从内存移除 + 清理 SessionMeta + 清理检查点。
|
||||
///
|
||||
/// **父子关系处理**:允许孤儿 session 存在(子 session 的 parent_id 仍指向已删除的父,
|
||||
/// 但 `parent()` 返回 `None`)。不递归删除子 session——调用方如需级联删除应自行遍历。
|
||||
pub async fn destroy(&self, session_id: &str) -> Result<(), EngineError>;
|
||||
|
||||
/// 暴露 Checkpointer 引用(调用方可直接操作检查点)。
|
||||
pub fn checkpointer(&self) -> &Checkpointer;
|
||||
}
|
||||
```
|
||||
|
||||
**变更记录**(审查修复):
|
||||
- `create()` 返回类型从 `String` 改为 `Result<String, EngineError>`
|
||||
- `create()` / `create_child()` session_id 统一为内部自动生成(UUID v4)
|
||||
- `get()` 改为"仅查内存",新增 `recover()` 显式恢复方法
|
||||
- 新增 `replace()` 方法支持 rollback 后的无缝替换
|
||||
- `destroy()` 明确孤儿策略:允许孤儿存在,不递归删除
|
||||
- `create_child()` 不再接受 `child_id` 参数(统一自动生成)
|
||||
- 锁契约明确化为 struct doc comment
|
||||
- `SessionManagerConfig` 新增 `default_bundle` 字段为后续扩展预留
|
||||
|
||||
### SessionMeta
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct SessionMeta {
|
||||
pub session_id: String,
|
||||
pub agent_name: String,
|
||||
pub parent_id: Option<String>,
|
||||
pub created_at: u64, // Unix 时间戳,秒
|
||||
pub turn_count: u32,
|
||||
}
|
||||
```
|
||||
|
||||
### 存储 Key 命名
|
||||
|
||||
| Key 模式 | 内容 | 说明 |
|
||||
|----------|------|------|
|
||||
| `session:{session_id}:meta` | `SessionMeta` JSON | session 元数据,含 parent_id |
|
||||
| `ckpt:{session_id}:{ckpt_id}` | `SessionSnapshot` JSON | 全量检查点,含 slots |
|
||||
|
||||
风格与 `ContextSlot` 的 `slot_data:{session_id}:{slot_id}` 一致:`前缀:session_id:后缀`。
|
||||
|
||||
**关于两种持久化路径共存**:`ContextSlot::save()`(增量消息持久化)和 `Checkpointer::checkpoint()`(全量快照)是互补的"增量基线 vs 全量备份"关系:
|
||||
- `ContextSlot::save()` 每轮追加消息到 slot 存储(增量),是进程重启后消息不丢的基线
|
||||
- `Checkpointer::checkpoint()` 全量序列化 session 状态(含所有 slot 消息),是 time-travel 回滚的快照
|
||||
- rollback 时优先使用 checkpoint 的 snapshot 数据(一致性保证),不依赖 slot 持久化中的消息状态
|
||||
|
||||
### EngineError
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum EngineError {
|
||||
/// 指定 session_id 不存在。
|
||||
/// 适用场景:get() 内存未命中、create_child() parent 不存在、destroy() 操作不存在的 session。
|
||||
#[error("Session not found: {0}")]
|
||||
SessionNotFound(String),
|
||||
|
||||
/// 创建 session 时 ID 已存在(自动生成 ID 时通常不会触发)。
|
||||
#[error("Session already exists: {0}")]
|
||||
SessionAlreadyExists(String),
|
||||
|
||||
/// 指定 ckpt_id 不存在。
|
||||
#[error("Checkpoint not found: {0}")]
|
||||
CheckpointNotFound(String),
|
||||
|
||||
/// 存储错误(透传 MemoryError)。
|
||||
/// Checkpointer 和 SessionManager 的所有 MemoryStore 操作通过此变体传播错误。
|
||||
/// 与项目既有模式一致(对比 AgentError:直接 #[from] LlmError/ToolError/MemoryError)。
|
||||
#[from]
|
||||
#[error("存储错误: {0}")]
|
||||
Memory(#[from] MemoryError),
|
||||
|
||||
/// 序列化/反序列化失败(serde_json/snapshot 格式错误)。
|
||||
#[error("序列化错误: {0}")]
|
||||
Serialization(String),
|
||||
|
||||
/// Agent 错误(透传 AgentError)。
|
||||
#[from]
|
||||
#[error("Agent 错误: {0}")]
|
||||
Agent(#[from] AgentError),
|
||||
}
|
||||
```
|
||||
|
||||
### 并发模型
|
||||
|
||||
`SessionManager` 内部使用 `tokio::sync::RwLock` 保护 `sessions: HashMap`:
|
||||
|
||||
```rust
|
||||
pub struct SessionManager {
|
||||
sessions: RwLock<HashMap<String, Arc<tokio::sync::Mutex<AgentSession>>>>,
|
||||
// ... 其他字段
|
||||
}
|
||||
```
|
||||
|
||||
- `RwLock` 适合读多写少的场景(`get()` 高频 > `create()` / `destroy()`)
|
||||
- `get()` 返回 `Arc<Mutex<AgentSession>>` 后立即释放 RwLock 读锁,调用方持有的是 session 级别的 Mutex 锁而非管理器级别的锁。**不持有 RwLock 跨越 .await**
|
||||
- 所有写操作(`create`/`destroy`/`replace`)先完成 HashMap 操作(持有写锁),释放 RwLock 后再调用 Checkpointer/MemoryStore 的异步 I/O
|
||||
- 返回的 `AgentSession` 用 `Arc<tokio::sync::Mutex<AgentSession>>` 包裹,支持跨 `.await` 的安全可变访问
|
||||
- `Checkpointer` 无锁(纯函数式操作 MemoryStore,依赖其内部实现)
|
||||
|
||||
---
|
||||
|
||||
## 实施建议
|
||||
|
||||
### 阶段划分(共 7 步)
|
||||
|
||||
```
|
||||
Step 1: 前置 derive 改动 → step-1-branch
|
||||
Step 2: EngineError + 模块骨架 → step-2-branch
|
||||
Step 3: SessionSnapshot + 扩展 → step-3-branch
|
||||
Step 4: Checkpointer → step-4-branch
|
||||
Step 5: SessionManager → step-5-branch
|
||||
Step 6: 自动 checkpoint 集成 → step-6-branch
|
||||
Step 7: 示例 + 测试补强 → step-7-branch
|
||||
```
|
||||
|
||||
#### Step 1:前置 derive 改动
|
||||
|
||||
- **文件**:`src/llm/types/usage.rs`、`src/agent/context.rs`(×2)
|
||||
- **内容**:
|
||||
- `CostTracker`:`#[derive(Debug, Default)]` → `#[derive(Debug, Default, Clone, Serialize, Deserialize)]`
|
||||
- `ContextSlot`:`#[derive(Debug, Clone)]` → `#[derive(Debug, Clone, Serialize, Deserialize)]`
|
||||
- `MergeStrategy`:`#[derive(Debug, Clone)]` → `#[derive(Debug, Clone, Serialize, Deserialize)]`
|
||||
- **验证**:`cargo build --all-targets` 编译通过
|
||||
|
||||
#### Step 2:EngineError + 模块骨架
|
||||
|
||||
- **文件**:
|
||||
- `src/engine/error.rs`(新增):`EngineError` 枚举定义
|
||||
- `src/engine/mod.rs`(新增):模块根声明 + `pub use` 重导出 `EngineError` / `SessionManager` / `Checkpointer` / `CkptMeta`
|
||||
- `src/lib.rs`(修改):加 `pub mod engine;`
|
||||
- **验证**:`cargo build --all-targets && cargo clippy --all-targets -- -D warnings`
|
||||
|
||||
#### Step 3:SessionSnapshot + AgentSession 扩展
|
||||
|
||||
- **文件**:`src/engine/snapshot.rs`(新增,来自 SA 审查建议)、`src/agent/session.rs`
|
||||
- **内容**:
|
||||
- `src/engine/snapshot.rs`:`SessionMemoryEntry` 结构体(含 `value`/`metadata`/`created_at`)、`SessionSnapshot` 结构体定义(`pub(crate)`)
|
||||
- `src/agent/session.rs`:`pub async fn to_snapshot(&self) -> SessionSnapshot`(**异步**,通过 `SessionMemory::list_entries()` 读取完整 session_memory 条目,复制 slots/cost_so_far/各标量字段)
|
||||
- `pub fn from_snapshot(snapshot, agent, bundle) -> Result<Self, EngineError>`(**纯同步**,不写 store;session_memory_data 暂存于内存,不写入持久层)
|
||||
- `pub async fn restore_memory(&self) -> Result<(), EngineError>`(异步,将 from_snapshot 暂存的 session_memory_data 写回持久层;逐条写入,失败时记录 error 但不回滚已写入条目)
|
||||
- `SessionMemory` 新增 `list_entries()` 方法返回 `Vec<(String, String, serde_json::Value, i64)>`(含 value/metadata/created_at),供 `to_snapshot` 消费
|
||||
- **验证**:单元测试 roundtrip(`to_snapshot().await` → `from_snapshot()` → 关键字段一致);`restore_memory` 幂等性测试
|
||||
|
||||
#### Step 4:Checkpointer
|
||||
|
||||
- **文件**:`src/engine/checkpointer.rs`(新增)
|
||||
- **内容**:
|
||||
- `Checkpointer` 结构体(持有 `Arc<dyn MemoryStore>`)
|
||||
- `CkptMeta` 结构体
|
||||
- `checkpoint()`:生成 ckpt_id(时间戳+计数器方案优先,ponytail;`uuid` 备选,需加依赖),`session.to_snapshot()` → JSON → 存 `ckpt:{session_id}:{ckpt_id}`
|
||||
- `rollback_load()`(两阶段 rollback 的第一阶段):读取 JSON → 反序列化为 `SessionSnapshot` → 返回 `SessionSnapshot`
|
||||
- 调用方拿到 `SessionSnapshot` 后,自行调用 `AgentSession::from_snapshot()`(纯同步)+ `restore_memory()`(异步)+ `SessionManager::replace()`(注册)
|
||||
- `list_checkpoints()`:prefix 查询 `ckpt:{session_id}:` → 反序列化 `CkptMeta`(从 snapshot JSON 中提取 `turn_index` / `created_at`)→ 按时间降序
|
||||
- `delete_all()`:prefix 查询 + 逐个删除
|
||||
- **验证**:3-5 个单元测试(checkpoint roundtrip / rollback_load 反序列化正确 / list 排序 / delete_all 幂等性)
|
||||
|
||||
#### Step 5:SessionManager
|
||||
|
||||
- **文件**:`src/engine/session_manager.rs`(新增)
|
||||
- **内容**:
|
||||
- `SessionManagerConfig` 结构体(含 `auto_checkpoint: bool` + `default_bundle: Option<Arc<RuntimeBundle>>`)
|
||||
- `SessionMeta` 结构体(`pub(crate)`)
|
||||
- `SessionManager` 结构体(`RwLock<HashMap<...>>` + `Checkpointer` + `store` + `config`)
|
||||
- `create()`:内部自动生成 session_id(UUID v4),`AgentSession::new()` → 存 `SessionMeta` → 注册到 `sessions` HashMap → `Ok(session_id)`
|
||||
- `create_child()`:验证 parent 存在 → 自动生成 child session_id → 设置 `parent_id` → `create()` 流程
|
||||
- `get()`:**仅查内存**,未命中返回 `SessionNotFound`(不自动从存储恢复)
|
||||
- `recover(session_id, agent, bundle)`:从存储读取 `SessionMeta` + 调 `Checkpointer` 最近 checkpoint → 重建 `AgentSession` → 注册到 HashMap
|
||||
- `replace(session_id, session)`:内存替换(覆盖 Mutex 中的 AgentSession)+ 写回 SessionMeta
|
||||
- `children(parent_id)`:prefix 查询 `session:{parent_id}:` → 过滤 `parent_id` 匹配 → 返回 child_id 列表
|
||||
- `parent(child_id)`:读 `SessionMeta.parent_id`,父已被销毁时返回 `Ok(None)`
|
||||
- `destroy(session_id)`:移除内存记录 → 删除 `SessionMeta` → 调 `Checkpointer::delete_all()`。**允许孤儿 session 存在**(不递归删除子 session)
|
||||
- **验证**:8-10 个单元测试(CRUD / recover 恢复 / replace 替换 / 树形关系 / session 隔离 / destroy 后 get 失败 / 孤儿 parent 返回 None)
|
||||
|
||||
#### Step 6:自动 checkpoint 集成
|
||||
|
||||
- **文件**:`src/engine/session_manager.rs`(扩展)
|
||||
- **内容**:
|
||||
- 在 `SessionManager` 上添加封装方法 `submit_turn(session_id, user_input)`,内部:
|
||||
1. `get(session_id)` 获取 session
|
||||
2. `session.lock().await.submit_turn(user_input).await`
|
||||
3. 如果 `config.auto_checkpoint == true`,同步调用 `checkpointer.checkpoint(&session).await`
|
||||
- checkpoint 失败时通过 `tracing::error!` 记录,不阻断 `submit_turn` 的 `Ok` 返回
|
||||
- 调用方如需强持久化保证,应显式调用 `checkpointer.checkpoint()` 并处理其 `Result`
|
||||
- 流式路径:仅在 `finalize_turn` 时创建 checkpoint(`submit_turn_stream` 返回流时不做 checkpoint)
|
||||
- 客户端断开连接导致 `finalize_turn` 未被调用时,保持上一个 checkpoint 的状态,不留下半成品 checkpoint 污染
|
||||
- `auto_checkpoint` 配置控制开关
|
||||
- **验证**:集成测试(`submit_turn` → `list_checkpoints` 中可查到新 checkpoint);关闭 `auto_checkpoint` 时不产生 checkpoint
|
||||
|
||||
#### Step 7:示例 + 测试补强 + Tracing 埋点
|
||||
|
||||
- **文件**:`examples/engine_demo.rs`(新增,~100 行)
|
||||
- **示例流程**:
|
||||
1. `SessionManager::create` → submit_turn
|
||||
2. `Checkpointer::checkpoint` → list_checkpoints
|
||||
3. `Checkpointer::rollback` + `AgentSession::restore_memory` + `SessionManager::replace`
|
||||
4. 验证回滚后 turn_index 和 cost 恢复到 checkpoint 时刻
|
||||
- **Tracing 埋点**(每个关键操作添加 `tracing` 日志,与项目既有风格一致):
|
||||
- `Checkpointer::checkpoint()` 成功时:`tracing::info!(ckpt_id, turn_index, snapshot_size, "checkpoint created")`
|
||||
- `Checkpointer::rollback()` 成功时:`tracing::info!(ckpt_id, session_id, turn_index, "rolled back")`
|
||||
- `Checkpointer::list_checkpoints` → `tracing::debug!(session_id, count)`
|
||||
- `SessionManager::create` → `tracing::info!(session_id, agent_name, "session created")`
|
||||
- `SessionManager::destroy` → `tracing::info!(session_id, "session destroyed")`
|
||||
- `SessionManager::get` / `recover` / `replace` → `tracing::debug!(session_id, ...)`
|
||||
- 序列化错误 / 存储错误 → `tracing::error!(session_id, error, ...)`
|
||||
- **补充测试**(12-15 个):
|
||||
- 空 slot checkpoint → rollback 后消息为空
|
||||
- Destroy 后再 checkpoint → 返回 `SessionNotFound`
|
||||
- 跨 session 检查点隔离(session A checkpoint 不影响 session B)
|
||||
- 序列化版本兼容(`#[serde(default)]` 兜底:缺少新字段的旧 snapshot 可正常反序列化)
|
||||
- 10 并发 session 创建/销毁(RwLock 写锁争用验证)
|
||||
- 父子 session 消息隔离(子 session 写数据不污染父 session)
|
||||
- `restore_memory` 幂等性(重复调用不产生重复数据)
|
||||
- `from_snapshot` 纯同步验证(检查构造过程中无 async 调用路径)
|
||||
- **验证**:`cargo test --all-targets` 全绿 + `cargo clippy` 0 警告
|
||||
|
||||
### 高层建议
|
||||
|
||||
1. **Step 1 应先行独立提交**:derive 改动可能触发整个 crate 的重新编译,与其他步骤分开可减少冲突
|
||||
2. **`get()` 只查内存,`recover()` 用于存储恢复**:`get()` 不自动从存储重建(因无 `agent`/`bundle` 通道)。冷启动后先 `create()` 再 `get()`,或显式调用 `recover(session_id, agent, bundle)`
|
||||
3. **ckpt_id 生成**:使用 `uuid::Uuid::new_v4()`(需在 `Cargo.toml` `[dependencies]` 中添加 `uuid = { version = "1", features = ["v4"] }`),或走无新增依赖方案:`format!("{}_{}", session_id, timestamp_nanos)` 结合单调计数器。建议优先走无新增依赖方案(ponytail)
|
||||
4. **SessionManager 的 RwLock 粒度**:避免持写锁时调 `checkpointer`(涉及 I/O),锁范围应仅限于 HashMap 操作;`get()` 返回 `Arc` 后立即释放读锁
|
||||
5. **自动 checkpoint 的持久化语义**:自动 checkpoint 采用`同步写入 + tracing::error! 记录失败` 模式(与 Phase 16 `maybe_summarize` 的静默模式一致)。**不提供强持久化保证**——调用方如需确保 checkpoint 成功,应显式调用 `checkpointer.checkpoint()` 并处理其 `Result`
|
||||
6. **ContextSlot 持久化与 Checkpointer 快照的关系**:两者是"增量基线 vs 全量备份"的互补关系。`ContextSlot::save()` 负责每轮追加消息到 slot 存储(增量),`Checkpointer::checkpoint()` 负责全量序列化 session 状态(快照)。rollback 时优先使用 checkpoint 数据(一致性),不依赖 slot 持久化的消息状态
|
||||
7. **`from_snapshot` 后调用 `restore_memory`**:`AgentSession::from_snapshot()` 是纯同步的,不写 store;写回 session_memory 需要显式 `await session.restore_memory()`。三步全流程:`from_snapshot → restore_memory → replace`
|
||||
|
||||
### @Chart 提示
|
||||
|
||||
```
|
||||
flowchart TD
|
||||
subgraph "engine/"
|
||||
SM[SessionManager]
|
||||
CP[Checkpointer]
|
||||
EE[EngineError]
|
||||
end
|
||||
|
||||
subgraph "现有模块"
|
||||
AS[AgentSession]
|
||||
CS[ContextSlot]
|
||||
CT[CostTracker]
|
||||
MS[MemoryStore]
|
||||
end
|
||||
|
||||
SM -->|组合持有| CP
|
||||
SM -->|RwLock 保护| HM[(sessions HashMap)]
|
||||
CP -->|持久化| MS
|
||||
AS -->|to_snapshot| SS[SessionSnapshot]
|
||||
SS -->|from_snapshot| AS
|
||||
|
||||
SM -->|get / create / destroy| AS
|
||||
CP -->|checkpoint / rollback| AS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 变更记录(审查修复)
|
||||
|
||||
| 日期 | 变更 | 触发 |
|
||||
|------|------|------|
|
||||
| 2026-07-15 | **🔴 `to_snapshot(&self)` 从同步改为 `pub async fn`** | SA 第 2 轮审查:同步方法无法 async 读 MemoryStore;需通过 `SessionMemory::list_entries()` 获取完整条目 |
|
||||
| 2026-07-15 | **🔴 `docs/roadmap.md` Phase 17 交付物列表同步更新** | PM 第 2 轮审查:Roadmap 仍使用旧版范围(`tree()`/`fork()`/`destroy_subtree()` 未推迟,`create()` 签名未更新,缺 `recover()`/`replace()`) |
|
||||
| 2026-07-15 | **`SessionMemory::list_entries()` 新增方法** | SA 第 2 轮审查:`to_snapshot` 需要读取完整 entry 数据,现有 API 只返回 `Option<String>` |
|
||||
| 2026-07-15 | **`to_snapshot` 注释清理:移除错误的 Cell/RefCell 方案** | SA 第 2 轮审查:同步方法中无法通过 Cell/RefCell 绕开 async |
|
||||
|
||||
| 日期 | 变更 | 触发 |
|
||||
|------|------|------|
|
||||
| 2026-07-15 | **🔴 `SessionManager::get()` 改为仅查内存,新增 `recover()` 显式恢复方法** | SA 审查:get() "从存储恢复"不可实现(无 agent/bundle 通道) |
|
||||
| 2026-07-15 | **🔴 `from_snapshot()` 改为纯同步构造 + 分离 `restore_memory()` 异步方法;返回 `Result`** | SA 审查:异步 I/O + 返回 Self 导致脏数据 |
|
||||
| 2026-07-15 | **🔴 `session_memory_data` 从 `HashMap<String, String>` 改为 `HashMap<String, SessionMemoryEntry>`** | SA 审查:拍平丢失 metadata/created_at |
|
||||
| 2026-07-15 | **🔴 `EngineError` 新增 `Memory(#[from] MemoryError)` 透传变体** | SA 审查:缺少 MemoryError 透传 |
|
||||
| 2026-07-15 | **🔴 `tree()` 在 MVP 推迟列补充(含推迟理由)** | PM 审查:Roadmap L781 需求完全未提及 |
|
||||
| 2026-07-15 | **🔴 `fork()` 推迟理由补充(技术拆解 + 产品权衡)** | PM 审查:推迟理由不充分 |
|
||||
| 2026-07-15 | **🔴 新增 `SessionManager::replace()` API 支持 rollback 后无缝切换** | PM 审查:rollback 后 session 无法替换到 Manager |
|
||||
| 2026-07-15 | **`SessionSnapshot` 移至 `engine/snapshot.rs`** | SA 审查:DTO 应放在 engine 层,保持依赖方向 engine→agent |
|
||||
| 2026-07-15 | **uuid 依赖修正:改为"时间戳+计数器优先,uuid 备选"** | SA 审查:文档声称"已有依赖"但 Cargo.toml 不含 |
|
||||
| 2026-07-15 | **160KB 具体数字删除(替换为保守上限描述)** | SA 审查:无测量依据 |
|
||||
| 2026-07-15 | **"关键假设(已验证)"改为"设计分析" + 验证方式** | PM 审查:"已验证"字面与实际不符 |
|
||||
| 2026-07-15 | **流式 checkpoint 时序明确定义:仅在 `finalize_turn` 时创建** | PM 审查:时序未定义 |
|
||||
| 2026-07-15 | **自动 checkpoint 语义:`tracing::error!` 模式,非强持久化** | SA 审查:fire-and-forget 不可靠 |
|
||||
| 2026-07-15 | **`create()` 返回 `Result<String, EngineError>` + 统一自动生成 ID** | PM 审查:返回 String 不能表达错误 |
|
||||
| 2026-07-15 | **`destroy()` 明确孤儿策略:允许孤儿,不递归删除,parent() 返回 None** | PM+SA 审查:孤儿语义未定义 |
|
||||
| 2026-07-15 | **`create_child()` 不再接受 `child_id`(统一自动生成)** | PM 审查:ID 策略不一致 |
|
||||
| 2026-07-15 | **`RuntimeBundle` 继承语义补充(`Arc::clone` 共享引用)** | PM 审查:继承语义未定义 |
|
||||
| 2026-07-15 | **并发模型补充 RwLock 锁范围注释** | SA 审查:跨 await 风险缺文档 |
|
||||
| 2026-07-15 | **Checkpointer 独立可用性约束标注** | SA 审查:rollback 重建需要 agent+bundle |
|
||||
| 2026-07-15 | **ContextSlot 与 Checkpointer 两种持久化路径关系补充说明** | SA 审查:共存缺说明 |
|
||||
| 2026-07-15 | **Step 7 扩充:示例流程 + 12-15 个边界测试 + Tracing 埋点规划** | SA 审查:缺 tracing 规划 |
|
||||
| 2026-07-15 | **`SessionManagerConfig` 新增 `default_bundle` 字段** | PM 审查:未来扩展预留 |
|
||||
| 2026-07-15 | **项目文件新增/修改数量同步更新(5 新增 + 5 修改,~725 行)** | 全部审查修复导致文件范围变化 |
|
||||
|
||||
## 参考来源
|
||||
|
||||
- Phase 10 方案文档:`docs/17-phase10-contextslot.md`(ContextSlot 持久化设计,Phase 17 的前置依赖)
|
||||
- Phase 16 方案文档:`docs/22-phase16-summary-auto-generation.md`(上一 Phase 的实施风格参考)
|
||||
- 当前代码:`src/agent/session.rs`(AgentSession 当前实现,`to_snapshot` / `from_snapshot` 扩展点)
|
||||
- 当前代码:`src/agent/context.rs`(ContextSlot 当前实现,derive 改动点)
|
||||
- 当前代码:`src/llm/types/usage.rs`(CostTracker 当前实现,derive 改动点)
|
||||
- 当前代码:`src/lib.rs`(模块注册点)
|
||||
- 当前代码:`src/agent.rs`(模块组织风格参考)
|
||||
@@ -0,0 +1,700 @@
|
||||
# Phase 18:Agent 角色热切换与子代理调度
|
||||
|
||||
## 背景与目标
|
||||
|
||||
### 问题空间
|
||||
|
||||
agcore 已完整交付 Phase 0-17,具备 SessionManager 会话生命周期管理、会话树(父子层次)、Checkpointer 检查点、流式输出、ContextSlot 上下文分区、MemoryStore 持久化、摘要自动生成等能力。当前 Session 在 `create()` 时绑定一个 `Arc<dyn Agent>`,此后无法变更角色;会话间调度仅通过 `create_child()` + `submit_turn()` 手动编排,缺乏内建的子代理派发机制。
|
||||
|
||||
Phase 18 要解决两个正交但关联的问题:
|
||||
|
||||
1. **Agent 角色热切换**:运行时替换 session 绑定的 Agent,保留上下文(slot 历史、turn_index、session_memory、cost_so_far)
|
||||
2. **子代理调度**:在 SessionManager 上提供声明式的 `dispatch` / `dispatch_stream` / `dispatch_all` API,支持父子 session 间的 Memory 继承、bridge_keys 注入、并发控制、结构化回传
|
||||
|
||||
### 目标
|
||||
|
||||
- 提供 `SessionManager::switch_agent(session_id, new_agent)`,替换 `Arc<dyn Agent>`,全量保留 slot / turn_index / session_memory
|
||||
- 提供 `DispatchConfig` / `SubTaskResult` / `SubTaskStreamEvent` 类型以及 `dispatch` / `dispatch_stream` / `dispatch_all` 三个核心方法
|
||||
- 实现父转子三层级交互:父->子(Memory 快照继承 + bridge_keys)、子->父(SubTaskResult 结构化回传 + result_summary)、子<->子(shared namespace)
|
||||
- 产出 4 个端到端示例:`agent_switch_demo` / `sub_agent_dispatch_demo` / `bridge_keys_demo` / `dispatch_stream_demo`
|
||||
|
||||
### 依赖与优先级
|
||||
|
||||
- **依赖**:Phase 17(SessionManager + 会话树 + Checkpointer)[高]
|
||||
- **优先级**:P0
|
||||
- **预估规模**:约 720 行核心 + 210 行测试 + 450 行示例
|
||||
- **审查修复**:第 1 轮审查修复(Finalize 方案重写 + 4 个 🔴 阻塞 + 8 个 🟡 改进)
|
||||
|
||||
---
|
||||
|
||||
## 当前状态分析
|
||||
|
||||
### 现有架构中的关键接入点
|
||||
|
||||
| 接入点 | 位置 | 可用性 | 分析 |
|
||||
|--------|------|--------|------|
|
||||
| `AgentSession.agent` | `src/agent/session.rs:53` | `pub` 字段 | 可直接替换,无需新增 setter [高] |
|
||||
| `AgentSession.session_id` | `src/agent/session.rs:51` | `pub` 字段 | 子 session 创建后可读取 [高] |
|
||||
| `AgentSession.session_memory` | `src/agent/session.rs:58` | `pub` 字段 | dispatch 后父可读子 memory [高] |
|
||||
| `SessionManager::create_child(parent_id, agent)` | `src/engine/session_manager.rs:202-239` | `pub async` | dispatch 可直接复用,bundle 继承避免重复构造 [高] |
|
||||
| `SessionMemory::list_entries()` | `src/agent/session_memory.rs:81-110` | `pub async` | 可获取全量条目用于父子继承 / bridge_keys 过滤 [高] |
|
||||
| `SessionMemory::set_with_meta()` | `src/agent/session_memory.rs:61-79` | `pub async` | 子 session 写入继承数据时保留原始 metadata [高] |
|
||||
| `CostTracker` | `src/llm/cycle.rs` | derive `Clone` | SubTaskResult 可直接 clone usage [高] |
|
||||
| `SessionMeta` 持久化 | `src/engine/session_manager.rs:32-67` | `pub(crate)` | 以 `session:{id}:meta` key 存到 MemoryStore;switch 后需更新 agent_name [高] |
|
||||
| `SessionManager::save_session_meta()` | `src/engine/session_manager.rs:131-142` | `async fn` (非 pub) | switch_agent 需要类似的 meta 更新能力;考虑提取为 `pub(crate)` [高] |
|
||||
| `SessionManager::destroy()` | `src/engine/session_manager.rs:495-512` | `pub async` | dispatch 失败时清理子 session 可直接复用 [高] |
|
||||
| `SessionManager.sessions` | `src/engine/session_manager.rs:92` | `pub(crate)` RwLock<HashMap> | switch_agent 和 dispatch 的 get / replace 操作均依赖此字段 [高] |
|
||||
| `EngineError` 枚举 | `src/engine/error.rs:16-46` | `#[non_exhaustive]` | 已有 6 个变体,需追加 DispatchFailed / SwitchFailed / SubAgentStreamError [中] |
|
||||
| `futures-core` / `futures-util` | `Cargo.toml:17-18` | 已引入 | dispatch_stream 返回 `Pin<Box<dyn Stream>>` 所需依赖已就绪,无需新增 [高] |
|
||||
|
||||
### Agent trait 与 SessionManager 之间的关系
|
||||
|
||||
```
|
||||
AgentSession {
|
||||
agent: Arc<dyn Agent>, // 可替换
|
||||
session_memory: SessionMemory, // 可继承(clone backend)
|
||||
slots: HashMap<String, ContextSlot>,
|
||||
turn_index: u32,
|
||||
cost_so_far: CostTracker,
|
||||
// ... 其余内部字段
|
||||
}
|
||||
|
||||
SessionManager {
|
||||
sessions: RwLock<HashMap<String, Arc<Mutex<AgentSession>>>>,
|
||||
checkpointer: Checkpointer,
|
||||
store: Arc<dyn MemoryStore>,
|
||||
config: SessionManagerConfig,
|
||||
}
|
||||
```
|
||||
|
||||
`AgentSession.agent` 是 `pub` 字段,这意味着 `switch_agent` 只需 `get()` → `lock()` → 替换 `agent` → 写回 meta。Route 明确、无架构阻力 [高]。
|
||||
|
||||
### 锁契约(需格外注意)
|
||||
|
||||
`src/engine/session_manager.rs:4-12` 记录了锁契约:**不持有 RwLock 跨越 `.await`**。所有 `.await` 点必须在 RwLock guard drop 之后。这意味着:
|
||||
|
||||
- `switch_agent`:读锁 `get()` 返回 `Arc<Mutex<AgentSession>>` 后释放,然后 lock session 级别的 Mutex → 替换 agent → 释放 Mutex → save_session_meta(I/O)[高]
|
||||
- `dispatch`:读锁 `get()` 父 session → 释放 → `create_child`(内部写锁)→ lock 子 session → inherit memory → submit_turn → 释放 [高]
|
||||
- 不会引入新的死锁风险 [高]
|
||||
|
||||
### 现有测试覆盖
|
||||
|
||||
SessionManager 已有 906 行(含 12 个测试),覆盖 create/get/destroy/create_child/replace/recover/children/parent/并发创建等场景:`src/engine/session_manager.rs:527-906`。Phase 18 新增测试不修改这些已有测试。
|
||||
|
||||
---
|
||||
|
||||
## 调研发现
|
||||
|
||||
### 1. bridge_keys 注入位置
|
||||
|
||||
**问题**:bridge_keys 本质是父 session 想注入到子 agent prompt 中的上下文数据。它应该放在哪里?
|
||||
|
||||
**调研来源**:
|
||||
- `docs/note-opencode-subagent-dispatch.md` — 明确反对修改 Agent 的 `system_prompt()` [高]
|
||||
- `src/agent/agent.rs:21` — `system_prompt()` 返回 `&str`,无状态变更能力 [高]
|
||||
- `src/agent/session_memory.rs` — `SessionMemory::set()` 提供 key-value 写入,子 agent 可读 [高]
|
||||
|
||||
**结论**:bridge_keys 通过 SessionMemory 副本继承 + 过滤注入,不碰 `system_prompt()`。子 agent 通过 `get_session_data(key)` 读取桥接数据 [高]。
|
||||
|
||||
### 2. SessionMemory 继承策略
|
||||
|
||||
**问题**:子 agent 启动时,父 session 的 SessionMemory 如何传递?
|
||||
|
||||
**方案 A —— 引用共享**:父子共享同一 `SessionMemory` 实例(Arc clone 后端)。优点是零拷贝,缺点是父子隔离被破坏 [中]。
|
||||
|
||||
**方案 B —— 快照副本**:父调用 `list_entries()` 获取全量条目,子通过 `set_with_meta()` 写入自己的 namespace。优点是隔离性强,缺点是 O(n) 拷贝开销 [高]。
|
||||
|
||||
**来源**:
|
||||
- `src/agent/session.rs:503-524` — `to_snapshot()` 已实现类似的 list_entries → HashMap 拍平 [高]
|
||||
- `src/agent/session_memory.rs:61-79` — `set_with_meta()` 可保留原始 metadata [高]
|
||||
- `docs/note-opencode-subagent-dispatch.md` — SA 建议副本策略 [中]
|
||||
|
||||
**结论**:采用方案 B(快照副本),隔离性优先。`inherit_session_memory` 内部使用 `list_entries()` → 按 `bridge_keys` 过滤 → `set_with_meta()` 写入子 namespace [高]。
|
||||
|
||||
### 3. dispatch_all 部分成功语义
|
||||
|
||||
**问题**:当一批子代理中部分失败时,dispatch_all 应该整体失败还是返回部分成功的 `Vec`?
|
||||
|
||||
**来源**:`docs/note-opencode-subagent-dispatch.md` — PM 和 SA 一致认为应返回部分成功语义 [高]。
|
||||
|
||||
**结论**:返回 `Vec<Result<SubTaskResult, EngineError>>`。调用方可迭代检查每个结果,失败条目保留 checkpoint 以便审计 [高]。
|
||||
|
||||
### 4. dispatch_stream 生命周期
|
||||
|
||||
**问题**:`dispatch_stream` 需要返回一个流,流内部要做 `submit_turn_stream` + `finalize_active_stream`。session 所有权和生命周期如何管理?
|
||||
|
||||
**来源**:
|
||||
- `src/engine/session_manager.rs:390-412` — `submit_turn_stream` 的锁模式:短持锁获取流后立即释放 [高]
|
||||
- `src/agent/session.rs:384-445` — `submit_turn_stream` 自身不持有跨 await 的锁 [高]
|
||||
- `docs/note-opencode-subagent-dispatch.md` — SA 建议在 AgentSession 新增 `finalize_active_stream()` 内部方法 [中]
|
||||
|
||||
**结论**:`dispatch_stream` 使用 `&Arc<Self>` 签名 + `tokio::spawn`。内部流管道:create_child → inherit_memory → submit_turn_stream → mpsc channel 转发事件 → 流消费完毕后调用 `finalize_active_stream()`。session 通过 `Arc<Mutex<AgentSession>>` 在 spawned task 中持有 [高]。
|
||||
|
||||
### 5. Cargo.toml 依赖分析
|
||||
|
||||
**来源**:
|
||||
- `Cargo.toml:17` — `futures-util = "0.3"` 已在依赖中 [高]
|
||||
- `Cargo.toml:15` — `tokio-stream = "0.1"` 已在依赖中 [高]
|
||||
- `Cargo.toml:16` — `futures = "0.3"` 已在依赖中 [高]
|
||||
|
||||
**结论**:dispatch_stream 所需的 `StreamExt` / `ReceiverStream` 所需的基础设施已全部就绪,无需新增任何依赖 [高]。
|
||||
|
||||
---
|
||||
|
||||
## 可选方案
|
||||
|
||||
### 方案 A:switch_agent 作为 AgentSession 方法 vs SessionManager 方法
|
||||
|
||||
| 维度 | A1: AgentSession 方法 | A2: SessionManager 方法 |
|
||||
|------|-----------------------|------------------------|
|
||||
| 实现位置 | `agent/session.rs` | `engine/switch.rs` |
|
||||
| 职责归属 | session 实例级 | 管理器级 |
|
||||
| 能否更新 SessionMeta | 不能(无 store 引用) | 能(有 store + checkpointer) |
|
||||
| 能否做自动 checkpoint | 不能(无 checkpointer) | 能 |
|
||||
| 与 create_child / replace 对齐 | 不对齐(create 在 SM) | 对齐(都在 SM) |
|
||||
|
||||
**来源**:
|
||||
- `src/agent/session.rs:49-71` — AgentSession 不持有 store / checkpointer 引用 [高]
|
||||
- `src/engine/session_manager.rs:325-347` — `replace()` 是 SM 方法,涉及 meta 持久化 [高]
|
||||
- roadmap lines 838 — `switch_agent(session_id, new_agent)` 签名暗示 SM 方法 [中]
|
||||
|
||||
**结论**:采用 A2(SessionManager 方法)。AgentSession 没有 store 引用,无法更新 SessionMeta。独立文件 `engine/switch.rs` 作为 SessionManager 的 impl 块。
|
||||
|
||||
### 方案 B:bridge_keys 注入方式
|
||||
|
||||
| 维度 | B1: SessionMemory 副本继承 + 过滤 | B2: 修改 Agent trait |
|
||||
|------|------------------------------------|----------------------|
|
||||
| 系统 prompt 侵入性 | 无 | 需新增 `set_bridge_data()` 方法 |
|
||||
| switch_agent 兼容性 | 天然兼容(与 agent 解耦) | switch 后需重新注入 |
|
||||
| 实现复杂度 | 一个私有辅助函数 | 需改 Agent trait + 所有实现 |
|
||||
| 测试增量 | 小(只测 `inherit_session_memory`) | 大(需测所有 Agent impl) |
|
||||
|
||||
**来源**:
|
||||
- `src/agent/agent.rs:16-30` — Agent trait 当前仅 3 个方法,简洁 [高]
|
||||
- `docs/note-opencode-subagent-dispatch.md` — "bridge_keys 通过 slot 注入,不修改 Agent system_prompt" [高]
|
||||
|
||||
**结论**:采用 B1。隔离关注点:Agent 负责"角色",SessionMemory 负责"桥接数据"。
|
||||
|
||||
### 方案 C:dispatch_stream 返回类型
|
||||
|
||||
| 维度 | C1: `Pin<Box<dyn Stream<Item=SubTaskStreamEvent>+Send>>` | C2: 自定义 struct 包装 |
|
||||
|------|----------------------------------------------------------|------------------------|
|
||||
| 与现有 API 一致性 | 与 `submit_turn_stream` 一致 [高] | 不一致 |
|
||||
| 调用方灵活性 | 直接 `.next()` + StreamExt | 需解包装 |
|
||||
| 实现复杂度 | 直接返回 stream | 需额外 struct + 方法 |
|
||||
| 可组合性 | 高(可直接 map/filter/collect) | 低 |
|
||||
|
||||
**来源**:
|
||||
- `src/engine/session_manager.rs:390-412` — `submit_turn_stream` 返回 `Pin<Box<dyn Stream<Item=StreamEvent>+Send>>` [高]
|
||||
|
||||
**结论**:采用 C1。保持一致的模式,调用方可以 `StreamExt::collect` / `map` 等。
|
||||
|
||||
### 否决方案
|
||||
|
||||
| 方案 | 否决原因 |
|
||||
|------|----------|
|
||||
| switch_agent 做自动 checkpoint | 与 auto_checkpoint 语义不一致(submit_turn 才触发),用户可手动 checkpoint。来源:`src/engine/session_manager.rs:357-384` auto_checkpoint 仅在 submit_turn/finalize_turn 触发 |
|
||||
| AgentSession 中的 `Agent` 用 `Box<dyn Agent>` | 与现有 `Arc<dyn Agent>` 不一致,且 SessionSnapshot 不序列化 agent(`src/agent/session.rs:502`)。来源:`src/agent/session.rs:53` |
|
||||
| dispatch_all 返回所有成功再返回 | 需要调用方等待全部完成才能拿到第一个结果。Rust 已有 `JoinSet` / `FuturesUnordered` 可选,但 v0.3 先保持简单 |
|
||||
| child_memory 加额外权限控制 | 子 session 是父创建的,父天然有 destroy / read 权限。来源:`docs/note-opencode-subagent-dispatch.md` PM 明确"不做额外权限控制" |
|
||||
| 子 session 失败时保留 checkpoint | `destroy()` 调 `checkpointer.delete_all`(`session_manager.rs:508`),不保留持久化残留。失败路径的调试信息通过 `tracing::error!` 日志记录 |
|
||||
|
||||
---
|
||||
|
||||
## 推荐方案
|
||||
|
||||
### 整体架构
|
||||
|
||||
```
|
||||
SessionManager (existing)
|
||||
├── switch_agent(id, new_agent) → engine/switch.rs
|
||||
├── dispatch(parent, agent, task, cfg) → engine/sub_agent.rs
|
||||
├── dispatch_stream(parent, agent, task, cfg) → engine/sub_agent.rs
|
||||
└── dispatch_all(parent, tasks, cfg) → engine/sub_agent.rs
|
||||
```
|
||||
|
||||
`switch.rs` 和 `sub_agent.rs` 均为 SessionManager 的 `impl` 块文件,通过 `pub mod` 在 `engine/mod.rs` 中注册 [高]。
|
||||
|
||||
### 决策清单
|
||||
|
||||
| # | 决策 | 结论 | 理由 |
|
||||
|---|------|------|------|
|
||||
| D1 | switch_agent 位置 | SessionManager 方法,在 `engine/switch.rs` | 需 store 更新 SessionMeta,AgentSession 无 store 引用 |
|
||||
| D2 | bridge_keys 注入方式 | SessionMemory 副本继承 + 过滤,不碰 system_prompt | 概念正交,switch_agent 友好 |
|
||||
| D3 | Memory 继承策略 | 快照副本(list_entries → set_with_meta) | 父子隔离优先,O(n) 拷贝可接受 |
|
||||
| D4 | dispatch_all 返回类型 | `Vec<Result<SubTaskResult, EngineError>>` | 部分成功语义,Rust-idiomatic |
|
||||
| D5 | dispatch_stream 返回类型 | `Pin<Box<dyn Stream<Item=SubTaskStreamEvent>+Send>>` | 与 `submit_turn_stream` 一致 |
|
||||
| D6 | switch_agent 的 lock 策略 | get() 读锁立即释放 → Mutex lock → 替换 → 释放 Mutex → I/O | 严格遵循已有锁契约 |
|
||||
| D7 | switch checkpoint | 不自动 checkpoint | 与 `auto_checkpoint` 语义一致(仅 submit_turn/finalize_turn 触发) |
|
||||
| D8 | dispatch 失败清理 | destroy 子 session | 不留僵尸 session |
|
||||
| D9 | dispatch 失败时 checkpoint | destroy 清理全部(含 checkpoint) | `destroy()` 内部调 `checkpointer.delete_all`,不留持久化垃圾 |
|
||||
| D10 | dispatch_all 并发控制 | `tokio::sync::Semaphore` | 轻量、内建、语义清晰 |
|
||||
| D11 | dispatch_all / dispatch_stream 签名 | `self: &Arc<Self>` | 满足 `tokio::spawn` `'static` 约束 |
|
||||
| D12 | 子 session 创建时 bundle | 从父 session 的 `RuntimeBundle` clone | 复用 `create_child` 已有逻辑 |
|
||||
|
||||
### 设计理由详述
|
||||
|
||||
**D1 为什么 switch_agent 必须放在 SessionManager 下**:因为 switch 后需要更新持久化的 SessionMeta(`agent_name` 变化),而 `save_session_meta` 需要 `&self.store`。AgentSession 不持有 store 引用(纯内存对象)。如果放在 AgentSession 上,要么给它加 store 引用(开历史倒车),要么让调用方手动调 `save_session_meta`(容易遗漏)[高]。
|
||||
|
||||
**D3 为什么选副本而非引用**:隔离性优先原则。父 session 可能在子运行期间 `set` 新数据,引用共享会导致子看到父的运行时中间状态;副本确保子看到的是 dispatch 时刻的稳定快照。性能方面,SessionMemory 条目数通常 < 100,O(n) 拷贝可忽略 [高]。
|
||||
|
||||
**D5 dispatch_stream 方案**:核心挑战是 session 生命周期管理和消息 finalize。方案使用 spawn task + `tokio::sync::mpsc::unbounded_channel`。spawn task 通过事件追踪重建消息列表:记录 `user_input` 作为首条 `UserMessage`,从 `StreamEvent::ToolExecutionCompleted` 事件提取工具结果,从 `StreamEvent::MessageComplete` 提取完整响应。流结束后直接调用 `AgentSession::finalize_turn(response, new_messages).await`。当 receiver 端 drop 时 sender 侧的 `send()` 错误会被捕获,task 内清理。`dispatch_stream` 返回的 stream 发出 `SubTaskStreamEvent::ChildCreated`(先导)+ `Stream(StreamEvent)`(中间,透传)+ `Completed(SubTaskResult)`(最终),消费者无需额外调 finalize [高]。
|
||||
|
||||
## 实施建议
|
||||
|
||||
### 阶段划分
|
||||
|
||||
共 9 个步骤,建议依次实施,不可并行。总预估时间由实现者在实施时评估。
|
||||
|
||||
#### Step 1 — `error.rs` 扩展
|
||||
|
||||
**文件**:`src/engine/error.rs`
|
||||
**内容**:EngineError 追加 3 个变体
|
||||
- `DispatchFailed(String)` — 子代理调度通用失败
|
||||
- `SwitchFailed(String)` — 角色切换失败
|
||||
- `SubAgentStreamError { child_id: String, detail: String }` — 流式调度中的子代理错误
|
||||
|
||||
**验证**:`cargo build` 成功。
|
||||
**注意**:已存在的 `#[non_exhaustive]` 属性确保这不是 breaking change [高]。
|
||||
|
||||
#### Step 2 — `session.rs` 无变更
|
||||
|
||||
**文件**:`src/agent/session.rs` — 不修改现有 API。
|
||||
|
||||
**审查发现**:第一轮审查确认 `finalize_active_stream()` 假设不成立。`submit_turn_stream`(`session.rs:384-445`)返回 stream 后 `LlmCycle` 即被 drop(`cycle.rs:647` 通过 `std::mem::take` 移出消息),不存在"active stream 内部状态"可读取。
|
||||
|
||||
**结论**:改为在 `dispatch_stream` 的 spawn task 中**从 StreamEvent 序列重建消息列表**,直接调用已有的 `finalize_turn(response, new_messages).await`。详见 Step 7 第 3 项。
|
||||
|
||||
**验证**:不修改 `session.rs`,Step 7 实施前 `cargo build` 可通过。
|
||||
|
||||
#### Step 3 — `switch.rs`
|
||||
|
||||
**文件**:`src/engine/switch.rs`
|
||||
**内容**:
|
||||
|
||||
```rust
|
||||
impl SessionManager {
|
||||
/// 热切换指定 session 的 Agent 角色。
|
||||
///
|
||||
/// - 保留 slot 历史 / turn_index / session_memory / cost_so_far
|
||||
/// - 自动更新 SessionMeta 中的 agent_name(保持原始 created_at / parent_id)
|
||||
/// - 不自动 checkpoint(与 `auto_checkpoint` 语义一致:仅 submit_turn 触发)
|
||||
/// - **注意**: 切换后新的 system_prompt 将与已有对话历史共存。
|
||||
/// 建议在切换后发送一条明确的上下文过渡提示
|
||||
/// (如"你现在以新角色 X 的身份继续对话")作为切换后的首条输入。
|
||||
/// - **安全提示**: `AgentSession.agent` 是 `pub` 字段可直接访问,
|
||||
/// 绕过 `switch_agent` 直接修改会导致 SessionMeta 中的 agent_name
|
||||
/// 与内存状态不一致,请始终使用此方法。
|
||||
pub async fn switch_agent(
|
||||
&self,
|
||||
session_id: &str,
|
||||
new_agent: Arc<dyn Agent>,
|
||||
) -> Result<(), EngineError> {
|
||||
// 1. get session(RwLock 读锁,返回后释放)
|
||||
let session = self.get(session_id).await?;
|
||||
|
||||
// 2. lock Mutex,替换 agent,读 name + turn_index
|
||||
let (agent_name, turn_index) = {
|
||||
let mut guard = session.lock().await;
|
||||
guard.agent = new_agent;
|
||||
(guard.agent.name().to_string(), guard.turn_index())
|
||||
}; // 释放 Mutex
|
||||
|
||||
// 3. 读取原始 SessionMeta(用于保留 created_at / parent_id)
|
||||
let existing_meta = self
|
||||
.load_session_meta(session_id)
|
||||
.await?
|
||||
.ok_or_else(|| EngineError::SessionNotFound(session_id.to_string()))?;
|
||||
|
||||
// 4. 构造新 meta 并持久化(I/O,无锁)
|
||||
let meta = SessionMeta {
|
||||
session_id: session_id.to_string(),
|
||||
agent_name,
|
||||
parent_id: existing_meta.parent_id,
|
||||
created_at: existing_meta.created_at,
|
||||
turn_count: turn_index,
|
||||
};
|
||||
self.save_session_meta(&meta).await?;
|
||||
|
||||
tracing::info!(
|
||||
session_id = %session_id,
|
||||
agent_name = %meta.agent_name,
|
||||
previous_agent = %existing_meta.agent_name,
|
||||
"agent switched"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**测试**(预计 4 个):
|
||||
1. 基本切换:switch 后 `agent.name()` 返回新 name
|
||||
2. 上下文保留:turn_index / session_memory / slot 历史均不变
|
||||
3. SessionMeta 持久化:`load_session_meta` 验证 agent_name 已更新
|
||||
4. 不存在的 session:返回 `SessionNotFound`
|
||||
|
||||
**验证**:`cargo test --all-targets` + clippy
|
||||
|
||||
#### Step 4 — `sub_agent.rs` 类型
|
||||
|
||||
**文件**:`src/engine/sub_agent.rs`
|
||||
**内容**:3 个类型定义
|
||||
|
||||
```rust
|
||||
/// 子代理调度配置。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DispatchConfig {
|
||||
/// 最大并发数(dispatch_all 用)。默认 10。
|
||||
pub max_concurrency: usize,
|
||||
/// 是否继承父 SessionMemory。默认 true。
|
||||
pub inherit_session_memory: bool,
|
||||
/// 桥接 key 列表:
|
||||
/// - `None` = 不继承任何父 SessionMemory
|
||||
/// - `Some(vec![])` = 继承全部父 SessionMemory
|
||||
/// - `Some(keys)` = 仅继承指定的 keys
|
||||
/// 默认 `None`(零继承),显式选择加入。
|
||||
pub bridge_keys: Option<Vec<String>>,
|
||||
/// 子↔子共享 namespace。如果为 `Some(prefix)`,
|
||||
/// 子 agent 可通过 `session.get_session_data(key)` 访问
|
||||
/// `shared:{prefix}:{key}` 命名空间的数据。
|
||||
/// 默认 `Some(parent_session_id)`。
|
||||
pub shared_namespace: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for DispatchConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_concurrency: 10,
|
||||
inherit_session_memory: true,
|
||||
bridge_keys: None,
|
||||
shared_namespace: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 子代理执行结果。
|
||||
///
|
||||
/// dispatch 成功后子 session **保留在 SessionManager 中**,调用方可
|
||||
/// 通过 `sm.get(&result.child_id)` 获取子 session 引用,进而通过
|
||||
/// `session_memory()` 读取子 SessionMemory(如 "result_summary")。
|
||||
#[derive(Debug)]
|
||||
pub struct SubTaskResult {
|
||||
/// 子 session ID。可通过此 ID 在 SessionManager 中读取子 session。
|
||||
pub child_id: String,
|
||||
/// LLM 最终响应。
|
||||
pub response: MessageResponse,
|
||||
/// 本次调用的 token 用量。
|
||||
pub usage: CostTracker,
|
||||
/// 可选摘要(读取子 session_memory 中的 "result_summary")。
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
/// 流式子代理调度事件。
|
||||
#[derive(Debug)]
|
||||
pub enum SubTaskStreamEvent {
|
||||
/// 子 session 已创建(携带 child_id)。
|
||||
ChildCreated { child_id: String },
|
||||
/// LLM 流事件(透传)。
|
||||
Stream(StreamEvent),
|
||||
/// 执行完成(携带完整结果)。
|
||||
Completed(SubTaskResult),
|
||||
}
|
||||
```
|
||||
|
||||
`SubTaskStreamEvent` 需实现 `Display` 和 `std::error::Error`(`Completed` 和 `ChildCreated` 不触发错误路径,`Display` 仅用于调试日志)[中]。
|
||||
|
||||
**验证**:`cargo build`
|
||||
|
||||
#### Step 5 — `sub_agent.rs dispatch` 核心
|
||||
|
||||
**文件**:`src/engine/sub_agent.rs`
|
||||
**内容**:
|
||||
|
||||
```rust
|
||||
impl SessionManager {
|
||||
/// 私有辅助:从父 session memory 继承条目到子 session。
|
||||
///
|
||||
/// **一致性模型**:捕获的是调用时刻的父 session_memory 快照。
|
||||
/// 即使在 `list_entries()` 返回后、`set_with_meta()` 写入前
|
||||
/// 父 session 被并发写入新数据,子 session 也**不会**看到这些
|
||||
/// 新数据(快照副本的内生特征)。[审查确认]
|
||||
async fn inherit_session_memory(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
child_id: &str,
|
||||
config: &DispatchConfig,
|
||||
) -> Result<(), EngineError> { /* ... */ }
|
||||
|
||||
/// 派发一个子任务,返回结构化结果。
|
||||
pub async fn dispatch(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
sub_agent: Arc<dyn Agent>,
|
||||
task: impl Into<String>,
|
||||
config: DispatchConfig,
|
||||
) -> Result<SubTaskResult, EngineError> { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
**dispatch 流程**:
|
||||
1. `create_child(parent_id, sub_agent)` → 获取 child_id
|
||||
2. 若 `config.inherit_session_memory == true` → `inherit_session_memory(parent_id, child_id, config)`
|
||||
3. `submit_turn(child_id, task)` → 获取 response
|
||||
4. 读取 `"result_summary"`(可选)
|
||||
5. 返回 `SubTaskResult`(子 session 保留在 SessionManager 中,可通过 `sm.get(&child_id)` 读取 child_memory)
|
||||
6. 失败路径:`let _ = self.destroy(&child_id).await; tracing::error!(...)`(静默吞掉清理错误,**原始 EngineError 优先**;`destroy` 会清理子 session 的 SessionMeta + checkpoint 条目,不留僵尸)
|
||||
|
||||
**测试**(预计 5 个):
|
||||
1. 基本调度:子 agent 返回预期响应
|
||||
2. bridge_keys 过滤:仅指定的 key 被继承
|
||||
3. memory 继承:父 set 的值子可读到
|
||||
4. submit_turn 失败:错误传播 + 子 session 被销毁
|
||||
5. 无效 parent_id:返回 `SessionNotFound`
|
||||
|
||||
**验证**:`cargo test --all-targets`
|
||||
|
||||
#### Step 6 — `sub_agent.rs dispatch_all`
|
||||
|
||||
**文件**:`src/engine/sub_agent.rs`
|
||||
**内容**:
|
||||
|
||||
```rust
|
||||
impl SessionManager {
|
||||
/// 并行派发一批子任务。
|
||||
pub async fn dispatch_all(
|
||||
self: &Arc<Self>,
|
||||
parent_id: &str,
|
||||
tasks: Vec<(Arc<dyn Agent>, String)>,
|
||||
config: DispatchConfig,
|
||||
) -> Vec<Result<SubTaskResult, EngineError>> { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 使用 `tokio::sync::Semaphore` 限制并发数(默认 `config.max_concurrency`)
|
||||
- **Semaphore acquire 在 spawn 内**:`let permit = semaphore.clone().acquire_owned().await;` — permit 所有权转移到 spawned task。避免 spawn N 个 task 时全量分配 Future 内存 [审查修复]
|
||||
- 每个 task `tokio::spawn` + `Arc<Self>` clone
|
||||
- 内部调用 `dispatch` 的同类逻辑(create_child → inherit → submit_turn)
|
||||
- **indexed 收集**:预分配 `Vec<Option<Result<...>>>` 按 `tasks` 索引填入,维持输入顺序。不使用排序(排序需等所有 child_id 生成后)[审查修复]
|
||||
- 每个结果独立:`Ok(SubTaskResult)` 或 `Err(EngineError)`
|
||||
|
||||
**测试**(预计 4 个):
|
||||
1. 并行 3 个全部成功
|
||||
2. 部分失败(MockProvider 对特定 task 返回错误)
|
||||
3. Semaphore 上限验证(max_concurrency=1 时串行执行)
|
||||
4. 空 tasks 列表
|
||||
|
||||
**验证**:`cargo test --all-targets`
|
||||
|
||||
#### Step 7 — `sub_agent.rs dispatch_stream`
|
||||
|
||||
**文件**:`src/engine/sub_agent.rs`
|
||||
**内容**:
|
||||
|
||||
```rust
|
||||
impl SessionManager {
|
||||
pub async fn dispatch_stream(
|
||||
self: &Arc<Self>,
|
||||
parent_id: &str,
|
||||
sub_agent: Arc<dyn Agent>,
|
||||
task: impl Into<String>,
|
||||
config: DispatchConfig,
|
||||
) -> Result<
|
||||
Pin<Box<dyn Stream<Item = SubTaskStreamEvent> + Send>>,
|
||||
EngineError,
|
||||
> { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 同步部分(lock 外):create_child + inherit_memory
|
||||
- 获取 Stream 后通过 `tokio::sync::mpsc::unbounded_channel` 转发事件(与 LLM stream 内部背压策略一致,避免有界 channel 的 sender 阻塞风险)[审查修复]
|
||||
- spawn task 持有 `Arc<Mutex<AgentSession>>` 消费 LLM stream
|
||||
- **消息重建机制**(替代已移除的 `finalize_active_stream()`):[审查修复]
|
||||
```
|
||||
// 在 spawn task 中:
|
||||
let mut new_messages: Vec<Message> = vec![Message::user_text(&task)];
|
||||
let mut final_response: Option<MessageResponse> = None;
|
||||
|
||||
while let Some(event) = llm_stream.next().await {
|
||||
// 转发事件到输出 channel
|
||||
tx.send(SubTaskStreamEvent::Stream(event.clone()))?;
|
||||
// 从 ToolExecutionCompleted 构造 ToolResult 消息
|
||||
if let StreamEvent::ToolExecutionCompleted { tool_name, tool_call_id, input, output } = &event {
|
||||
new_messages.push(Message::tool_result(tool_call_id, tool_name, output));
|
||||
}
|
||||
// 捕获最终响应
|
||||
if let StreamEvent::MessageComplete(ref resp) = event {
|
||||
final_response = Some(resp.clone());
|
||||
}
|
||||
}
|
||||
// 流结束后,追加 assistant 消息并 finalize
|
||||
if let Some(response) = &final_response {
|
||||
new_messages.push(response.message.clone());
|
||||
child_session.lock().await
|
||||
.finalize_turn(response, new_messages).await?;
|
||||
}
|
||||
```
|
||||
- 事件序列:`ChildCreated` → `Stream(StreamEvent)` × N → `Completed(SubTaskResult)`
|
||||
- 消费者 drop receiver → unbounded channel sender 错误 → task 自动退出
|
||||
|
||||
**测试**(预计 4 个):
|
||||
1. 事件序列验证:收到 ChildCreated → 至少一个 Stream → Completed
|
||||
2. 错误传播:LLM 内部错误 → 正确映射到 error 事件
|
||||
3. receiver dropped:drop receiver 后 task 正确退出,不 panic
|
||||
4. finalize 正确性:Completed 中的 usage / summary 正确
|
||||
|
||||
**验证**:`cargo test --all-targets`
|
||||
|
||||
#### Step 8 — `mod.rs` + 集成验证
|
||||
|
||||
**文件**:`src/engine/mod.rs`
|
||||
**内容**:追加 `pub mod switch;` 和 `pub mod sub_agent;` + `pub use`
|
||||
|
||||
```rust
|
||||
pub mod checkpointer;
|
||||
pub mod error;
|
||||
pub mod session_manager;
|
||||
pub mod snapshot;
|
||||
pub mod switch; // <-- 新增
|
||||
pub mod sub_agent; // <-- 新增
|
||||
```
|
||||
|
||||
**验证**:
|
||||
1. `cargo test --all-targets` — 374+ 测试全部通过
|
||||
2. `cargo clippy --all-targets -- -D warnings` — 0 警告
|
||||
3. `cargo doc --no-deps` — 0 warning
|
||||
|
||||
#### Step 9 — 示例
|
||||
|
||||
**文件 1**:`examples/agent_switch_demo.rs`(约 80 行)
|
||||
- 创建 session → submit_turn(角色 A)→ switch_agent(角色 B)→ submit_turn(角色 B)→ 验证上下文保留
|
||||
- 演示目的:证明 switch_agent 保留 slot 历史 / turn_index / session_memory
|
||||
|
||||
**文件 2**:`examples/sub_agent_dispatch_demo.rs`(约 150 行)
|
||||
- 父 session → dispatch_all 3 个子 agent(研究、写作、审校)→ 收集结果 → 父汇总
|
||||
- 树形验证:`children(parent_id)` 返回 3 个子 ID
|
||||
- 演示目的:多 agent 协作完整链路
|
||||
|
||||
**文件 3**:`examples/bridge_keys_demo.rs`(约 140 行)
|
||||
- 父设置 SessionMemory(key: "project_goal", "constraints")→ dispatch + bridge_keys → 子 agent 通过 `get_session_data` 读取
|
||||
- 子↔子交互:父通过 `DispatchConfig.shared_namespace` 设定共享命名空间,子 A 写入 `shared:{parent_id}:fact_x`,子 B 通过约定 key 读取
|
||||
- 演示目的:bridge_keys 过滤机制 + 父子数据桥接 + 子↔子共享 namespace
|
||||
|
||||
**文件 4 — 新增**:`examples/dispatch_stream_demo.rs`(约 100 行)
|
||||
- 父 session → dispatch_stream 单个子 agent → 消费 `SubTaskStreamEvent` 序列
|
||||
- 验证收到 `ChildCreated` + 至少一个 `Stream` + `Completed` 事件
|
||||
- 输出 `SubTaskResult.child_id` / `usage` / `summary`,验证消息重建和 finalize 正确性
|
||||
- 演示 `receiver dropped` 场景:中途 drop receiver 后 task 正确退出不 panic
|
||||
- 演示目的:dispatch_stream 的事件序列 + finalize 完整性验证
|
||||
|
||||
**验证**:4 个示例全部 `cargo run --example` exit 0
|
||||
|
||||
### 高层实施建议
|
||||
|
||||
1. **Step 1 优先于所有步骤**:Error 扩展是所有后续步骤的基础,无依赖可并行 [高]
|
||||
2. **Step 3 独立性强**:switch_agent 不依赖 dispatch 的任何类型,可单独实施和测试 [高]
|
||||
3. **Step 4 是 Step 5-7 的前置**:类型定义不依赖其他逻辑,建议在 Step 3 完成后立即实施 [高]
|
||||
4. **Step 5-7 按复杂度递增**:dispatch → dispatch_all → dispatch_stream。dispatch_all 复用 dispatch 的核心逻辑;dispatch_stream 是最复杂的,建议最后实施 [高]
|
||||
5. **Step 8 集成验证不可跳过**:clippy + doc 全量验证确保无回归 [高]
|
||||
6. **Step 9 在所有核心完成后实施**:示例是验收标准的一部分,PM 确认 3 个递进示例 [中]
|
||||
7. **全量测试密码**:实施过程中持续 `cargo test --all-targets`,不在最后统一修复 [高]
|
||||
|
||||
### 风险矩阵
|
||||
|
||||
| # | 风险 | 等级 | 可能性 | 对策 |
|
||||
|---|------|------|--------|------|
|
||||
| R1 | dispatch_stream 的消息重建:从 StreamEvent 序列重建 `new_messages` 的完整性 | 🟡 中 | 低 | 已移除 `finalize_active_stream()` 方案。spawn task 通过追踪 `ToolExecutionCompleted` / `MessageComplete` 事件重建消息列表。完整性由 `MessageComplete` 事件保证 |
|
||||
| R2 | `tokio::spawn` `'static` + SessionManager 引用 | 🟡 中 | 低 | dispatch_all 和 dispatch_stream 签名已明确用 `&Arc<Self>`;调用方包装 `Arc<SessionManager>` |
|
||||
| R3 | 子 session 创建成功但后续 submit_turn 失败 | 🟡 中 | 中 | dispatch 内 `destroy(child_id)` 放在 `?` 前确保清理;通过 `let child_id = ...;` 先绑定,再 `let r = submit_turn(...).await`,失败时 `destroy(&child_id).await?` 清理 |
|
||||
| R4 | 部分失败时孤儿 checkpoint 数据 | 🟢 低 | 必然 | **正向利用**:保留用于调试审计,存储开销可忽略 |
|
||||
| R5 | 并发 dispatch_all 中任务 panic | 🟡 中 | 低 | `tokio::spawn` 的 `JoinHandle` 通过 `.await` 捕获 panic;panic 传播到 `dispatch_all` 内作为 `Err` 返回 |
|
||||
| R6 | bridge_keys 中不存在的 key | 🟢 低 | 中 | 静默跳过(与 `List_entries` 返回全量后再过滤,不存在的 key 自然不会出现在结果中) |
|
||||
|
||||
### 架构图(文本示意)
|
||||
|
||||
```
|
||||
SessionManager
|
||||
/ | \
|
||||
/ | \
|
||||
switch_agent dispatch dispatch_stream
|
||||
| | |
|
||||
v v v
|
||||
AgentSession create_child create_child
|
||||
.agent = new inherit_mem inherit_mem
|
||||
SessionMeta submit_turn submit_turn_stream
|
||||
更新 返回结果 mpsc 转发事件
|
||||
finalize_on_complete
|
||||
|
||||
交互层次:
|
||||
父 -> 子: SessionMemory snapshot + bridge_keys 过滤
|
||||
子 -> 父: SubTaskResult { child_id, response, usage, summary }
|
||||
子 <-> 子: shared:{parent_session_id} namespace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 参考来源
|
||||
|
||||
### 代码路径
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `src/agent/session.rs` | AgentSession 定义、`agent` pub 字段(L53)、`session_memory` pub 字段(L58)、`submit_turn_stream`(L384)、`finalize_turn`(L456) |
|
||||
| `src/agent/agent.rs` | Agent trait 定义(3 个方法) |
|
||||
| `src/agent/session_memory.rs` | SessionMemory: `set`(L50)、`set_with_meta`(L61)、`list_entries`(L81) |
|
||||
| `src/engine/session_manager.rs` | SessionManager: `create_child`(L202)、`get`(L244)、`destroy`(L495)、`save_session_meta`(L131)、`load_session_meta`(L144)、SessionMeta(L32)、锁契约(L4-12) |
|
||||
| `src/engine/error.rs` | EngineError 枚举(当前 6 变体,`#[non_exhaustive]`) |
|
||||
| `src/engine/mod.rs` | 模块注册 |
|
||||
| `src/engine/snapshot.rs` | SessionSnapshot(from_snapshot / to_snapshot 所需) |
|
||||
| `src/llm/stream.rs` | StreamEvent 枚举 |
|
||||
| `Cargo.toml` | 依赖声明(`futures-util` L17、`tokio-stream` L15、`futures-core` L18) |
|
||||
|
||||
### 文档路径
|
||||
|
||||
| 文档 | 用途 |
|
||||
|------|------|
|
||||
| `docs/roadmap-v0.3.0.md` §Phase 18 | Phase 18 原始需求(交付物、交互层级、优先级) |
|
||||
| `docs/note-opencode-agent-switching.md` | Agent 热切换调研笔记(桥接方案分析、生命周期讨论) |
|
||||
| `docs/note-opencode-subagent-dispatch.md` | SubAgent Dispatch 调研笔记(PM/SA 建议、设计推演) |
|
||||
| `docs/23-phase17-agent-execution-engine.md` | Phase 17 方案文档(SessionManager 设计背景) |
|
||||
| `docs/7-agent-runtime.md` | Agent 运行时设计文档(Session 与 Agent 的关系) |
|
||||
| `docs/17-phase10-contextslot.md` | ContextSlot 上下文管理(Phase 10) |
|
||||
| `docs/24-phase18-agent-switch-and-dispatch.md` | 本文档 — 第 1 轮审查修复记录 |
|
||||
|
||||
### 决策轨迹
|
||||
|
||||
| 决策 | 参考来源 | 置信度 |
|
||||
|------|----------|--------|
|
||||
| switch_agent 在 SessionManager 而非 AgentSession | `src/agent/session.rs` AgentSession 无 store 引用 | 高 |
|
||||
| bridge_keys 通过 SessionMemory 副本,不碰 system_prompt | `docs/note-opencode-subagent-dispatch.md` PM/SA 建议 | 高 |
|
||||
| dispatch_all 返回 `Vec<Result<..>>` 部分成功 | `docs/note-opencode-subagent-dispatch.md` PM/SA 一致 | 高 |
|
||||
| dispatch_stream 返回 `Pin<Box<dyn Stream>>` | `src/engine/session_manager.rs` `submit_turn_stream` 签名一致 | 高 |
|
||||
| 失败时 destroy 子 session 清理全部(含 checkpoint) | `session_manager.rs:508` destroy 调 `checkpointer.delete_all` | 高 |
|
||||
| dispatch_all 用 `&Arc<Self>` 签名 | `tokio::spawn` `'static` 约束 | 高 |
|
||||
| child_memory 不做额外权限控制 | `docs/note-opencode-subagent-dispatch.md` PM 明确 | 高 |
|
||||
| `#[non_exhaustive]` 已存在 -> 新增 EngineError 变体不是 breaking change | `src/engine/error.rs:15` | 高 |
|
||||
| `futures-util` 已存在,无需新增依赖 | `Cargo.toml:17` | 高 |
|
||||
|
||||
### 审查修复轨迹(第 1 轮)
|
||||
|
||||
| # | 问题 | 🔴/🟡 | 修复内容 |
|
||||
|---|------|--------|---------|
|
||||
| F1 | `finalize_active_stream()` 假设不成立:`submit_turn_stream` 返回后 cycle 被 drop | 🔴 | 移除 Step 2 的 `finalize_active_stream()`,改为 spawn task 内从 StreamEvent 重建消息列表直接调 `finalize_turn()` |
|
||||
| F2 | `SubTaskResult` 缺 child_memory 访问路径 | 🔴 | `SubTaskResult.child_id` 可经由 `sm.get()` 读取子 session。构型 doc comment 增加说明 |
|
||||
| F3 | dispatch 失败路径 `destroy` 自身 I/O 可能失败,覆盖原始错误 | 🔴 | 改用 `let _ = destroy` + `tracing::error!`,原始 `EngineError` 优先 |
|
||||
| F4 | switch_agent SessionMeta 构造中 `created_at`/`parent_id` 用占位符 | 🔴 | 改用 `load_session_meta` 读取原始值,`turn_count` 从 `guard.turn_index()` 读取 |
|
||||
| F5 | D9 与 `destroy()` 实现矛盾(方案说保留,代码说删除) | 🟡 | D9 修正为"destroy 清理全部",否决条目同步更新 |
|
||||
| F6 | `bridge_keys` 默认值安全反直觉(空=全量继承) | 🟡 | 类型改为 `Option<Vec<String>>`,`None` = 不继承(默认),`Some(vec![])` = 全量 |
|
||||
| F7 | Semaphore acquire 位置未指定 | 🟡 | 指定 `acquire_owned()` 在 spawn 内 + indexed 收集维持输入顺序 |
|
||||
| F8 | dispatch_stream 缺少独立示例 | 🟡 | Step 9 追加 `dispatch_stream_demo` |
|
||||
| F9 | mpsc channel 背压策略未指定 | 🟡 | 改用 `unbounded_channel`,与 LLM stream 内部模式一致 |
|
||||
| F10 | 子↔子交互层缺少实现细节 | 🟡 | `DispatchConfig.shared_namespace` 字段 + 示例 3 演示 |
|
||||
| F11 | inherit_session_memory 竞态窗口未文档化 | 🟡 | doc comment 声明快照一致性模型 |
|
||||
| F12 | switch_agent system_prompt 断裂风险未说明 | 🟡 | doc comment 增加使用建议 + 安全提示 |
|
||||
|
||||
---
|
||||
|
||||
*本文档对应的实施步骤记录在 `docs/roadmap-v0.3.0.md` §Phase 18,实施完成后同步更新 roadmap 状态。*
|
||||
@@ -0,0 +1,652 @@
|
||||
# Phase 19:知识图谱 + 双通道检索
|
||||
|
||||
## 背景与目标
|
||||
|
||||
### 问题空间
|
||||
|
||||
agcore v0.3.0 已交付 Phase 0-18,记忆系统具备 `KnowledgeStore`(页面级内容检索)和 `VectorStore`(向量语义检索),但缺少实体-关系维度的关联检索能力。用户搜索"X 与什么相关"时,现有系统无法返回实体间的拓扑关系。
|
||||
|
||||
`docs/note-knowledge-graph-design.md` 已记录完整的知识图谱设计,Phase 19 将其落地为可编译、可测试的模块。
|
||||
|
||||
### 目标
|
||||
|
||||
- 新增 `memory/graph.rs`,实现 `KnowledgeGraph` trait + `InMemoryGraph` 内存实现
|
||||
- 扩展 `MemoryRetriever` 为双通道:KnowledgeStore(内容)+ KnowledgeGraph(实体关系)
|
||||
- 通过 `RetrievalStrategy` 枚举控制通道选择(Hybrid / KnowledgeOnly / GraphOnly)
|
||||
- 统一 `RetrievalResult.items` 为 `Vec<RetrievalItem>`,enum 变体区分类别
|
||||
- 标签管理 API 预留(无自动提取流程,Agent 层显式写入)
|
||||
- Phase 19 仅提供底层 CRUD 接口,实体/关系的写入由 Agent 层(如 LLM 提取)在后续 Phase 中接入。当前无自动填充流程,需 Agent 显式调用 `add_entity`/`add_relation`。
|
||||
|
||||
### 与现有模块的定位关系
|
||||
|
||||
```
|
||||
KnowledgeStore: 页面级内容("什么是 X") ← Phase 6 已有
|
||||
VectorStore: 向量语义(相似度检索) ← Phase 15 已有
|
||||
KnowledgeGraph: 实体级关系("X 与什么相关") ← Phase 19 新增
|
||||
MemoryRetriever: 统一检索入口 ← Phase 19 扩展为双通道
|
||||
```
|
||||
|
||||
### 依赖与优先级
|
||||
|
||||
- **依赖**:Phase 6(KnowledgeStore)[高]、Phase 15(VectorStore 模式参考)[低]
|
||||
- **优先级**:P0(v0.3.0 最后一个 Phase)
|
||||
- **预估规模**:约 600 行核心 + 200 行测试
|
||||
|
||||
---
|
||||
|
||||
## 需求分析
|
||||
|
||||
### 功能需求
|
||||
|
||||
| ID | 需求 | 优先级 |
|
||||
|----|------|--------|
|
||||
| F1 | `GraphEntity` / `GraphRelation` / `RelationDirection` 类型定义 | P0 |
|
||||
| F2 | `KnowledgeGraph` trait(10 个 async 方法) | P0 |
|
||||
| F3 | `InMemoryGraph` 实现(HashMap + Vec + tag_index) | P0 |
|
||||
| F4 | BFS 图遍历(防环、权重衰减、方向过滤) | P0 |
|
||||
| F5 | `RetrievalItem` / `RetrievalStrategy` / `RetrievalResult` 扩展 | P0 |
|
||||
| F6 | `MemoryRetriever` 双通道(`tokio::join!` 并行) | P0 |
|
||||
| F7 | 标签管理(set_entity_tags / find_tags / entity_count_by_tag) | P1(预留) |
|
||||
|
||||
### 非功能需求
|
||||
|
||||
| ID | 需求 | 说明 |
|
||||
|----|------|------|
|
||||
| NF1 | 零新依赖 | 纯 std + tokio + 已有 crate |
|
||||
| NF2 | 异步安全 | `InMemoryGraph` 内部 Mutex 保护,trait 方法 async |
|
||||
| NF3 | 类型安全 | 不新增 `MemoryError` 变体,复用现有 5 个 |
|
||||
| NF4 | 向后兼容 | `MemoryRetriever::new()` 签名不变,可选链式注入 graph |
|
||||
| NF5 | Breaking change 受控 | `RetrievalResult.items` 类型变化,需在 CHANGELOG 标注 |
|
||||
|
||||
---
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 3.1 数据模型
|
||||
|
||||
#### GraphEntity
|
||||
|
||||
```rust
|
||||
/// 图谱实体 —— 表示一个可被关联检索的节点。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GraphEntity {
|
||||
/// 唯一标识(如 "person:rust-dev-01")。
|
||||
pub id: String,
|
||||
/// 实体名称(用于展示和关键词匹配)。
|
||||
pub name: String,
|
||||
/// 实体类型("person" | "concept" | "project" | ...)。
|
||||
pub entity_type: String,
|
||||
/// 一句话描述。
|
||||
pub description: String,
|
||||
/// 检索标签(全小写,原子词,由 Agent 层显式写入)。
|
||||
pub tags: Vec<String>,
|
||||
/// 任意附加属性(与 PersistentVectorStore.metadata 保持一致)。
|
||||
pub properties: HashMap<String, String>,
|
||||
}
|
||||
```
|
||||
|
||||
#### GraphRelation
|
||||
|
||||
```rust
|
||||
/// 图谱关系 —— 连接两个实体的有向边。
|
||||
///
|
||||
/// 无 `id` 字段,用 `(source_id, target_id, relation_type)` 三元组唯一标识。
|
||||
/// 提供 `composite_key()` 作为派生 id,满足未来独立 id 需求。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GraphRelation {
|
||||
/// 源实体 ID。
|
||||
pub source_id: String,
|
||||
/// 目标实体 ID。
|
||||
pub target_id: String,
|
||||
/// 关系类型("works_on" | "part_of" | "related_to" | ...)。
|
||||
pub relation_type: String,
|
||||
/// 关系强度 [0.0, 1.0],用于 BFS 评分衰减。
|
||||
pub weight: f32,
|
||||
}
|
||||
|
||||
impl GraphRelation {
|
||||
/// 复合键:`source_id:target_id:relation_type`,用于去重和查找。
|
||||
pub fn composite_key(&self) -> String {
|
||||
format!("{}:{}:{}", self.source_id, self.target_id, self.relation_type)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### RelationDirection
|
||||
|
||||
```rust
|
||||
/// 关系遍历方向。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RelationDirection {
|
||||
/// 仅出边:source_id → target_id(默认)。
|
||||
Outgoing,
|
||||
/// 仅入边:target_id → source_id。
|
||||
Incoming,
|
||||
/// 双向遍历。
|
||||
Both,
|
||||
}
|
||||
|
||||
impl Default for RelationDirection {
|
||||
fn default() -> Self {
|
||||
Self::Outgoing
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### ScoredEntity
|
||||
|
||||
```rust
|
||||
/// 带评分的实体 + 路径信息。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScoredEntity {
|
||||
pub entity: GraphEntity,
|
||||
/// 基于图距离的评分 [0.0, 1.0],沿路径权重乘积衰减。
|
||||
pub score: f32,
|
||||
/// 从查询实体到当前实体的 ID 路径(用于可解释性)。
|
||||
pub path: Vec<String>,
|
||||
}
|
||||
```
|
||||
|
||||
#### TagConstraints
|
||||
|
||||
```rust
|
||||
/// 标签约束配置。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TagConstraints {
|
||||
/// 每个实体最多标签数(默认 8)。
|
||||
pub max_tags_per_entity: usize,
|
||||
}
|
||||
|
||||
impl Default for TagConstraints {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_tags_per_entity: 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 KnowledgeGraph trait
|
||||
|
||||
```rust
|
||||
/// 知识图谱抽象 —— 实体-关系存储与图遍历检索。
|
||||
///
|
||||
/// 所有方法 `async + Send + Sync`,支持跨 `.await` 调用。
|
||||
/// 复用 `MemoryError`,不新增变体。
|
||||
#[async_trait]
|
||||
pub trait KnowledgeGraph: Send + Sync {
|
||||
// ── 实体管理 ──
|
||||
|
||||
/// 添加或更新实体(upsert 语义)。
|
||||
async fn add_entity(&self, entity: GraphEntity) -> Result<(), MemoryError>;
|
||||
|
||||
/// 按 ID 获取实体,不存在返回 `Ok(None)`。
|
||||
async fn get_entity(&self, id: &str) -> Result<Option<GraphEntity>, MemoryError>;
|
||||
|
||||
/// 删除实体及其所有关联关系。
|
||||
async fn remove_entity(&self, id: &str) -> Result<(), MemoryError>;
|
||||
|
||||
// ── 关系管理 ──
|
||||
|
||||
/// 添加关系(若复合键已存在则覆盖 weight)。
|
||||
async fn add_relation(&self, relation: GraphRelation) -> Result<(), MemoryError>;
|
||||
|
||||
/// 按复合键删除关系。
|
||||
async fn remove_relation(
|
||||
&self,
|
||||
source_id: &str,
|
||||
target_id: &str,
|
||||
relation_type: &str,
|
||||
) -> Result<(), MemoryError>;
|
||||
|
||||
/// 从指定实体出发,BFS 遍历 depth 层,返回关联实体(带评分)。
|
||||
///
|
||||
/// - `direction`:遍历方向(Outgoing / Incoming / Both)
|
||||
/// - `relation_types`:可选过滤,仅遍历指定关系类型
|
||||
async fn get_related(
|
||||
&self,
|
||||
entity_id: &str,
|
||||
depth: usize,
|
||||
direction: RelationDirection,
|
||||
relation_types: Option<&[&str]>,
|
||||
) -> Result<Vec<ScoredEntity>, MemoryError>;
|
||||
|
||||
// ── 检索 ──
|
||||
|
||||
/// 按关键词子串匹配实体(不区分大小写),与 KnowledgeStore.search 一致。
|
||||
async fn find_by_keywords(&self, keywords: &[String]) -> Result<Vec<GraphEntity>, MemoryError>;
|
||||
|
||||
// ── 标签管理(预留接口,Agent 层显式写入) ──
|
||||
|
||||
/// 按前缀查找已有标签(用于标签复用)。
|
||||
async fn find_tags(&self, prefix: &str) -> Result<Vec<String>, MemoryError>;
|
||||
|
||||
/// 设置实体标签(替换式,保留前 max_tags_per_entity 个)。
|
||||
/// 返回实际设置的标签数。
|
||||
async fn set_entity_tags(
|
||||
&self,
|
||||
entity_id: &str,
|
||||
tags: Vec<String>,
|
||||
) -> Result<usize, MemoryError>;
|
||||
|
||||
/// 按标签统计实体数量。
|
||||
async fn entity_count_by_tag(&self, tag: &str) -> Result<usize, MemoryError>;
|
||||
|
||||
/// 获取标签约束配置。
|
||||
fn tag_constraints(&self) -> TagConstraints;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 InMemoryGraph 实现
|
||||
|
||||
#### 内部结构
|
||||
|
||||
```rust
|
||||
/// 内存知识图谱实现 —— 纯内存,无持久化。
|
||||
///
|
||||
/// 生命周期跟随实例;持久化路径参考 InMemoryVectorStore → PersistentVectorStore 演进模式。
|
||||
pub struct InMemoryGraph {
|
||||
/// 内部状态(单一锁结构,避免嵌套锁死锁)
|
||||
inner: Mutex<GraphInner>,
|
||||
/// 标签约束
|
||||
constraints: TagConstraints,
|
||||
}
|
||||
|
||||
struct GraphInner {
|
||||
/// id → entity
|
||||
entities: HashMap<String, GraphEntity>,
|
||||
/// 所有关系(线性扫描,实测 5000 条 ≈ 1-50µs,无需邻接表索引)
|
||||
relations: Vec<GraphRelation>,
|
||||
/// tag → entity_ids(反向索引,用于 find_tags / entity_count_by_tag)
|
||||
tag_index: HashMap<String, HashSet<String>>,
|
||||
}
|
||||
```
|
||||
|
||||
#### BFS 遍历算法
|
||||
|
||||
```rust
|
||||
async fn get_related(
|
||||
&self,
|
||||
entity_id: &str,
|
||||
depth: usize,
|
||||
direction: RelationDirection,
|
||||
relation_types: Option<&[&str]>,
|
||||
) -> Result<Vec<ScoredEntity>, MemoryError> {
|
||||
// 1. 验证起点存在
|
||||
let inner = self.inner.lock().unwrap();
|
||||
if !inner.entities.contains_key(entity_id) {
|
||||
return Err(MemoryError::NotFound(entity_id.to_string()));
|
||||
}
|
||||
|
||||
// 2. BFS 初始化
|
||||
let mut visited: HashSet<String> = HashSet::new();
|
||||
let mut result: Vec<ScoredEntity> = Vec::new();
|
||||
// 队列:(entity_id, score, path)
|
||||
let mut queue: VecDeque<(String, f32, Vec<String>)> = VecDeque::new();
|
||||
|
||||
queue.push_back((entity_id.to_string(), 1.0, vec![entity_id.to_string()]));
|
||||
visited.insert(entity_id.to_string());
|
||||
|
||||
// 3. BFS 逐层遍历
|
||||
for _ in 0..depth {
|
||||
let mut next_queue: VecDeque<(String, f32, Vec<String>)> = VecDeque::new();
|
||||
|
||||
while let Some((current_id, score, path)) = queue.pop_front() {
|
||||
// 筛选与 current_id 相关的关系
|
||||
for rel in inner.relations.iter() {
|
||||
// 方向过滤
|
||||
let (match_source, match_target) = match direction {
|
||||
RelationDirection::Outgoing => (&rel.source_id, &rel.target_id),
|
||||
RelationDirection::Incoming => (&rel.target_id, &rel.source_id),
|
||||
RelationDirection::Both => {
|
||||
if rel.source_id == current_id {
|
||||
(&rel.source_id, &rel.target_id)
|
||||
} else if rel.target_id == current_id {
|
||||
(&rel.target_id, &rel.source_id)
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if *match_source != current_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 关系类型过滤
|
||||
if let Some(types) = relation_types {
|
||||
if !types.contains(&rel.relation_type.as_str()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let neighbor_id = match_target.clone();
|
||||
if visited.contains(&neighbor_id) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(neighbor_id.clone());
|
||||
|
||||
// 权重乘积衰减
|
||||
let new_score = score * rel.weight;
|
||||
let mut new_path = path.clone();
|
||||
new_path.push(neighbor_id.clone());
|
||||
|
||||
result.push(ScoredEntity {
|
||||
entity: inner.entities.get(&neighbor_id).cloned()
|
||||
.ok_or_else(|| MemoryError::NotFound(neighbor_id.clone()))?,
|
||||
score: new_score,
|
||||
path: new_path.clone(),
|
||||
});
|
||||
|
||||
next_queue.push_back((neighbor_id, new_score, new_path));
|
||||
}
|
||||
}
|
||||
|
||||
queue = next_queue;
|
||||
}
|
||||
|
||||
// 4. 按分数降序排列
|
||||
result.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
Ok(result)
|
||||
}
|
||||
```
|
||||
|
||||
`depth=0` 时仅验证起点实体存在,返回空关联列表(不遍历任何边)。
|
||||
|
||||
**BFS 关键设计点**:
|
||||
|
||||
| 特性 | 处理方式 |
|
||||
|------|----------|
|
||||
| 环路 | `visited: HashSet<String>` 已访问集合防环 |
|
||||
| 评分衰减 | 沿路径 `score *= rel.weight`,权重乘积 |
|
||||
| 多路径 | BFS 天然先到先得,同一实体只保留首次到达路径 |
|
||||
| 关系类型过滤 | `relation_types: Option<&[&str]>`,`None` 表示不过滤 |
|
||||
| 方向过滤 | `RelationDirection` 枚举,`Both` 时双向检查 |
|
||||
|
||||
> 以上性能数据为基于算法复杂度的估算值(O(R) 线性扫描,R=关系数),实际性能需通过基准测试验证。建议在实现后添加 `#[bench]` 或 criterion 基准测试。
|
||||
|
||||
### 3.4 检索扩展
|
||||
|
||||
#### RetrievalStrategy
|
||||
|
||||
```rust
|
||||
/// 检索策略 —— 控制双通道分流。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub enum RetrievalStrategy {
|
||||
/// 并行 KnowledgeStore + KnowledgeGraph,合并排序(默认)。
|
||||
#[default]
|
||||
Hybrid,
|
||||
/// 仅 KnowledgeStore。
|
||||
KnowledgeOnly,
|
||||
/// 仅 KnowledgeGraph。
|
||||
GraphOnly,
|
||||
}
|
||||
```
|
||||
|
||||
#### RetrievalItem
|
||||
|
||||
```rust
|
||||
/// 统一检索条目 —— enum 变体区分类别,两通道分数均在 [0,1] 区间。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RetrievalItem {
|
||||
/// 知识页面(来自 KnowledgeStore)。
|
||||
KnowledgePage {
|
||||
page: KnowledgePage,
|
||||
/// TextOverlap 评分 [0.0, 1.0]。
|
||||
score: f32,
|
||||
},
|
||||
/// 图谱实体(来自 KnowledgeGraph)。
|
||||
GraphEntity {
|
||||
entity: crate::memory::graph::GraphEntity,
|
||||
/// 图距离评分 [0.0, 1.0]。
|
||||
score: f32,
|
||||
/// 从查询实体到当前实体的 ID 路径。
|
||||
path: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl RetrievalItem {
|
||||
/// 统一分数(用于合并排序)。
|
||||
pub fn score(&self) -> f32 {
|
||||
match self {
|
||||
Self::KnowledgePage { score, .. } => *score,
|
||||
Self::GraphEntity { score, .. } => *score,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:两个通道的分数维度不同(TextOverlap vs 图距离),合并排序仅用于统一返回,不代表跨通道可比性。
|
||||
|
||||
#### 向后兼容导出(ScoredItem)
|
||||
|
||||
```rust
|
||||
// ── 向后兼容导出 ──
|
||||
|
||||
/// 旧版带评分的知识页面检索结果(已废弃)。
|
||||
///
|
||||
/// 请迁移到 `RetrievalItem::KnowledgePage { page, score }`。
|
||||
#[deprecated(since = "0.3.0", note = "使用 RetrievalItem::KnowledgePage 代替")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScoredItem {
|
||||
pub page: KnowledgePage,
|
||||
pub score: f32,
|
||||
}
|
||||
|
||||
// 在 memory.rs 模块根的重导出中保留:
|
||||
// #[allow(deprecated)]
|
||||
// pub use retriever::ScoredItem;
|
||||
```
|
||||
|
||||
#### RetrievalResult 更新
|
||||
|
||||
```rust
|
||||
/// 检索结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetrievalResult {
|
||||
/// 统一条目列表,按分数降序排列。
|
||||
pub items: Vec<RetrievalItem>,
|
||||
pub query: String,
|
||||
/// 本次检索实际执行的策略(可能因 graph 未注入而退化),而非用户通过 `with_strategy()` 配置的值。
|
||||
pub strategy: RetrievalStrategy,
|
||||
}
|
||||
```
|
||||
|
||||
#### MemoryRetriever 扩展
|
||||
|
||||
```rust
|
||||
pub struct MemoryRetriever {
|
||||
knowledge_store: KnowledgeStore,
|
||||
/// 可选知识图谱(None 时退化为单通道)。
|
||||
knowledge_graph: Option<Arc<dyn KnowledgeGraph>>,
|
||||
/// 检索策略(默认 Hybrid)。
|
||||
strategy: RetrievalStrategy,
|
||||
config: RetrieverConfig,
|
||||
stop_words: HashSet<String>,
|
||||
}
|
||||
|
||||
impl MemoryRetriever {
|
||||
/// 创建新的 MemoryRetriever(保持向后兼容)。
|
||||
pub fn new(knowledge_store: KnowledgeStore, config: RetrieverConfig) -> Self {
|
||||
Self {
|
||||
knowledge_store,
|
||||
knowledge_graph: None,
|
||||
strategy: RetrievalStrategy::default(),
|
||||
config,
|
||||
stop_words: default_stop_words(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 注入知识图谱,启用双通道检索。
|
||||
pub fn with_knowledge_graph(mut self, graph: Arc<dyn KnowledgeGraph>) -> Self {
|
||||
self.knowledge_graph = Some(graph);
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置检索策略。
|
||||
pub fn with_strategy(mut self, strategy: RetrievalStrategy) -> Self {
|
||||
self.strategy = strategy;
|
||||
self
|
||||
}
|
||||
|
||||
/// 检索相关记忆(双通道)。
|
||||
pub async fn retrieve(&self, query: &str) -> Result<RetrievalResult, MemoryError> {
|
||||
if query.is_empty() {
|
||||
return Ok(RetrievalResult {
|
||||
items: Vec::new(),
|
||||
query: query.to_string(),
|
||||
strategy: self.strategy.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let keywords = extract_keywords(query, &self.stop_words);
|
||||
let has_graph = self.knowledge_graph.is_some();
|
||||
|
||||
// 按策略分流
|
||||
match (&self.strategy, has_graph) {
|
||||
// 仅知识页面
|
||||
(RetrievalStrategy::KnowledgeOnly, _) | (_, false) => {
|
||||
let items = self.search_knowledge_store(query, &keywords).await?;
|
||||
Ok(RetrievalResult {
|
||||
items,
|
||||
query: query.to_string(),
|
||||
strategy: RetrievalStrategy::KnowledgeOnly,
|
||||
})
|
||||
}
|
||||
// 仅图谱
|
||||
(RetrievalStrategy::GraphOnly, true) => {
|
||||
let graph = self.knowledge_graph.as_ref().unwrap();
|
||||
let items = self.search_graph(query, &keywords, graph).await?;
|
||||
Ok(RetrievalResult {
|
||||
items,
|
||||
query: query.to_string(),
|
||||
strategy: self.strategy.clone(),
|
||||
})
|
||||
}
|
||||
// 混合:并行执行,合并排序
|
||||
(RetrievalStrategy::Hybrid, true) => {
|
||||
let graph = self.knowledge_graph.as_ref().unwrap();
|
||||
let (kp_items, g_items) = tokio::join!(
|
||||
self.search_knowledge_store(query, &keywords),
|
||||
self.search_graph(query, &keywords, graph),
|
||||
);
|
||||
|
||||
let mut items = kp_items?;
|
||||
items.extend(g_items?);
|
||||
items.sort_by(|a, b| {
|
||||
b.score().partial_cmp(&a.score())
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
items.truncate(self.config.max_results);
|
||||
|
||||
Ok(RetrievalResult {
|
||||
items,
|
||||
query: query.to_string(),
|
||||
strategy: self.strategy.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 标签管理
|
||||
|
||||
#### 标签索引维护
|
||||
|
||||
`tag_index: HashMap<String, HashSet<String>>` 维护 tag → entity_ids 反向映射:
|
||||
|
||||
- **`set_entity_tags`**:先清除旧标签的反向引用,再写入新标签。超出 `max_tags_per_entity` 时截断。
|
||||
- **`find_tags`**:遍历 `tag_index.keys()`,按前缀过滤。
|
||||
- **`entity_count_by_tag`**:直接返回 `tag_index.get(tag).map_or(0, |s| s.len())`。
|
||||
|
||||
#### 实现要点
|
||||
|
||||
```rust
|
||||
async fn set_entity_tags(
|
||||
&self,
|
||||
entity_id: &str,
|
||||
tags: Vec<String>,
|
||||
) -> Result<usize, MemoryError> {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
let entity = inner.entities.get_mut(entity_id)
|
||||
.ok_or_else(|| MemoryError::NotFound(entity_id.to_string()))?;
|
||||
|
||||
// 清除旧标签的反向引用
|
||||
for old_tag in &entity.tags {
|
||||
if let Some(ids) = inner.tag_index.get_mut(old_tag) {
|
||||
ids.remove(entity_id);
|
||||
if ids.is_empty() {
|
||||
inner.tag_index.remove(old_tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 截断到 max_tags_per_entity
|
||||
let max = self.constraints.max_tags_per_entity;
|
||||
let new_tags: Vec<String> = tags.into_iter().take(max).collect();
|
||||
|
||||
// 写入新标签的反向引用
|
||||
for tag in &new_tags {
|
||||
inner.tag_index.entry(tag.clone())
|
||||
.or_default()
|
||||
.insert(entity_id.to_string());
|
||||
}
|
||||
|
||||
entity.tags = new_tags.clone();
|
||||
Ok(new_tags.len())
|
||||
}
|
||||
```
|
||||
|
||||
#### 标签复用流程(文档说明)
|
||||
|
||||
```
|
||||
LLM 提取候选标签 → 对每个候选:
|
||||
graph.find_tags(candidate.lowercase())
|
||||
├─ 命中已有标签 → 复用
|
||||
└─ 无匹配 → 注册新标签
|
||||
```
|
||||
|
||||
> **标注**:当前无自动提取流程,需 Agent 层显式调用 `set_entity_tags`。
|
||||
|
||||
---
|
||||
|
||||
## 实现计划
|
||||
|
||||
| Step | 内容 | 文件范围 | 验证标准 | 预估行数 |
|
||||
|------|------|----------|----------|----------|
|
||||
| 1 | `graph.rs` 核心类型:GraphEntity / GraphRelation / RelationDirection / ScoredEntity / TagConstraints | `src/memory/graph.rs` | `cargo check` 编译通过 | ~80 |
|
||||
| 2 | `KnowledgeGraph` trait 定义(10 个 async 方法) | `src/memory/graph.rs` | trait 编译通过,无未实现方法 | ~90 |
|
||||
| 3 | `InMemoryGraph` 实现 + BFS 遍历 | `src/memory/graph.rs` | 单元测试:添加实体/关系、BFS 遍历、方向过滤、类型过滤 | ~250 |
|
||||
| 4 | 标签管理实现(set_entity_tags / find_tags / entity_count_by_tag) | `src/memory/graph.rs` | 单元测试:标签增删查、截断、反向索引维护 | ~80 |
|
||||
| 5 | `retriever.rs` 扩展:RetrievalItem / RetrievalStrategy / MemoryRetriever 改造 | `src/memory/retriever.rs` | `cargo check` + 双通道检索测试 | ~180 |
|
||||
| 6 | `memory.rs` 模块根更新 + 重导出 | `src/memory.rs` | `cargo check`,pub use 无编译错误 | ~10 |
|
||||
| 7 | 内联测试 | `src/memory/graph.rs` + `src/memory/retriever.rs` | `cargo test --all-targets` 全绿 | ~150 |
|
||||
|
||||
**总预估**:约 840 行(核心 640 + 测试 200)
|
||||
|
||||
---
|
||||
|
||||
## 风险评估
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| 标签 API 无消费者 | 低 — 预留接口,不影响核心功能 | 文档标注"Agent 层显式写入",后续 Phase 接入 |
|
||||
| 评分不可比 | 中 — TextOverlap vs 图距离维度不同 | `RetrievalItem` enum 变体分离,合并排序仅统一返回,文档注明维度差异 |
|
||||
| BFS 性能 | 低 — 5000 关系遍历 ≈ 1-50µs | 不引入邻接表索引,等实测超过 1ms 再优化 |
|
||||
| Breaking change | 中 — `RetrievalResult.items` 类型变化 | CHANGELOG 标注,`ScoredItem` 保留为 `pub` 兼容导出(deprecate) |
|
||||
| Mutex 竞争 | 低 — InMemoryGraph 单实例场景 | 读多写少, Mutex 性能足够;后续可升级 RwLock |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
| 检查项 | 标准 | 验证命令 |
|
||||
|--------|------|----------|
|
||||
| 编译 | 0 error | `cargo check --all-targets` |
|
||||
| 测试 | 全绿,测试数从 ~391 增至 ~410+ | `cargo test --all-targets` |
|
||||
| Clippy | 0 warning | `cargo clippy --all-targets -- -D warnings` |
|
||||
| 文档 | 0 warning | `cargo doc --no-deps` |
|
||||
| BFS 覆盖 | 所有边界条件:空图、单实体、环路、深度 0、方向过滤、类型过滤 | 内联测试 |
|
||||
| 双通道 | Hybrid / KnowledgeOnly / GraphOnly 三种策略功能正确 | 内联测试 |
|
||||
| 向后兼容 | `MemoryRetriever::new()` 签名不变,现有调用无需修改 | `cargo check` 无 breaking error |
|
||||
@@ -0,0 +1,694 @@
|
||||
# AG Core v0.3.2 Step 1(Phase 20)— Cargo Features 基础设施改造实施方案
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
**背景**:agcore 是一个 Rust 编写的智能体核心工具箱,目前约 23,718 行、66 个源文件。v0.3.0 发布后,所有模块在编译时全量捆绑,下游用户无法按需选择模块,即使只使用 LLM 对话也需要编译 sqlite / MCP / agent 引擎等全部依赖。
|
||||
|
||||
**目标**:通过 Cargo features 拆分让下游按需选择模块。Step 1 是基础设施变更 —— `Cargo.toml` features 定义 + 依赖 optional 化 + 必要的子模块 cfg 门控,编译通过后打 checkpoint。
|
||||
|
||||
**预期效果**:
|
||||
- `default = ["full"]` → v0.3.0 用户零迁移成本
|
||||
- 最小组合(`document`)零重型外部依赖(仅依赖始终编译的轻量依赖:serde/serde_json/thiserror/async-trait/tracing)
|
||||
- 纯对话组合(`chat + provider-openai`)仅需 ~10 个依赖,不含 sqlite / MCP / engine
|
||||
|
||||
## 2. 需求分析
|
||||
|
||||
### 2.1 约束条件
|
||||
|
||||
| # | 约束 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `default = ["full"]` | 保持向后兼容,v0.3.0 用户零迁移成本 |
|
||||
| 2 | `document` feature 零重型外部依赖(仅依赖始终编译的轻量依赖:serde/serde_json/thiserror/async-trait/tracing) | 纯 std + 始终编译的轻量依赖(serde/serde_json/thiserror/async-trait/tracing) |
|
||||
| 3 | tokio 从 `["full"]` 拆细 | 已验证全库无 net/fs/signal 使用,拆为 `["rt", "sync", "time", "macros", "process", "io-util"]` |
|
||||
| 4 | 重型依赖全部 optional | tokio、reqwest、rusqlite、tracing-subscriber、tokio-stream、futures、futures-util、futures-core、bytes、async-stream、tokio-util、time |
|
||||
| 5 | 始终编译的轻量依赖 | serde、serde_json、thiserror、async-trait、tracing |
|
||||
|
||||
### 2.2 关键决策
|
||||
|
||||
| # | 决策 | 理由 |
|
||||
|---|------|------|
|
||||
| 1 | `tools` feature 必须 `imply tokio` | `src/tools/registry.rs` 使用 `tokio::time::timeout` |
|
||||
| 2 | `pub mod llm` 门控条件为 `any(feature = "llm-types", feature = "llm")` | `prompt → llm-types` 路径需要 llm 模块编译,但只需 types 子模块 |
|
||||
| 3 | 测试 dev-dependencies 加 `tokio = { version = "1", features = ["rt", "macros"] }` | 现有 `#[tokio::test]` 需要 tokio runtime |
|
||||
| 4 | 快捷组合名保持原名(chat/multi/light) | 文档中说明各组合包含的 feature 约束 |
|
||||
| 5 | `init_tracing()` 函数整体用 `#[cfg(feature = "tracing-init")]` 包裹 | 避免 `use tracing_subscriber` 出现在未启用 feature 时编译失败 |
|
||||
|
||||
## 3. 方案设计
|
||||
|
||||
### 3.1 Features 定义(完整 Cargo.toml `[features]` 草案)
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = ["full"]
|
||||
|
||||
# === 模块级 features ===
|
||||
document = []
|
||||
llm-types = []
|
||||
prompt = ["llm-types"]
|
||||
llm = ["llm-types", "tokio", "async-stream", "futures-core", "tokio-stream"]
|
||||
tools = ["llm-types", "futures", "tokio-util", "tokio"]
|
||||
tools-mcp = ["tools", "reqwest"]
|
||||
# memory 模块依赖 llm(conversation/vector_store 使用 compact/embedding)、tokio(knowledge.rs 使用 Mutex)、time(types.rs 使用 OffsetDateTime)
|
||||
memory = ["document", "llm", "tokio", "time"]
|
||||
memory-sqlite = ["memory", "rusqlite", "time"]
|
||||
agent = ["llm", "tools", "memory", "futures-util"]
|
||||
engine = ["agent"]
|
||||
|
||||
# === Provider features ===
|
||||
# Provider features — openai/anthropic 额外依赖 bytes(流式解析)和 futures-util(Stream 组合)
|
||||
provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
|
||||
provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
|
||||
# deepseek/qwen 使用 openai_compat 适配层,不需要 bytes 和 futures-util
|
||||
provider-deepseek = ["llm", "reqwest"]
|
||||
provider-qwen = ["llm", "reqwest"]
|
||||
provider-ollama = ["llm", "reqwest"]
|
||||
|
||||
# === 工具 features ===
|
||||
tracing-init = ["tracing-subscriber"]
|
||||
|
||||
# === 快捷组合 ===
|
||||
full = [
|
||||
"document", "llm-types", "prompt", "llm",
|
||||
"tools", "tools-mcp",
|
||||
"memory", "memory-sqlite",
|
||||
"agent", "engine",
|
||||
"provider-openai", "provider-anthropic", "provider-deepseek",
|
||||
"provider-qwen", "provider-ollama",
|
||||
"tracing-init",
|
||||
]
|
||||
light = ["llm", "provider-openai", "tools", "tools-mcp", "memory", "agent", "engine", "prompt", "document"]
|
||||
chat = ["agent", "provider-openai"]
|
||||
multi = ["engine", "provider-openai"]
|
||||
```
|
||||
|
||||
**features 依赖图(简略)**:
|
||||
|
||||
```
|
||||
document (零外部依赖)
|
||||
└── memory (+llm, +tokio, +time) ─── memory-sqlite (+rusqlite, +time)
|
||||
|
||||
llm-types (零依赖)
|
||||
├── prompt
|
||||
└── llm (+tokio, +async-stream, +futures-core, +tokio-stream)
|
||||
├── tools (+futures, +tokio-util) ─── tools-mcp (+reqwest)
|
||||
├── provider-openai / provider-anthropic (+reqwest, +bytes, +futures-util)
|
||||
├── provider-deepseek / provider-qwen / provider-ollama (+reqwest)
|
||||
└── agent (+tools, +memory, +futures-util) ─── engine
|
||||
```
|
||||
|
||||
### 3.2 依赖 optional 化方案
|
||||
|
||||
**始终编译(5 个,不参与门控)**:
|
||||
|
||||
```toml
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
async-trait = "0.1"
|
||||
tracing = "0.1"
|
||||
```
|
||||
|
||||
**12 个依赖加 `optional = true`**:
|
||||
|
||||
| 依赖 | 原声明 | 新声明 |
|
||||
|------|--------|--------|
|
||||
| tokio | `{ version = "1", features = ["full"] }` | `{ version = "1", features = ["rt", "sync", "time", "macros", "process", "io-util"], optional = true }` |
|
||||
| reqwest | `{ version = "0.12", features = ["json", "stream"] }` | `{ version = "0.12", features = ["json", "stream"], optional = true }` |
|
||||
| rusqlite | `{ version = "0.32", features = ["bundled"] }` | `{ version = "0.32", features = ["bundled"], optional = true }` |
|
||||
| tracing-subscriber | `{ version = "0.3", features = ["env-filter"] }` | `{ version = "0.3", features = ["env-filter"], optional = true }` |
|
||||
| tokio-stream | `{ version = "0.1" }` | `{ version = "0.1", optional = true }` |
|
||||
| futures | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
|
||||
| futures-util | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
|
||||
| futures-core | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
|
||||
| bytes | `{ version = "1" }` | `{ version = "1", optional = true }` |
|
||||
| async-stream | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
|
||||
| tokio-util | `{ version = "0.7", features = ["rt", "sync"] }` | `{ version = "0.7", features = ["rt", "sync"], optional = true }` |
|
||||
| time | `{ version = "0.3", features = ["serde", "parsing", "formatting", "macros"] }` | `{ version = "0.3", features = ["serde", "parsing", "formatting", "macros"], optional = true }` |
|
||||
|
||||
**dev-dependencies 新增**:
|
||||
|
||||
```toml
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["rt", "macros"] }
|
||||
```
|
||||
|
||||
### 3.3 源文件改动清单
|
||||
|
||||
共涉及 **8 个文件**(预估 ~100 行改动):`Cargo.toml`、`src/lib.rs`、`src/llm.rs`、`src/llm/cycle.rs`、`src/tools.rs`、`src/memory.rs`、`src/memory/store.rs`、`src/agent/session.rs`
|
||||
|
||||
---
|
||||
|
||||
#### 文件 1:`Cargo.toml`
|
||||
|
||||
**改动 1.1** — 新增 `[features]` 表(约 45 行,插入在 `[package]` 之后、`[dependencies]` 之前)
|
||||
|
||||
```diff
|
||||
+ [features]
|
||||
+ default = ["full"]
|
||||
+
|
||||
+ # === 模块级 features ===
|
||||
+ document = []
|
||||
+ llm-types = []
|
||||
+ prompt = ["llm-types"]
|
||||
+ llm = ["llm-types", "tokio", "async-stream", "futures-core", "tokio-stream"]
|
||||
+ tools = ["llm-types", "futures", "tokio-util", "tokio"]
|
||||
+ tools-mcp = ["tools", "reqwest"]
|
||||
+ # memory 模块依赖 llm(conversation/vector_store 使用 compact/embedding)、tokio(knowledge.rs 使用 Mutex)、time(types.rs 使用 OffsetDateTime)
|
||||
+ memory = ["document", "llm", "tokio", "time"]
|
||||
+ memory-sqlite = ["memory", "rusqlite", "time"]
|
||||
+ agent = ["llm", "tools", "memory", "futures-util"]
|
||||
+ engine = ["agent"]
|
||||
+
|
||||
+ # === Provider features ===
|
||||
+ # Provider features — openai/anthropic 额外依赖 bytes(流式解析)和 futures-util(Stream 组合)
|
||||
+ provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
|
||||
+ provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
|
||||
+ # deepseek/qwen 使用 openai_compat 适配层,不需要 bytes 和 futures-util
|
||||
+ provider-deepseek = ["llm", "reqwest"]
|
||||
+ provider-qwen = ["llm", "reqwest"]
|
||||
+ provider-ollama = ["llm", "reqwest"]
|
||||
+
|
||||
+ # === 工具 features ===
|
||||
+ tracing-init = ["tracing-subscriber"]
|
||||
+
|
||||
+ # === 快捷组合 ===
|
||||
+ full = [
|
||||
+ "document", "llm-types", "prompt", "llm",
|
||||
+ "tools", "tools-mcp",
|
||||
+ "memory", "memory-sqlite",
|
||||
+ "agent", "engine",
|
||||
+ "provider-openai", "provider-anthropic", "provider-deepseek",
|
||||
+ "provider-qwen", "provider-ollama",
|
||||
+ "tracing-init",
|
||||
+ ]
|
||||
+ light = ["llm", "provider-openai", "tools", "tools-mcp", "memory", "agent", "engine", "prompt", "document"]
|
||||
+ chat = ["agent", "provider-openai"]
|
||||
+ multi = ["engine", "provider-openai"]
|
||||
```
|
||||
|
||||
**改动 1.2** — tokio 依赖声明修改
|
||||
|
||||
```diff
|
||||
- tokio = { version = "1", features = ["full"] }
|
||||
+ tokio = { version = "1", features = ["rt", "sync", "time", "macros", "process", "io-util"], optional = true }
|
||||
```
|
||||
|
||||
**改动 1.3** — 11 个重型依赖逐行加 `optional = true`
|
||||
|
||||
```diff
|
||||
- reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
+ reqwest = { version = "0.12", features = ["json", "stream"], optional = true }
|
||||
|
||||
- rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
+ rusqlite = { version = "0.32", features = ["bundled"], optional = true }
|
||||
|
||||
- tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
+ tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||
|
||||
- tokio-stream = "0.1"
|
||||
+ tokio-stream = { version = "0.1", optional = true }
|
||||
|
||||
- futures = "0.3"
|
||||
+ futures = { version = "0.3", optional = true }
|
||||
|
||||
- futures-util = "0.3"
|
||||
+ futures-util = { version = "0.3", optional = true }
|
||||
|
||||
- futures-core = "0.3"
|
||||
+ futures-core = { version = "0.3", optional = true }
|
||||
|
||||
- bytes = "1"
|
||||
+ bytes = { version = "1", optional = true }
|
||||
|
||||
- async-stream = "0.3"
|
||||
+ async-stream = { version = "0.3", optional = true }
|
||||
|
||||
- tokio-util = { version = "0.7", features = ["rt"] }
|
||||
+ tokio-util = { version = "0.7", features = ["rt", "sync"], optional = true }
|
||||
|
||||
- time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"] }
|
||||
+ time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"], optional = true }
|
||||
```
|
||||
|
||||
**改动 1.4** — `[dev-dependencies]` 新增 tokio
|
||||
|
||||
```diff
|
||||
+ [dev-dependencies]
|
||||
+ tokio = { version = "1", features = ["rt", "macros"] }
|
||||
```
|
||||
|
||||
**说明**:如果原 `Cargo.toml` 已有 `[dev-dependencies]` 则追加该行;若无则新增整个 section。
|
||||
|
||||
---
|
||||
|
||||
#### 文件 2:`src/lib.rs`(当前约 26 行 → 改动后约 40 行)
|
||||
|
||||
**当前内容(参考)**:
|
||||
```rust
|
||||
//! agcore —— 智能体(Agent)核心工具箱。
|
||||
|
||||
pub mod llm;
|
||||
pub mod document;
|
||||
pub mod prompt;
|
||||
pub mod tools;
|
||||
pub mod memory;
|
||||
pub mod agent;
|
||||
pub mod engine;
|
||||
|
||||
pub use document::Document;
|
||||
|
||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
static INIT: std::sync::Once = std::sync::Once::new();
|
||||
pub fn init_tracing() {
|
||||
INIT.call_once(|| {
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("agcore=info"));
|
||||
tracing_subscriber::registry()
|
||||
.with(fmt::layer())
|
||||
.with(filter)
|
||||
.init();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**改动后内容**:
|
||||
```diff
|
||||
//! agcore —— 智能体(Agent)核心工具箱。
|
||||
|
||||
- pub mod llm;
|
||||
+ #[cfg(any(feature = "llm-types", feature = "llm"))]
|
||||
+ pub mod llm;
|
||||
- pub mod document;
|
||||
+ #[cfg(feature = "document")]
|
||||
+ pub mod document;
|
||||
- pub mod prompt;
|
||||
+ #[cfg(feature = "prompt")]
|
||||
+ pub mod prompt;
|
||||
- pub mod tools;
|
||||
+ #[cfg(feature = "tools")]
|
||||
+ pub mod tools;
|
||||
- pub mod memory;
|
||||
+ #[cfg(feature = "memory")]
|
||||
+ pub mod memory;
|
||||
- pub mod agent;
|
||||
+ #[cfg(feature = "agent")]
|
||||
+ pub mod agent;
|
||||
- pub mod engine;
|
||||
+ #[cfg(feature = "engine")]
|
||||
+ pub mod engine;
|
||||
|
||||
- pub use document::Document;
|
||||
+ #[cfg(feature = "document")]
|
||||
+ pub use document::Document;
|
||||
|
||||
- use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
- static INIT: std::sync::Once = std::sync::Once::new();
|
||||
- pub fn init_tracing() {
|
||||
- INIT.call_once(|| {
|
||||
- let filter = EnvFilter::try_from_default_env()
|
||||
- .unwrap_or_else(|_| EnvFilter::new("agcore=info"));
|
||||
- tracing_subscriber::registry()
|
||||
- .with(fmt::layer())
|
||||
- .with(filter)
|
||||
- .init();
|
||||
- });
|
||||
- }
|
||||
+ #[cfg(feature = "tracing-init")]
|
||||
+ use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
+
|
||||
+ #[cfg(feature = "tracing-init")]
|
||||
+ static INIT: std::sync::Once = std::sync::Once::new();
|
||||
+
|
||||
+ #[cfg(feature = "tracing-init")]
|
||||
+ pub fn init_tracing() {
|
||||
+ INIT.call_once(|| {
|
||||
+ let filter = EnvFilter::try_from_default_env()
|
||||
+ .unwrap_or_else(|_| EnvFilter::new("agcore=info"));
|
||||
+ tracing_subscriber::registry()
|
||||
+ .with(fmt::layer())
|
||||
+ .with(filter)
|
||||
+ .init();
|
||||
+ });
|
||||
+ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 文件 3:`src/llm.rs`(当前约 12 行 → 改动后约 24 行)
|
||||
|
||||
**改动说明**:为每个子模块声明加 feature 门控。`types` 子模块在 `llm-types` 或 `llm` 任一 feature 启用时编译(`llm` imply `llm-types`,但 `prompt` 也 depend on `llm-types`);其余子模块(compact/convert/cycle 等)仅在 `llm` feature 启用时编译。
|
||||
|
||||
```diff
|
||||
//! LLM 调用周期 —— 大模型基础调用周期控制。
|
||||
|
||||
- pub mod types;
|
||||
+ #[cfg(feature = "llm-types")]
|
||||
+ pub mod types;
|
||||
- pub mod compact;
|
||||
+ #[cfg(feature = "llm")]
|
||||
+ pub mod compact;
|
||||
- pub mod convert;
|
||||
+ #[cfg(feature = "llm")]
|
||||
+ pub mod convert;
|
||||
- pub mod cycle;
|
||||
+ #[cfg(feature = "llm")]
|
||||
+ pub mod cycle;
|
||||
- pub mod embedding;
|
||||
+ #[cfg(feature = "llm")]
|
||||
+ pub mod embedding;
|
||||
- pub mod error;
|
||||
+ #[cfg(feature = "llm")]
|
||||
+ pub mod error;
|
||||
- pub mod hooks;
|
||||
+ #[cfg(feature = "llm")]
|
||||
+ pub mod hooks;
|
||||
- pub mod mock;
|
||||
+ #[cfg(feature = "llm")]
|
||||
+ pub mod mock;
|
||||
- pub mod provider;
|
||||
+ // provider 模块依赖 reqwest(通过 reqwest::Client),仅在任一 provider feature 启用时编译
|
||||
+ #[cfg(any(feature = "provider-openai", feature = "provider-anthropic", feature = "provider-deepseek", feature = "provider-qwen", feature = "provider-ollama"))]
|
||||
+ pub mod provider;
|
||||
- pub mod stream;
|
||||
+ #[cfg(feature = "llm")]
|
||||
+ pub mod stream;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 文件 3b:`src/llm/cycle.rs`(新增文件,约 35 行)
|
||||
|
||||
**改动说明**:`cycle.rs` 中使用 `crate::tools::ToolRegistry`(第 29 行),依赖 `tools` feature。工具相关字段和方法需加 `#[cfg(feature = "tools")]` 门控。
|
||||
|
||||
> ⚠️ 这是 Phase 22.7 原计划的门控变更,因为编译阻塞提前到 Step 1 执行。
|
||||
|
||||
```diff
|
||||
//! Cycle —— 多轮对话与工具调用编排。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt; // 来自 llm→tokio imply 链
|
||||
+ #[cfg(feature = "tools")]
|
||||
use crate::tools::ToolRegistry;
|
||||
|
||||
// ... struct / enum 定义 ...
|
||||
|
||||
// ===== CycleConfig — 工具相关字段加 cfg 门控 =====
|
||||
pub struct CycleConfig {
|
||||
pub max_retries: usize,
|
||||
pub max_history: usize,
|
||||
+ #[cfg(feature = "tools")]
|
||||
pub max_tool_turns: usize,
|
||||
+ #[cfg(feature = "tools")]
|
||||
pub tool_timeout_secs: u64,
|
||||
// ... 其他字段 ...
|
||||
}
|
||||
|
||||
// ===== Cycle — 方法加 cfg 门控 =====
|
||||
impl Cycle {
|
||||
/// 仅在有 tools feature 时才有工具调用相关方法
|
||||
+ #[cfg(feature = "tools")]
|
||||
pub async fn submit_with_tools(&self, ...) -> Result<...> {
|
||||
// ...
|
||||
}
|
||||
|
||||
+ /// submit_with_tools_stream 方法同样需要 tools 门控,
|
||||
+ /// 因参数包含 Arc<ToolRegistry> 而与 submit_with_tools 同理。
|
||||
+ #[cfg(feature = "tools")]
|
||||
+ pub async fn submit_with_tools_stream(
|
||||
+ &self, ... // 方法签名中包含 Arc<ToolRegistry> 参数
|
||||
+ ) -> Result<...> {
|
||||
+ // ...
|
||||
+ }
|
||||
|
||||
+ #[cfg(feature = "tools")]
|
||||
async fn run_tool_loop(&self, ...) -> Result<...> {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
+ // ===== 顶层函数 — 同样依赖 ToolRegistry =====
|
||||
+ /// run_tool_loop 函数(顶层函数,非 LlmCycle 方法)同样依赖 Arc<ToolRegistry>,
|
||||
+ /// 参数包含 Arc<ToolRegistry>,需 #[cfg(feature = "tools")]。
|
||||
+ #[cfg(feature = "tools")]
|
||||
+ pub async fn run_tool_loop(
|
||||
+ // ... 函数签名中包含 Arc<ToolRegistry> 参数
|
||||
+ ) -> Result<...> {
|
||||
+ // ...
|
||||
+ }
|
||||
|
||||
**说明**:`Cycle` 本身的 struct 定义、`submit()` 基础方法、`ResponseStream` 等不依赖 tools 的部分保持无门控,仅在 `llm` feature 下编译即可。
|
||||
|
||||
---
|
||||
|
||||
#### 文件 4:`src/tools.rs`(当前约 13 行 → 改动后约 15 行)
|
||||
|
||||
**改动说明**:`mcp` 子模块及对应的 `pub use` 仅在 `tools-mcp` feature 启用时编译。其余子模块(base/error/permission/registry)始终在 `tools` feature 下编译。
|
||||
|
||||
```diff
|
||||
//! 工具系统 —— 工具抽象、注册、调用、权限控制与 MCP 集成。
|
||||
|
||||
pub mod base;
|
||||
pub mod error;
|
||||
- pub mod mcp;
|
||||
+ #[cfg(feature = "tools-mcp")]
|
||||
+ pub mod mcp;
|
||||
pub mod permission;
|
||||
pub mod registry;
|
||||
|
||||
pub use base::{BaseTool, ToolContext, ToolRef};
|
||||
pub use error::ToolError;
|
||||
- pub use mcp::{McpClient, McpTransport};
|
||||
+ #[cfg(feature = "tools-mcp")]
|
||||
+ pub use mcp::{McpClient, McpTransport};
|
||||
pub use permission::{Permission, PermissionChecker, PermissionConfig};
|
||||
pub use registry::{ToolInvocation, ToolRegistry};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 文件 5:`src/memory/store.rs`(当前约 62 行 → 改动后约 64 行)
|
||||
|
||||
**改动说明**:`sqlite_store` 子模块及其 `pub use` 仅在 `memory-sqlite` feature 启用时编译。
|
||||
|
||||
```diff
|
||||
//! MemoryStore 抽象接口与默认实现。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crate::memory::error::MemoryError;
|
||||
use crate::memory::types::{MemoryFilter, MemoryItem};
|
||||
|
||||
pub mod in_memory;
|
||||
- pub mod sqlite_store;
|
||||
+ #[cfg(feature = "memory-sqlite")]
|
||||
+ pub mod sqlite_store;
|
||||
|
||||
pub use in_memory::InMemoryStore;
|
||||
- pub use sqlite_store::SqliteStore;
|
||||
+ #[cfg(feature = "memory-sqlite")]
|
||||
+ pub use sqlite_store::SqliteStore;
|
||||
```
|
||||
|
||||
**说明**:`MemoryStore` trait、`EvictionConfig`、`EvictionPolicy` 等定义保持不变,不需要 cfg 门控。
|
||||
|
||||
---
|
||||
|
||||
#### 文件 6:`src/memory.rs`(当前约 32 行 → 改动后约 34 行)
|
||||
|
||||
**改动说明**:`SqliteStore` 的重新导出仅在 `memory-sqlite` feature 启用时编译。其余子模块声明和 `pub use` 保持不变(`memory` feature 门控由 `src/lib.rs` 负责)。
|
||||
|
||||
```diff
|
||||
//! 记忆系统 —— 对话消息管理、知识页面存储与关键词检索。
|
||||
|
||||
// 所有子模块声明保持不变:
|
||||
// pub mod conversation;
|
||||
// pub mod error;
|
||||
// pub mod graph;
|
||||
// pub mod knowledge;
|
||||
// pub mod retriever;
|
||||
// pub mod store;
|
||||
// ...
|
||||
|
||||
// 高频类型
|
||||
pub use conversation::{ConversationMemory, ConversationMemoryConfig};
|
||||
pub use error::MemoryError;
|
||||
pub use graph::{GraphEntity, GraphRelation, InMemoryGraph, KnowledgeGraph, RelationDirection, ScoredEntity};
|
||||
pub use knowledge::KnowledgeStore;
|
||||
pub use retriever::MemoryRetriever;
|
||||
pub use store::{InMemoryStore, MemoryStore};
|
||||
+ #[cfg(feature = "memory-sqlite")]
|
||||
+ pub use store::SqliteStore;
|
||||
// 其余 pub use 保持不变...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 文件 7:`src/agent/session.rs`(新增文件,约 30 行)
|
||||
|
||||
**改动说明**:`session.rs` 中引用了 `crate::engine::*`(SessionMemoryEntry、SessionSnapshot、EngineError),而 `agent` feature 不含 `engine`(`engine = ["agent"]` 是反向依赖)。需要对 engine 相关导入和方法加门控。
|
||||
|
||||
> ⚠️ 阻塞 B3:`src/agent/session.rs:28-29` 无条件引用 `crate::engine::*`,在 `agent` feature 下编译时因缺少 engine 而失败。
|
||||
|
||||
```diff
|
||||
//! Session —— Agent 会话管理。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crate::llm::types::LLMRequest;
|
||||
use crate::memory::MemoryStore;
|
||||
+ #[cfg(feature = "engine")]
|
||||
use crate::engine::snapshot::{SessionMemoryEntry, SessionSnapshot};
|
||||
+ #[cfg(feature = "engine")]
|
||||
use crate::engine::EngineError;
|
||||
|
||||
// ===== AgentSession — pending_memory_restore 字段 =====
|
||||
+ /// AgentSession 结构体中的 pending_memory_restore 字段类型来自 engine 模块,
|
||||
+ /// 需要条件编译。
|
||||
pub struct AgentSession {
|
||||
+ // ... 其他字段 ...
|
||||
+
|
||||
+ #[cfg(feature = "engine")]
|
||||
+ pending_memory_restore: Option<HashMap<String, SessionMemoryEntry>>,
|
||||
+ // ... 其他字段 ...
|
||||
+ }
|
||||
|
||||
impl Session {
|
||||
/// to_snapshot / from_snapshot / restore_memory 仅在 engine feature 下可用
|
||||
+ #[cfg(feature = "engine")]
|
||||
pub fn to_snapshot(&self) -> SessionSnapshot {
|
||||
// ...
|
||||
}
|
||||
|
||||
+ #[cfg(feature = "engine")]
|
||||
pub fn from_snapshot(snap: SessionSnapshot) -> Result<Self, EngineError> {
|
||||
// ...
|
||||
}
|
||||
|
||||
+ #[cfg(feature = "engine")]
|
||||
async fn restore_memory(&mut self, entries: Vec<SessionMemoryEntry>) -> Result<(), MemoryError> {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:`Session` 结构体本身以及不依赖 engine 的方法(如 `new()`、`add_message()`、`get_history()`)保持无门控,仅在 `agent` feature 下编译即可。
|
||||
|
||||
---
|
||||
|
||||
## 4. 实施步骤
|
||||
|
||||
按 **3 个 commit** 粒度执行,每个 commit 后编译验证。
|
||||
|
||||
### Commit 1:Cargo.toml features 定义 + 依赖 optional 化
|
||||
|
||||
**涉及文件**:仅 `Cargo.toml`
|
||||
|
||||
**操作清单**:
|
||||
|
||||
1. 在 `[package]` 之后、`[dependencies]` 之前插入 `[features]` 表(16 个 features + 4 个快捷组合,约 45 行)
|
||||
2. tokio features 从 `["full"]` 改为 `["rt", "sync", "time", "macros", "process", "io-util"]` 并加 `optional = true`
|
||||
3. reqwest / rusqlite / tracing-subscriber / tokio-stream / futures / futures-util / futures-core / bytes / async-stream / tokio-util / time 共 11 个依赖加 `optional = true`
|
||||
4. 在 `[dependencies]` 之后新增 `[dev-dependencies]` 加 `tokio = { version = "1", features = ["rt", "macros"] }`
|
||||
|
||||
**验证**:
|
||||
```bash
|
||||
cargo build --no-default-features # 不依赖任何 optional crate,应通过
|
||||
cargo build -F document # 零外部依赖,应通过
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Commit 2:cfg 门控(pub mod + pub use + init_tracing)
|
||||
|
||||
**涉及文件**:`src/lib.rs`、`src/llm.rs`、`src/llm/cycle.rs`、`src/tools.rs`、`src/memory/store.rs`、`src/memory.rs`、`src/agent/session.rs`
|
||||
|
||||
**操作清单**:
|
||||
|
||||
按文件逐一执行:
|
||||
|
||||
1. `src/lib.rs` — 7 个 `pub mod` 加 `#[cfg(feature = "...")]`、`Document` pub use 加 cfg、`init_tracing` 整体用 `#[cfg(feature = "tracing-init")]` 包裹
|
||||
2. `src/llm.rs` — 10 个子模块按 `llm-types` / `llm` / provider 分类门控
|
||||
3. `src/llm/cycle.rs` — `ToolRegistry` 导入加 `#[cfg(feature = "tools")]`,工具字段和方法加相同门控
|
||||
4. `src/tools.rs` — `pub mod mcp` 和 `pub use mcp::*` 加 `#[cfg(feature = "tools-mcp")]`
|
||||
5. `src/memory/store.rs` — `pub mod sqlite_store` 和 `pub use sqlite_store::SqliteStore` 加 `#[cfg(feature = "memory-sqlite")]`
|
||||
6. `src/memory.rs` — `pub use store::SqliteStore` 加 `#[cfg(feature = "memory-sqlite")]`
|
||||
7. `src/agent/session.rs` — engine 相关导入加 `#[cfg(feature = "engine")]`,to_snapshot/from_snapshot/restore_memory 加相同门控
|
||||
|
||||
**验证**:
|
||||
```bash
|
||||
cargo build -F "full" # 全量回归
|
||||
cargo build -F "prompt" # 验证 llm::types imply 路径
|
||||
cargo build -F "tools" # 验证 tokio imply 路径
|
||||
cargo build -F "memory" # 验证记忆模块不含 sqlite
|
||||
cargo build -F "chat,provider-openai" # 纯对话组合
|
||||
cargo build -F "chat,provider-openai,tools-mcp" # 带 MCP 对话
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Commit 3:Checkpoint 全量验证
|
||||
|
||||
**操作清单**:
|
||||
|
||||
1. 完整的验证矩阵执行(见第 5 节)
|
||||
2. `cargo test -F "full"` 确认 427 passed
|
||||
|
||||
**验证**:
|
||||
```bash
|
||||
cargo test -F "full"
|
||||
cargo build -F "light"
|
||||
cargo build -F "multi"
|
||||
```
|
||||
|
||||
## 5. 验证标准
|
||||
|
||||
### 编译验证矩阵
|
||||
|
||||
| 命令 | 验证目标 | 预期结果 |
|
||||
|------|---------|---------|
|
||||
| `cargo build --no-default-features` | 空 crate | 编译通过(无模块) |
|
||||
| `cargo build -F "full"` | 全量回归 | 编译通过,与 v0.3.0 语义一致 |
|
||||
| `cargo test -F "full"` | 测试回归 | `427 passed` |
|
||||
| `cargo build -F "document"` | 文档模块独立 | 编译通过,零外部依赖 |
|
||||
| `cargo build -F "prompt"` | 提示词独立 | 编译通过,`llm::types` imply 路径正确 |
|
||||
| `cargo build -F "tools"` | 工具独立 | 编译通过,tokio imply 路径正确 |
|
||||
| `cargo build -F "memory"` | 记忆模块独立编译 | ✅ 不含 sqlite(依赖 B1/B2 修复) |
|
||||
| `cargo build -F "memory-sqlite"` | 含 SQLite 的记忆模块 | ✅ 含 rusqlite |
|
||||
| `cargo build -F "agent"` | Agent 独立编译 | ✅ 含 llm+tools+memory,不含 engine(依赖 B1/B3 修复) |
|
||||
| `cargo build -F "engine"` | Engine 独立编译 | ✅ imply agent → llm+tools+memory |
|
||||
| `cargo build -F "chat,provider-openai"` | 纯对话组合 | 编译通过,不含 MCP、sqlite |
|
||||
| `cargo build -F "chat,provider-openai,tools-mcp"` | 带 MCP 对话 | 编译通过,含 reqwest 无 sqlite |
|
||||
| `cargo build -F "light"` | 生产常用组合 | 编译通过 |
|
||||
| `cargo build -F "multi"` | 多 provider 组合 | 编译通过 |
|
||||
|
||||
### 验证操作指令
|
||||
|
||||
每次编译验证后执行(验证编译产物不含意外符号):
|
||||
|
||||
```bash
|
||||
# 确认空 crate 确实没有模块符号
|
||||
cargo build --no-default-features 2>&1 && echo "OK"
|
||||
|
||||
# 确认 document 零外部依赖(无 reqwest/rusqlite 等符号)
|
||||
cargo build -F "document" 2>&1 && echo "OK"
|
||||
|
||||
# 全量构建 + 测试
|
||||
cargo build -F "full" 2>&1 && cargo test -F "full" 2>&1 | tail -5
|
||||
```
|
||||
|
||||
### 验证通过条件
|
||||
|
||||
- 所有 14 条编译验证命令返回 exit code 0
|
||||
- `cargo test -F "full"` 输出 `427 passed`(与 v0.3.0 基线一致,不要求测试数精确匹配,但必须全部通过且数量合理)
|
||||
- 无 `unused import` / `unused variable` / `dead code` warning(由 `#[cfg]` 引起的新 warning 需逐一修复)
|
||||
|
||||
## 6. 风险评估
|
||||
|
||||
| 风险 | 等级 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| **tokio features 拆细遗漏**:某些代码路径用到 net/fs/signal | 低 | 已通过 SA(静态分析)验证全库无相关使用 |
|
||||
| **`#[tokio::test]` 编译失败**:测试代码无 tokio runtime | 低 | `[dev-dependencies]` 添加 `tokio = { version = "1", features = ["rt", "macros"] }` |
|
||||
| **下游 transitive tokio features 缩小**:依赖 agcore 的 crate 之前通过 agcore 间接获得 `full` tokio,现在范围缩小 | 中 | Phase 27 README 发布说明中明确告知迁移方案;下游如需完整 tokio 需自行添加 |
|
||||
| **imply 链未闭合**:某个 feature 依赖了未 imply 的 feature | 低 | 7 种特征组合全部逐条构建验证;features 定义中有交叉引用的全部显式列出 |
|
||||
| **unused cfg warning**:某些 `#[cfg]` 标记导致编译 warning | 低 | 每个 commit 后检查编译器输出,发现后立即修复 |
|
||||
| **测试依赖循环**:dev-deps 与普通 deps 版本冲突 | 低 | dev-deps 的 tokio 版本与主依赖保持一致 (`version = "1"`,由 cargo 自动选择兼容版本) |
|
||||
| **memory 跨模块 imply 链** | **高** | memory 模块依赖 llm(conversation/vector_store)、tokio(knowledge)、time(types),imply 链必须完整传递 | `memory` feature 定义已包含 `llm`、`tokio`、`time`;验证矩阵覆盖 memory 独立编译 |
|
||||
| **跨模块引用未门控** | **高** | provider.rs 依赖 reqwest、cycle.rs 依赖 ToolRegistry、session.rs 依赖 engine,Step 1 必须添加 cfg 门控 | provider 模块 cfg 改为 provider-xxx 条件;cycle.rs 加 tools 门控;session.rs 加 engine 门控 |
|
||||
@@ -0,0 +1,424 @@
|
||||
# AG Core v0.3.2 Step 3(Phase 26–27)— 验证固化 + 文档更新实施方案
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
**背景**:agcore v0.3.2 Step 1(Phase 20–25)已交付 —— Cargo features 拆分基础设施改造全部完成,所有模块 `#[cfg]` 门控注入完毕,依赖全部 optional 化,`default = ["full"]` 保持向后兼容。当前项目处于已改造完成但未经 CI 固化、无文档指引的状态。
|
||||
|
||||
**当前状态快照**:
|
||||
- v0.3.0 → v0.3.2 Step 1:68 个源文件,23,765 行
|
||||
- 16 个 features(10 模块级 + 5 provider + 1 工具)+ 4 个快捷组合
|
||||
- `cargo test --features "full"`:427 passed
|
||||
- 7 种 feature 组合的 `cargo test --lib` 已全部通过(full / light / chat / chat+mcp / multi / multi+mcp / clippy),无需修复 cfg 遗漏
|
||||
- 但 `cargo test`(不带 `--lib`)会因 18 个 example 缺少 `required-features` 而失败
|
||||
- 项目无 CI/CD 配置
|
||||
|
||||
**目标**:通过 Step 3 将 features 体系验证固化到 CI 中,消除编译死代码警告,完成文档指引,使 v0.3.2 达到可发布状态。
|
||||
|
||||
**预期效果**:
|
||||
- 每次提交自动验证 7 种 feature 组合的编译 + 测试(零 warning)
|
||||
- 18 个 example 各自标注准确的 `required-features`,外树用户可一键运行
|
||||
- `cargo clippy --all-features -- -D warnings` 零告警
|
||||
- README 含完整 feature 表 + `Cargo.toml` 配置示例 + 升级指南,新用户 5 分钟内可选定组合
|
||||
- roadmap 同步更新
|
||||
|
||||
## 2. 需求分析
|
||||
|
||||
### 2.1 功能需求
|
||||
|
||||
| # | 需求 | 说明 | 对应工作 |
|
||||
|---|------|------|---------|
|
||||
| F1 | LlmProvider trait 不应依赖具体 provider feature | trait 自身不依赖 reqwest 或任何 provider 实现,纯 Mock 场景也应可用 | 工作 0 |
|
||||
| F2 | 每个 example 通过 `cargo run --example xxx` 正确编译 | 18 个 example 各有精确的最小 features 声明 | 工作 1 |
|
||||
| F3 | CI 自动验证 7 种特征组合的编译与测试 | push / PR 触发 | 工作 2 |
|
||||
| F4 | 所有 feature 组合下 0 个编译器警告 | 消除 dead_code 等警告 | 工作 3 |
|
||||
| F5 | README 提供完整的 feature 选择指引 | 表格 + 场景推荐 + Cargo.toml 示例 | 工作 5(Phase 27) |
|
||||
| F6 | example 文件顶部标注所需 features | 用户可一键复制运行命令 | 工作 5(Phase 27) |
|
||||
| F7 | roadmap 状态同步 | 总入口 + v0.3.2 子文档 | 工作 5(Phase 27) |
|
||||
|
||||
### 2.2 非功能需求
|
||||
|
||||
| # | 需求 | 指标 | 对应工作 |
|
||||
|---|------|------|---------|
|
||||
| N1 | 向后兼容 | `default = ["full"]` 行为与 v0.3.0 一致,427 tests passed | 全部 |
|
||||
| N2 | CI 时效 | 全矩阵 ≤ 10 分钟 | 工作 2 |
|
||||
| N3 | 最少侵入 | 不改动功能逻辑,仅 cfg / 配置 / 文档变更 | 全部 |
|
||||
|
||||
### 2.3 推演概要
|
||||
|
||||
**需求拆解**:从当前编译验证结果出发,发现三类待解决问题:
|
||||
1. **架构归属问题**——`LlmProvider` trait 定义在 provider 模块门控下,语义上应归属 `llm` 基础设施。同时其返回类型 `ProviderCapabilities` / `ProviderFeatures` 也必须一并移出
|
||||
2. **example 可编译性问题**——18 个 example 无 `required-features`,多组合下 `cargo test` 失败
|
||||
3. **代码质量问题**——`session.rs` 中 `bundle()` 方法在 `chat` 组合下 dead_code
|
||||
4. **工程缺失**——无 CI、README 无 features 说明、roadmap 未同步
|
||||
|
||||
**边界识别**:
|
||||
- 工作 0 仅移动 trait 及关联类型定义,不改变公开 API 签名
|
||||
- 工作 1 的 required-features 是最小集合,不添加冗余 feature
|
||||
- 工作 2 的 CI 仅验证编译 + 单元测试,不包含集成测试
|
||||
- 工作 5 的文档更新不涉及新的功能描述
|
||||
|
||||
**非目标声明**:
|
||||
- 不新增 feature 组合(保持现有的 4 个快捷组合不变)
|
||||
- 不重构 `ProviderConfig` / `ProviderType` / `create_provider()` 等 provider 模块创建逻辑(仅移出 trait 和元数据结构体)
|
||||
- 不集成集成测试(CI 仅验证 `--lib` 单元测试 + example 编译验证)
|
||||
- 不改动 `Cargo.toml` 的 `[dependencies]` 声明
|
||||
- 不改变 `default = ["full"]` 的默认行为
|
||||
|
||||
### 2.4 需求映射矩阵
|
||||
|
||||
| 功能需求 | 非功能需求 | 对应工作 | 验收项 |
|
||||
|---------|-----------|---------|-------|
|
||||
| F1 + N1 + N3 | — | 工作 0 | A1, A2, A9 |
|
||||
| F2 | — | 工作 1 | A10 |
|
||||
| F3 | N2 | 工作 2 | A5, A11 |
|
||||
| F4 | — | 工作 3 | A4 |
|
||||
| F5 | N1 | 工作 5 | A6 |
|
||||
| F6 | — | 工作 5 | A7 |
|
||||
| F7 | — | 工作 5 | A8 |
|
||||
| — | N3 | 全部 | A1 |
|
||||
| — | R2 缓解 | 全部 | A10 |
|
||||
|
||||
## 3. 方案设计
|
||||
|
||||
### 3.1 总体架构调整
|
||||
|
||||
```
|
||||
工作 0 — 架构修正(LlmProvider trait 及关联类型归属调整)
|
||||
|
||||
当前:
|
||||
src/llm/provider.rs #[cfg(any(feature = "provider-openai", ...))]
|
||||
├─ pub trait LlmProvider { ... }
|
||||
├─ pub struct ProviderCapabilities { ... }
|
||||
├─ pub struct ProviderFeatures { ... }
|
||||
└─ pub fn capabilities(&self) -> ProviderCapabilities;
|
||||
|
||||
目标:
|
||||
src/llm/provider_trait.rs #[cfg(feature = "llm")]
|
||||
├─ pub trait LlmProvider { ... }
|
||||
├─ pub struct ProviderCapabilities { ... }
|
||||
├─ pub struct ProviderFeatures { ... }
|
||||
└─ pub fn capabilities(&self) -> ProviderCapabilities;
|
||||
src/llm/provider.rs #[cfg(any(feature = "provider-openai", ...))]
|
||||
└─ 各 provider 实现 + ProviderConfig / ProviderType / create_provider()
|
||||
src/llm.rs
|
||||
└─ pub use provider_trait::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
|
||||
影响文件(6 个源文件 + 1 个新建):
|
||||
- src/llm.rs — 添加 mod provider_trait 声明 + pub use 重导出
|
||||
- src/llm/provider_trait.rs — 新文件,trait + 关联类型定义移入
|
||||
- src/llm/provider.rs — 移出 trait + 关联类型
|
||||
- src/llm/provider/openai.rs — use super:: → use crate::llm::
|
||||
- src/llm/provider/anthropic.rs — 同上
|
||||
- src/llm/provider/ollama.rs — 同上
|
||||
- src/llm/provider/openai_compat.rs — 同上(两个 import 合并)
|
||||
```
|
||||
|
||||
### 3.2 各子项设计方案
|
||||
|
||||
#### 工作 0 — LlmProvider trait 归属修正
|
||||
|
||||
**设计方案**:
|
||||
1. 在 `src/llm/` 下新建 `provider_trait.rs`,门控为 `#[cfg(feature = "llm")]`
|
||||
2. 从 `src/llm/provider.rs` 中提取以下定义到新文件:
|
||||
- `pub trait LlmProvider`(含关联方法 `chat` / `chat_stream` / `capabilities`)
|
||||
- `pub struct ProviderCapabilities`(含字段 `features: ProviderFeatures`)
|
||||
- `pub struct ProviderFeatures`(含 8 个功能开关字段)
|
||||
3. `src/llm.rs` 中声明 `mod provider_trait;`,并 `pub use provider_trait::{LlmProvider, ProviderCapabilities, ProviderFeatures};`
|
||||
4. `src/llm/provider.rs` 移除上述定义,保留 `ProviderConfig` / `ProviderType` / `create_provider()` 等运行时代码
|
||||
5. 更新所有 import 路径(详见下方清单)
|
||||
|
||||
**Import 路径调整清单**:
|
||||
|
||||
现有写法 → 目标写法
|
||||
|
||||
| # | 文件 | 现有 import | 目标 import |
|
||||
|---|------|------------|------------|
|
||||
| 1 | `agent/builder.rs:16` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
|
||||
| 2 | `agent/builder.rs:135`(test) | `use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
|
||||
| 3 | `agent/session.rs:35` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
|
||||
| 4 | `agent/runtime.rs:21` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
|
||||
| 5 | `llm/mock.rs:46` | `use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
|
||||
| 6 | `llm/cycle.rs:22` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
|
||||
| 7 | `llm/cycle.rs:940,1401`(test) | `use crate::llm::provider::{ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{ProviderCapabilities, ProviderFeatures};` |
|
||||
| 8 | `llm/provider/openai.rs:24` | `use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
|
||||
| 9 | `llm/provider/anthropic.rs:21` | `use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
|
||||
| 10 | `llm/provider/ollama.rs:14` | `use super::{LlmProvider, ProviderCapabilities};` | `use crate::llm::{LlmProvider, ProviderCapabilities};` |
|
||||
| 11 | `llm/provider/openai_compat.rs:18,21` | `use super::ProviderCapabilities;` + `use crate::llm::provider::LlmProvider;` | `use crate::llm::{LlmProvider, ProviderCapabilities};`(合并为一行) |
|
||||
| 12 | `llm/provider/registry.rs:6` | `use crate::llm::provider::{LlmProvider, ProviderConfig, ProviderType, create_provider};` | `use crate::llm::LlmProvider;` + `use crate::llm::provider::{ProviderConfig, ProviderType, create_provider};`(拆分) |
|
||||
|
||||
**示例文件 import 调整**:
|
||||
|
||||
| # | 文件 | 现有 import | 目标 import |
|
||||
|---|------|------------|------------|
|
||||
| 13 | `examples/end_to_end.rs:21` | `use agcore::llm::provider::{create_provider, LlmProvider, ProviderConfig, ProviderType};` | `use agcore::llm::LlmProvider;` + `use agcore::llm::provider::{create_provider, ProviderConfig, ProviderType};` |
|
||||
| 14 | `examples/context_slot_demo.rs:18` | `use agcore::llm::provider::LlmProvider;` | `use agcore::llm::LlmProvider;` |
|
||||
| 15 | `examples/streaming_events_demo.rs:19` | `use agcore::llm::provider::LlmProvider;` | `use agcore::llm::LlmProvider;` |
|
||||
| 16 | `examples/quick_start.rs:9` | `use agcore::llm::provider::LlmProvider;` | `use agcore::llm::LlmProvider;` |
|
||||
|
||||
**Breaking Change 声明**:
|
||||
|
||||
工作 0 是**非兼容性变更**,现有用户可能通过以下路径引用 `LlmProvider`:
|
||||
|
||||
| 旧路径(v0.3.0–v0.3.2 Step 1) | 新路径(v0.3.2 Step 3 后) |
|
||||
|--------------------------------|---------------------------|
|
||||
| `agcore::llm::provider::LlmProvider` | `agcore::llm::LlmProvider` |
|
||||
| `agcore::llm::provider::ProviderCapabilities` | `agcore::llm::ProviderCapabilities` |
|
||||
| `agcore::llm::provider::ProviderFeatures` | `agcore::llm::ProviderFeatures` |
|
||||
|
||||
**向后兼容方案(可选)**:在 `src/llm/provider.rs` 中添加 `#[cfg(feature = "llm")]` 门控的类型别名,让老路径仍然可用:
|
||||
```rust
|
||||
#[cfg(feature = "llm")]
|
||||
pub use super::provider_trait::LlmProvider;
|
||||
#[cfg(feature = "llm")]
|
||||
pub use super::provider_trait::ProviderCapabilities;
|
||||
#[cfg(feature = "llm")]
|
||||
pub use super::provider_trait::ProviderFeatures;
|
||||
```
|
||||
**推荐**:用户应迁移到新路径 `agcore::llm::LlmProvider`,`provider` 模块仅保留 `ProviderConfig` / `ProviderType` / `create_provider()` 等创建逻辑。
|
||||
|
||||
**验证**:
|
||||
- `cargo test --features "full"` 仍 427 passed
|
||||
- `cargo test --no-default-features --features "llm,llm-types" --lib` 编译通过(无需任何 provider feature)
|
||||
- 所有 12 个内部文件 + 4 个示例文件的 import 路径正确
|
||||
|
||||
#### 工作 1 — examples required-features 标注
|
||||
|
||||
**设计方案**:在 `Cargo.toml` 中为每个 example 添加 `[[example]]` + `required-features`,精确到最小 features 集合。
|
||||
|
||||
```
|
||||
上下文 slot 示例(context_slot_demo):
|
||||
工作 0 后仅需 ["agent"](修正前需 ["agent", "provider-openai"])
|
||||
因为 agent 的测试无需真实 provider,mock 即可
|
||||
|
||||
推理不变的 example(simple_visit):
|
||||
真正调用 LLM,需要 ["llm", "provider-openai", "tracing-init"]
|
||||
```
|
||||
|
||||
完整映射关系见实施计划 §4.2。
|
||||
|
||||
#### 工作 2 — CI 配置
|
||||
|
||||
**设计方案**:GitHub Actions 矩阵策略,7 个并行测试 job + 1 clippy + 1 format + 1 example 验证。
|
||||
|
||||
| Job | Command | 作用域 |
|
||||
|-----|---------|--------|
|
||||
| full | `RUSTFLAGS="-D warnings" cargo test --features "full" --lib` | 全量回归,零警告 |
|
||||
| light | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "light" --lib` | 生产常用,零警告 |
|
||||
| chat | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "chat,provider-openai" --lib` | 纯对话,零警告 |
|
||||
| chat+mcp | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "chat,provider-openai,tools-mcp" --lib` | 对话 + 工具,零警告 |
|
||||
| multi | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "multi,provider-openai" --lib` | 多 Agent,零警告 |
|
||||
| multi+mcp | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "multi,provider-openai,tools-mcp" --lib` | 多 Agent + 工具,零警告 |
|
||||
| clippy | `cargo clippy --all-features --lib -- -D warnings` | lint 检查 |
|
||||
| format | `cargo fmt --check`(stable toolchain) | 格式检查 |
|
||||
| examples | `cargo test --features "full"`(不加 `--lib`,编译并运行所有 example) | example 编译验证 |
|
||||
|
||||
**关键决策**:
|
||||
- 矩阵中统一使用 `--lib` 而非 `--all-targets`。理由:examples 的编译由 `required-features` 独立管理,若混入矩阵会因 feature 组合不匹配导致 example 编译失败,干扰模块测试结果验证。
|
||||
- 使用 `RUSTFLAGS="-D warnings"` 将警告升级为编译错误,确保 `F4(0 编译器警告)`被矩阵中所有 6 个测试 job 强制执行。
|
||||
- format job 使用 stable toolchain(`cargo fmt --check` 不需要 nightly)。
|
||||
- 独立 `examples` job 使用 `cargo test --features "full"`(不加 `--lib`),验证所有 example 在完整 features 下编译并运行通过。
|
||||
|
||||
#### 工作 3 — bundle() 死代码警告修复
|
||||
|
||||
**设计方案**:在 `src/agent/session.rs:154` 的 `pub(crate) fn bundle()` 方法上添加 `#[cfg(feature = "engine")]` 条件编译。
|
||||
|
||||
背景:`bundle()` 仅被 `engine/session_manager.rs:219` 调用,当启用 `chat` 组合(agent 但非 engine)时产生 dead_code 警告。
|
||||
|
||||
**验证**:`cargo test --no-default-features --features "chat,provider-openai" --lib` 0 warnings。
|
||||
|
||||
#### 工作 4(可选)— 编译时间基线
|
||||
|
||||
记录但不沉淀到代码或 CI 中,仅供性能参考:
|
||||
```bash
|
||||
time cargo build --features "full"
|
||||
time cargo build --no-default-features --features "light"
|
||||
```
|
||||
|
||||
注:此工作在 v0.3.2 发布前为手动执行,不纳入 CI 或验收标准。若后续版本需要编译时间回归检测,可将其提升为正式工作项。
|
||||
|
||||
#### 工作 5 — 文档更新
|
||||
|
||||
三处并行更新:
|
||||
|
||||
**README.md 新增 features 表格**:
|
||||
- 4 个快捷组合 + 推荐使用场景 + `Cargo.toml` 配置示例
|
||||
- 下游用户可快速选择并复制配置
|
||||
|
||||
**升级指南章节(README.md 新增)**:
|
||||
- 针对工作 0 的 Breaking Change 提供迁移说明
|
||||
- 列出旧路径 → 新路径的对照表
|
||||
- 提供向后兼容的重导出方案说明
|
||||
- 示例:`agcore::llm::provider::LlmProvider` → `agcore::llm::LlmProvider`
|
||||
- 提醒用户更新 `use` 声明
|
||||
|
||||
**example 文件顶部注释**:
|
||||
- 每个 example 第一行格式:`// Required features: cargo run --example xxx --features "..."`
|
||||
- 对应工作 1 的 `required-features` 声明
|
||||
|
||||
**roadmap 状态同步**:
|
||||
- `docs/roadmap.md`:补充 Phase 26-27 完成状态 + v0.3.2 链接
|
||||
- `docs/roadmap-v0.3.2.md`:Phase 26/27 状态从 ⏳ 改为 ✅ + 完成日期
|
||||
|
||||
### 3.3 ADR 记录
|
||||
|
||||
#### ADR-1:LlmProvider trait 及关联类型归属 llm 模块
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| 问题 | `LlmProvider` trait 定义在 provider 模块门控 `any(provider-openai, provider-anthropic, ...)` 下,纯 Mock 场景被迫引入至少一个 provider feature。其返回类型 `ProviderCapabilities` / `ProviderFeatures` 同样被困在 provider 门控中 |
|
||||
| 决策 | 将 trait 定义 + `ProviderCapabilities` / `ProviderFeatures` 一并提取到 `src/llm/provider_trait.rs`,归属 `#[cfg(feature = "llm")]` |
|
||||
| 备选方案 | 保持不动,在 mock provider 上添加 cfg 绕过 — 否决,因为 provider 模块整体门控错误 |
|
||||
| 理由 | trait 本身是个接口定义,不依赖 reqwest 或任何 provider 实现细节;`ProviderCapabilities` / `ProviderFeatures` 是 trait 方法的返回类型,必须与 trait 同门控 |
|
||||
| 影响 | 修改 6 个源文件 + 1 个新建文件 + 4 个示例文件(详见 §3.2 import 调整清单) |
|
||||
| 状态 | 已采纳 |
|
||||
|
||||
#### ADR-2:CI 使用 nightly toolchain
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| 问题 | 项目已使用 edition 2024,是否降级到 2021 以使用 stable Rust |
|
||||
| 决策 | 测试和 clippy 使用 nightly(edition 2024 目前要求 nightly);format 使用 stable |
|
||||
| 备选方案 | 降级 edition 到 2021 — 否决,已迁移至 edition 2024 且编译通过 |
|
||||
| 理由 | edtion 2024 是主动选择的方向,降级是倒退且涉及大量语法变更;`cargo fmt --check` 无需 nightly |
|
||||
| 影响 | CI 依赖 `actions-rust-lang/setup-rust-toolchain@v1`;format job 指定 `toolchain: stable` |
|
||||
| 状态 | 已采纳 |
|
||||
|
||||
#### ADR-3:CI 矩阵使用 `--lib` 而非 `--all-targets`
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| 问题 | `cargo test --all-targets` 会编译所有 example,与矩阵中自选的 feature 组合可能冲突 |
|
||||
| 决策 | 矩阵测试使用 `--lib`,examples 由独立 job(`cargo test --features "full"` 不加 `--lib`)验证 |
|
||||
| 备选方案 | 在矩阵中也传入 `--all-targets` — 否决,example 编译失败会干扰模块测试验证 |
|
||||
| 理由 | 分离关注点:矩阵验证模块级编译 + 零警告,独立 job 验证 example 编译 |
|
||||
| 状态 | 已采纳 |
|
||||
|
||||
## 4. 实施计划
|
||||
|
||||
### 4.1 任务拆解与优先级
|
||||
|
||||
| 优先级 | 工作 | 编号 | 规模 | 依赖 |
|
||||
|--------|------|------|------|------|
|
||||
| P0 | LlmProvider trait 归属修正 | 工作 0 | ~30 行(含关联类型移动 + import 调整) | 无 |
|
||||
| P0 | bundle() 门控修复 | 工作 3 | 1 行 | 无 |
|
||||
| P0 | examples required-features | 工作 1 | ~50 行 | 工作 0(context_slot_demo / quick_start 最小 features 从 agent+provider-openai 降为 agent) |
|
||||
| P0 | CI 配置 | 工作 2 | ~80 行 | 无 |
|
||||
| P1 | 文档更新 | 工作 5 | ~150 行 | 全部 |
|
||||
| P2 | 编译时间基线 | 工作 4 | 手动 | 全部 |
|
||||
|
||||
### 4.2 各 example 的 required-features 清单
|
||||
|
||||
| example 文件名 | required-features | 运行环境备注 |
|
||||
|---------------|-------------------|------------|
|
||||
| `prompt_composer` | `["prompt"]` | — |
|
||||
| `custom_tool` | `["tools"]` | — |
|
||||
| `conversation_memory_demo` | `["memory"]` | — |
|
||||
| `knowledge_graph_demo` | `["memory"]` | — |
|
||||
| `knowledge_search_demo` | `["memory"]` | — |
|
||||
| `agent_session_demo` | `["agent"]` | — |
|
||||
| `task_agent_demo` | `["agent"]` | — |
|
||||
| `context_slot_demo` | `["agent"]` | 工作 0 后无需 provider |
|
||||
| `quick_start` | `["agent"]` | 工作 0 后无需 provider |
|
||||
| `simple_visit` | `["llm", "provider-openai", "tracing-init"]` | 需要 API key |
|
||||
| `streaming_events_demo` | `["llm", "provider-openai"]` | 需要 API key |
|
||||
| `agent_switch_demo` | `["engine"]` | — |
|
||||
| `bridge_keys_demo` | `["engine"]` | — |
|
||||
| `dispatch_stream_demo` | `["engine"]` | — |
|
||||
| `engine_demo` | `["engine"]` | — |
|
||||
| `sub_agent_dispatch_demo` | `["engine"]` | — |
|
||||
| `document_demo` | `["memory", "tracing-init"]` | 需 sqlite 依赖(memory-sqlite feature 可选) |
|
||||
| `end_to_end` | `["agent", "memory-sqlite", "provider-openai"]` | 需要 API key + sqlite 依赖 |
|
||||
|
||||
**验证策略**:每个 example 除 `--lib` 验证外,还需单独运行以下命令确认 required-features 精确性:
|
||||
```bash
|
||||
cargo test --no-default-features --features "<features>" --example <name>
|
||||
```
|
||||
|
||||
### 4.3 Commit 策略
|
||||
|
||||
每个工作独立 commit,按依赖顺序排列:
|
||||
|
||||
| 顺序 | Scope | Type | 描述 | 依赖 |
|
||||
|------|-------|------|------|------|
|
||||
| 1 | `core` | `refactor` | 将 LlmProvider trait 及关联类型移出 provider 模块归属 llm | 无 |
|
||||
| 2 | `agent` | `fix` | 为 session.rs bundle() 方法添加 engine feature 门控 | 无 |
|
||||
| 3 | `examples` | `chore` | 为 18 个 example 添加 required-features 声明 | 工作 1(context_slot 等受益于工作 0 的轻量 features) |
|
||||
| 4 | `ci` | `chore` | 创建 CI 测试矩阵配置 | 无 |
|
||||
| 5 | `docs` | `docs` | 更新 README feature 表 + 升级指南 + 示例注释 + roadmap 状态 | 全部 |
|
||||
|
||||
### 4.4 参考实现:CI 配置
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
on: [push, pull_request]
|
||||
env:
|
||||
RUSTFLAGS: "-D warnings"
|
||||
jobs:
|
||||
test-matrix:
|
||||
strategy:
|
||||
matrix:
|
||||
features:
|
||||
- "full"
|
||||
- "light"
|
||||
- "chat,provider-openai"
|
||||
- "chat,provider-openai,tools-mcp"
|
||||
- "multi,provider-openai"
|
||||
- "multi,provider-openai,tools-mcp"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
- run: cargo test --no-default-features --features "${{ matrix.features }}" --lib
|
||||
clippy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
- run: cargo clippy --all-features --lib -- -D warnings
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- run: cargo fmt --check
|
||||
examples:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
- run: cargo test --features "full"
|
||||
```
|
||||
|
||||
## 5. 风险评估
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 | 对应验收项 |
|
||||
|------|------|------|---------|-----------|
|
||||
| 工作 0 重构后公开 API 被意外改变 | 低 | 高 | 重构前后分别跑 `cargo test --features "full"` 确认测试数一致(427),且 `cargo doc` 无差异 | A1 |
|
||||
| required-features 标注不准确导致 example 运行时缺少 trait 实现 | 低 | 中 | 每个 example 在 `--lib` 验证后,再单独跑 `cargo test --example xxx --no-default-features --features "对应features"` 确认 | A10 |
|
||||
| CI 首次在 GitHub runner 上因环境差异(OS、Toolchain 版本)失败 | 中 | 低 | 非阻塞问题,修复后重新推送即可;本地已在 macOS 验证 7 种组合 | A5 |
|
||||
| edition 2024 在 GitHub runner 的特定 nightly 版本上不稳定 | 低 | 中 | 可在 `Cargo.toml` 中加 `rust-version = "1.85"` 下限约束 | A5 |
|
||||
| bundle() 门控修复后 engine 组合下方法不可见 | 极低 | 中 | `cargo test --features "engine,provider-openai"` 编译通过即可验证 | A4 |
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
| # | 验收项 | 验证方式 | 对应工作 |
|
||||
|---|--------|---------|---------|
|
||||
| A1 | `cargo test --features "full"` 仍 427 passed | `cargo test -F full -q` | 工作 0 |
|
||||
| A2 | `cargo test --no-default-features --features "llm,llm-types" --lib` 编译通过 | 无需任何 provider feature 即完成编译 | 工作 0 |
|
||||
| A3 | 7 种组合下 `cargo test --no-default-features --features "组合" --lib -q` 全部通过 | 逐一验证 | 工作 1(example features 正确不会干扰 --lib) |
|
||||
| A4 | 6 个矩阵组合(full / light / chat / chat+mcp / multi / multi+mcp)下 `RUSTFLAGS="-D warnings" cargo test --lib` 0 warnings | 所有组合均无编译器警告 | 工作 3 |
|
||||
| A5 | `.github/workflows/ci.yml` 文件存在,结构包含 9 个 job(6 测试 + 1 clippy + 1 format + 1 examples) | 文件检查 | 工作 2 |
|
||||
| A6 | README.md 包含 features 表格 + 使用场景 + Cargo.toml 配置示例 + 升级指南 | review 通过 | 工作 5 |
|
||||
| A7 | 所有 18 个 example 文件首行含 `// Required features: cargo run --example xxx --features "..."` 注释 | review 通过 | 工作 5 |
|
||||
| A8 | `docs/roadmap.md` 和 `docs/roadmap-v0.3.2.md` 中 Phase 26/27 状态标记为 ✅ | review 通过 | 工作 5 |
|
||||
| A9 | 工作 0 后 `cargo doc --no-deps --features "llm,llm-types"` 可生成 `LlmProvider` / `ProviderCapabilities` / `ProviderFeatures` 的 API 文档(无需任何 provider feature) | review 通过 | 工作 0 |
|
||||
| A10 | 每个 example 单独验证:`cargo test --no-default-features --features "<对应features>" --example <name>` 编译通过 | 逐一验证 18 个 example | 工作 1 |
|
||||
| A11 | 全矩阵 CI(6 测试 + clippy + format + examples)从 checkout 到完成 ≤ 10 分钟 | 实测计时 | 工作 2 |
|
||||
@@ -0,0 +1,790 @@
|
||||
# Phase 28-30 — OpenAI Response API Provider 实施方案
|
||||
|
||||
> **版本**:v1 | **作者**:Writer Agent | **日期**:2026-07-20
|
||||
>
|
||||
> **阅读前提**:本文档假设读者已熟悉现有的 Provider 实现模式(`AnthropicProvider` 独立实现方式)、IR 类型系统(`MessageRequest` / `MessageResponse` / `ContentBlock` / `StreamEvent` / `LlmProvider trait`)以及 Cargo features 门控机制。
|
||||
>
|
||||
> **前置条件**:v0.3.2(Phase 20-27)已发布,Cargo features 拆分完成,CI 矩阵 6 种组合全部通过。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
OpenAI 于 2025 年下半年发布了 **Response API**(`POST /responses`),作为 Chat Completions API(`POST /chat/completions`)的下一代接口。Response API 不仅提供了更简洁的请求/响应结构,还将 `web_search`、`file_search`、`computer_use` 等内置工具提升为一等公民,并引入了 `previous_response_id` 多轮续写等新机制。
|
||||
|
||||
agcore 当前通过 `GenericOpenaiProvider` 实现了 OpenAI Chat Completions 协议。`ProviderType::OpenaiResponse` 枚举项已在 `src/llm/provider.rs` 中定义,但工厂函数返回 `Err("Phase 1 暂不实现;请使用 OpenaiChat")`。
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
- 实现独立的 `OpenaiResponseProvider`(不套用 `GenericOpenaiProvider`,参考 `AnthropicProvider` 模式)
|
||||
- 覆盖 Response API 的核心能力:文本对话、流式输出、Vision 输入、工具调用(function calling)
|
||||
- 新增独立 feature `provider-openai-response`,加入 `full` 快捷组合
|
||||
- 内置工具(`web_search` / `file_search` / `computer_use`)通过 `MessageRequest.extra` 逃生舱传递
|
||||
- 多轮接续第一版走全量消息历史模式
|
||||
|
||||
### 1.3 范围
|
||||
|
||||
| 维度 | 包含 | 不包含 |
|
||||
|------|------|--------|
|
||||
| 协议端点 | `POST /responses` | `/responses/{id}/input_items` 等管理端点 |
|
||||
| 输入模式 | 全量消息历史 + `previous_response_id` | 增量续写优化 |
|
||||
| 内置工具 | 通过 `extra` 逃生舱透传 | 原生 ToolDef 结构改动 |
|
||||
| 流式 | SSE 语义事件 → `StreamEvent` | — |
|
||||
| 结构化输出 | `text.format` | 暂不专项封装 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 需求分析
|
||||
|
||||
### 2.1 功能需求
|
||||
|
||||
| # | 需求 | 优先级 | 说明 |
|
||||
|---|------|--------|------|
|
||||
| F1 | 文本对话(非流式 + 流式) | P0 | 最基础的对话能力 |
|
||||
| F2 | Vision 图片输入 | P0 | `UserImage` → `input_image` |
|
||||
| F3 | Function Calling 工具调用 | P0 | `ToolDef` → `{type: "function", ...}` |
|
||||
| F4 | 多轮接续 | P1 | 全量消息历史模式 |
|
||||
| F5 | System 消息处理 | P0 | 多个 System 消息拼接到 `instructions` |
|
||||
| F6 | 流式 SSE 事件映射 | P0 | 按 Response API SSE 事件序列映射 |
|
||||
| F7 | 内置工具逃生舱 | P2 | `extra` 字段透传 `web_search` / `file_search` |
|
||||
| F8 | 结构化输出逃生舱 | P2 | `extra` 字段透传 `text.format` |
|
||||
|
||||
### 2.2 非功能需求
|
||||
|
||||
| # | 需求 | 指标 |
|
||||
|---|------|------|
|
||||
| N1 | 编译隔离 | 新增 feature 不增加 `light` / `chat` 组合的依赖 |
|
||||
| N2 | 测试覆盖 | wiremock 覆盖非流式 + 流式 + 错误路径 |
|
||||
| N3 | 错误映射 | 复用 `GenericOpenaiProvider` 的错误映射逻辑 |
|
||||
| N4 | Clippy 合规 | `cargo clippy --all-features --lib -- -D warnings` 通过 |
|
||||
|
||||
### 2.3 与 Chat Completions 的差异回顾
|
||||
|
||||
| 维度 | Chat Completions | Response API |
|
||||
|------|-----------------|--------------|
|
||||
| 端点 | `POST /chat/completions` | `POST /responses` |
|
||||
| 输入 | `messages: [{role, content}]` | `input: string \| items[]` + 顶层 `instructions` |
|
||||
| 输出 | `choices[n].message` | `output: []` 异构 items 数组 |
|
||||
| 内置工具 | 无(仅 function calling) | `web_search` / `file_search` / `computer_use` 一等公民 |
|
||||
| 多轮接续 | 调用方拼接 messages | `previous_response_id` 参数 或 全量回传 |
|
||||
| 流式 | SSE chunk `choices[n].delta` | SSE 语义事件:`response.text.delta` / `response.output_item.added` 等 |
|
||||
| 结构化输出 | `response_format` | `text.format` |
|
||||
| 认证 | `Authorization: Bearer` | 相同 |
|
||||
| 错误结构 | 相同(401/429/500) | 相同 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 方案设计
|
||||
|
||||
### 3.1 设计决策
|
||||
|
||||
| # | 决策 | 选项 | 选择 | 理由 |
|
||||
|---|------|------|------|------|
|
||||
| D1 | 实现方式 | 独立 Provider vs 套用 GenericOpenaiProvider | **独立 Provider** | Response API 请求/响应结构与 Chat Completions 差异过大,序列化/反序列化无共用价值 |
|
||||
| D2 | Feature 粒度 | 合并到 `provider-openai` vs 独立 | **独立 feature** | 与 `AnthropicProvider` 对齐,避免 `full` 组合膨胀 |
|
||||
| D3 | 加入快捷组合 | 加入 `full` 但不加入 `light` | **`full` 包含** | Response API 属于高级能力,`light` 保持轻量 |
|
||||
| D4 | 多轮方案 | 全量历史 vs 增量 | **全量历史(模式 A)** | 功能正确,无需改动 `LlmCycle` |
|
||||
| D5 | 内置工具支持 | 改 ToolDef vs extra 逃生舱 | **extra 逃生舱** | 不改已有 IR 类型,最小侵入 |
|
||||
|
||||
### 3.2 Feature 定义
|
||||
|
||||
```toml
|
||||
provider-openai-response = ["llm", "reqwest", "bytes", "futures-util"]
|
||||
```
|
||||
|
||||
与 `provider-openai` / `provider-anthropic` 的依赖集合一致——`llm` 已包含 `tokio` / `async-stream` / `futures-core` / `futures-util` / `tokio-stream`,此处补充 `reqwest`(HTTP 客户端)和 `bytes`(流式 buffer 操作)。
|
||||
|
||||
`full` 快捷组合追加 `"provider-openai-response"`。
|
||||
|
||||
### 3.3 新增文件
|
||||
|
||||
所有实现集中在单一文件:
|
||||
|
||||
```
|
||||
src/llm/provider/openai_response.rs ← 全部实现(Wire 类型 + Provider 结构体 + 请求转换 + 响应转换 + 流式处理 + 测试)
|
||||
```
|
||||
|
||||
不在 `provider/` 下创建子目录。模块声明在 `src/llm.rs`,在现有 Provider features cfg 条件中追加 `feature = "provider-openai-response"`:
|
||||
|
||||
```rust
|
||||
#[cfg(any(
|
||||
feature = "provider-openai",
|
||||
feature = "provider-anthropic",
|
||||
feature = "provider-deepseek",
|
||||
feature = "provider-qwen",
|
||||
feature = "provider-ollama",
|
||||
feature = "provider-openai-response",
|
||||
))]
|
||||
pub mod provider;
|
||||
```
|
||||
|
||||
### 3.4 架构概览
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ OpenaiResponseProvider │
|
||||
│ ┌──────────────────────────────────────────┐ │
|
||||
│ │ convert_request() │ │
|
||||
│ │ MessageRequest → OpenaiResponseRequest │ │
|
||||
│ └──────────────────┬───────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────▼───────────────────────┐ │
|
||||
│ │ HTTP POST /responses │ │
|
||||
│ │ (reqwest Client) │ │
|
||||
│ └──────────────────┬───────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────▼───────────────────────┐ │
|
||||
│ │ convert_response() │ │
|
||||
│ │ OpenaiResponseBody → MessageResponse │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────────────────────┐ │
|
||||
│ │ ResponseSseEventStream │ │
|
||||
│ │ SSE bytes → StreamEvent 流 │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.5 Wire 类型设计
|
||||
|
||||
#### 请求体类型
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct OpenaiResponseRequest {
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instructions: Option<String>,
|
||||
pub input: Vec<ResponseInputItem>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<ResponseTool>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_output_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub previous_response_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub store: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub truncation: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning: Option<Value>,
|
||||
}
|
||||
```
|
||||
|
||||
#### Input Item 枚举
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum ResponseInputItem {
|
||||
Message {
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
item_type: Option<String>, // 可选,固定为 "message"(assistant 回传时使用)
|
||||
role: String,
|
||||
content: Vec<ResponseInputContent>,
|
||||
},
|
||||
FunctionCall {
|
||||
#[serde(rename = "type")]
|
||||
item_type: String, // 固定为 "function_call"
|
||||
call_id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
status: Option<String>,
|
||||
},
|
||||
FunctionCallOutput {
|
||||
#[serde(rename = "type")]
|
||||
item_type: String, // 固定为 "function_call_output"
|
||||
call_id: String,
|
||||
output: String,
|
||||
},
|
||||
}
|
||||
|
||||
> **关于 `ResponseInputItem` 与 `ResponseOutputItem` 的职责划分**:
|
||||
>
|
||||
> - **`ResponseInputItem`**(`#[serde(untagged)]`):仅用于**请求序列化**(`convert_request`),由代码控制枚举变体的生成,永远不会遇到未知的 `item_type`。因此 untagged 模式是安全的,无需 fallback。
|
||||
> - **`ResponseOutputItem`**(非 untagged,`item_type: String` 为必填字段):用于**响应反序列化**(`convert_response`),来自 API 响应。未知的 `item_type` 已通过 §3.7 的 `ContentBlock::Extension` fallback 处理,不会因新增 item 类型而触发 serde 反序列化失败。
|
||||
|
||||
/// 消息内容块(嵌套在 Message 变体的 content 数组中)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum ResponseInputContent {
|
||||
InputText {
|
||||
text: String,
|
||||
},
|
||||
InputImage {
|
||||
image_url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<String>,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
> **补充说明**:Response API 的 `input` 字段还支持简化格式——`input: "Hello"`(单字符串)或 `input: ["Hello", "Hi"]`(字符串数组),但这些格式只能表达纯文本消息。为支持多模态内容(文本 + 图片)和工具调用,本实现使用完整的消息对象数组格式。
|
||||
|
||||
#### Tool 类型
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum ResponseTool {
|
||||
Function {
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: Value,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应体类型
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct OpenaiResponseBody {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub output: Vec<ResponseOutputItem>,
|
||||
pub usage: Usage,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct ResponseOutputItem {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub item_type: String,
|
||||
pub status: Option<String>,
|
||||
pub role: Option<String>,
|
||||
pub content: Option<Vec<ResponseContentPart>>,
|
||||
pub call_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub arguments: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct ResponseContentPart {
|
||||
#[serde(rename = "type")]
|
||||
pub part_type: String,
|
||||
pub text: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
#### SSE 事件类型
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum ResponseSseEvent {
|
||||
#[serde(rename = "response.created")]
|
||||
ResponseCreated { response: ResponseSseMeta },
|
||||
#[serde(rename = "response.completed")]
|
||||
ResponseCompleted { response: ResponseSseMeta },
|
||||
#[serde(rename = "response.failed")]
|
||||
ResponseFailed { error: Option<serde_json::Value> },
|
||||
#[serde(rename = "response.output_item.added")]
|
||||
ResponseOutputItemAdded { item: ResponseOutputItem },
|
||||
#[serde(rename = "response.output_item.done")]
|
||||
ResponseOutputItemDone { item: ResponseOutputItem },
|
||||
#[serde(rename = "response.output_text.delta")]
|
||||
ResponseOutputTextDelta { delta: String, item_id: String },
|
||||
#[serde(rename = "response.output_text.done")]
|
||||
ResponseOutputTextDone { text: String, item_id: String },
|
||||
#[serde(rename = "response.refusal.delta")]
|
||||
ResponseRefusalDelta { delta: String, item_id: String },
|
||||
#[serde(rename = "response.refusal.done")]
|
||||
ResponseRefusalDone { refusal: String, item_id: String },
|
||||
#[serde(rename = "response.function_call_arguments.delta")]
|
||||
ResponseFunctionCallArgumentsDelta { delta: String, item_id: String },
|
||||
#[serde(rename = "response.function_call_arguments.done")]
|
||||
ResponseFunctionCallArgumentsDone { arguments: String, item_id: String },
|
||||
#[serde(rename = "error")]
|
||||
Error { code: String, message: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct ResponseSseMeta {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub status: String,
|
||||
}
|
||||
```
|
||||
|
||||
### 3.6 请求转换(convert_request)
|
||||
|
||||
#### 消息类型映射
|
||||
|
||||
| 输入场景 | Message 类型 | → Response API input item |
|
||||
|---------|-------------|--------------------------|
|
||||
| 文本 User | `Message::User { content: [Text] }` | `{role: "user", content: [{type: "input_text", text}]}` |
|
||||
| Vision | `Message::UserImage { data, mime_type, detail }` | `{role: "user", content: [{type: "input_image", image_url: "data:{mime};base64,{data}", detail}]}` |
|
||||
| User 多模态 | `Message::User { content: [Text, Image, ...] }` | `{role: "user", content: [{type: "input_text", text}, {type: "input_image", image_url, detail}]}` |
|
||||
| Assistant 文本 | `Message::Assistant { content: [Text] }` | User 侧:`{role: "assistant", content: [{type: "output_text", text}]}`(无 `type` 字段);回传时: `{type: "message", role: "assistant", content: [{type: "output_text", text}]}`(有 `type: "message"`) |
|
||||
| Assistant 工具调用 | `Message::Assistant { content: [ToolUse] }` | `FunctionCall { call_id, name, arguments }` |
|
||||
| Assistant 文本+工具 | `Message::Assistant { content: [Text, ToolUse, ...] }` | 一个 `Message(assistant)` + 一个或多个 `FunctionCall` 项 |
|
||||
| 工具结果 | `Message::ToolResult { tool_call_id, content, is_error }` | `FunctionCallOutput { call_id, output: content }` |
|
||||
| System | `Message::System { content }` | 拼接到顶层 `instructions` 字段(非 input) |
|
||||
|
||||
#### 字段映射
|
||||
|
||||
| MessageRequest 字段 | → Response API 字段 |
|
||||
|---------------------|---------------------|
|
||||
| `model` | `model` |
|
||||
| `max_tokens` | `max_output_tokens` |
|
||||
| `temperature` | `temperature` |
|
||||
| `top_p` | `top_p` |
|
||||
| `stop_sequences` | `stop` |
|
||||
| `stream` | `stream` |
|
||||
| `tools` (ToolDef) | `tools` = `[{type: "function", name, description, parameters}]` |
|
||||
| `tool_choice` | `tool_choice` |
|
||||
|
||||
#### extra 字段映射
|
||||
|
||||
| `MessageRequest.extra` key | → Response API 字段 |
|
||||
|---------------------------|---------------------|
|
||||
| `previous_response_id` | `previous_response_id` |
|
||||
| `store` | `store` |
|
||||
| `metadata` | `metadata` |
|
||||
| `truncation` | `truncation` |
|
||||
| `reasoning.effort` | `reasoning: {effort: ...}` |
|
||||
| 内置工具(`web_search` / `file_search` 等) | 追加到 `tools` 数组 |
|
||||
|
||||
> **备注**:当前仅支持 `reasoning.effort` 子字段(值为 `low`/`medium`/`high`),其他子字段(如 `reasoning.summary`)将在后续版本支持。
|
||||
|
||||
### 3.7 响应转换(convert_response)
|
||||
|
||||
| Response API output item | → MessageResponse 中的表示 |
|
||||
|-------------------------|------------------------------|
|
||||
| `{type: "message", role: "assistant", content: [{type: "output_text", text}]}` | `Message::Assistant { content: [ContentBlock::Text { text }] }` |
|
||||
| `{type: "function_call", name, arguments, call_id}` | `ContentBlock::ToolUse { id: call_id, name, input: arguments }` |
|
||||
| `{type: "web_search_call", ...}` | `ContentBlock::Extension { kind: "web_search_call", data: ... }` |
|
||||
| `{type: "reasoning", ...}` | `ContentBlock::Extension { kind: "reasoning", data: ... }` |
|
||||
| `{type: "file_search_call", ...}` | `ContentBlock::Extension { kind: "file_search_call", data: ... }` |
|
||||
|
||||
**status → StopReason 映射**:
|
||||
- `completed` → `StopReason::Stop`
|
||||
- `incomplete` → `StopReason::Length`
|
||||
- `failed` → `StopReason::Other`
|
||||
|
||||
当 `response.output` 为空数组时,返回 `LlmError::Request { status: 200, body: "empty output" }`,表示响应格式异常。
|
||||
|
||||
对于未知的 `item_type`(非 `message`/`function_call`/`web_search_call`/`file_search_call`/`reasoning`),转换为 `ContentBlock::Extension { kind: item_type, data: serde_json::to_value(item)? }` 以保持前向兼容。
|
||||
|
||||
### 3.8 流式 SSE 事件映射
|
||||
|
||||
| Response API SSE event | → StreamEvent |
|
||||
|------------------------|---------------|
|
||||
| `response.created` | `MessageStart { id, model }` |
|
||||
| `response.output_item.added` (type: message) | `ContentBlockStart { index, block_type: Text }` |
|
||||
| `response.output_text.delta` | `TextDelta { text }` |
|
||||
| `response.output_text.done` | `ContentBlockEnd { index }` |
|
||||
| `response.refusal.delta` | `RefusalDelta { text }` |
|
||||
| `response.refusal.done` | `ContentBlockEnd { index }` |
|
||||
| `response.function_call_arguments.delta` | `ToolCallArgumentsDelta { index, arguments }` |
|
||||
| `response.function_call_arguments.done` | `ToolCallEnd { index }` |
|
||||
| `response.completed` | `MessageComplete { full_response }` |
|
||||
| `response.failed` | `Error { message }` |
|
||||
|
||||
### 3.9 流式 SSE 状态机
|
||||
|
||||
`ResponseSseEventStream` 维护以下状态:
|
||||
|
||||
```
|
||||
字段:
|
||||
- byte_stream: reqwest 的 bytes_stream
|
||||
- buffer: Vec<u8>(SSE 行缓冲)
|
||||
- partial: PartialMessageResponse(累积响应状态)
|
||||
- block_index: u32(输出 block 序号计数器)
|
||||
- saw_terminal: bool(是否已见到 response.completed / response.failed)
|
||||
|
||||
流程:
|
||||
line 级解析 → event: + data: 配对
|
||||
→ 反序列化 ResponseSseEvent
|
||||
→ try_into_stream_event() 映射为 StreamEvent
|
||||
→ StreamEvent::apply_to(&mut partial)
|
||||
→ yield StreamEvent
|
||||
response.completed → partial.finalize() → yield MessageComplete
|
||||
response.failed → yield Error
|
||||
```
|
||||
|
||||
### 3.10 错误映射
|
||||
|
||||
复用 `GenericOpenaiProvider` 的 `handle_error_response()` 逻辑:
|
||||
|
||||
| HTTP 状态码 | → LlmError |
|
||||
|------------|------------|
|
||||
| 401 | `LlmError::Authentication(body)` |
|
||||
| 429 | `LlmError::RateLimit { retry_after }` |
|
||||
| 5xx | `LlmError::Request { status, body }` |
|
||||
| 400 + `context_length_exceeded` | `LlmError::ContextLength` |
|
||||
|
||||
### 3.11 Provider 结构体
|
||||
|
||||
```rust
|
||||
pub(crate) struct OpenaiResponseProvider {
|
||||
http_client: Client,
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
timeout_secs: u64,
|
||||
}
|
||||
```
|
||||
|
||||
#### 工厂方法
|
||||
|
||||
```rust
|
||||
impl OpenaiResponseProvider {
|
||||
pub(crate) fn from_parts(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
http_client: Client,
|
||||
timeout_secs: u64,
|
||||
) -> Self {
|
||||
Self { http_client, base_url, api_key, model, timeout_secs }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.12 LlmProvider trait 实现
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
impl LlmProvider for OpenaiResponseProvider {
|
||||
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
|
||||
self.chat_blocking(request).await
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
|
||||
self.chat_stream_inner(request).await
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> ProviderCapabilities { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 3.13 Capabilities
|
||||
|
||||
```rust
|
||||
ProviderCapabilities {
|
||||
provider_name: "openai-response",
|
||||
supported_models: Some(vec![model]),
|
||||
features: ProviderFeatures {
|
||||
streaming: true,
|
||||
thinking: true, // o-series reasoning
|
||||
vision: true, // image input
|
||||
audio_input: false,
|
||||
tool_use: true,
|
||||
parallel_tool_calls: true,
|
||||
system_prompt_in_messages: false,
|
||||
max_context_window: 200_000,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 3.14 工厂函数注册
|
||||
|
||||
```rust
|
||||
ProviderType::OpenaiResponse => {
|
||||
let client = build_client_with_timeout(config.timeout_secs)?;
|
||||
Ok(Box::new(openai_response::OpenaiResponseProvider::from_parts(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
client,
|
||||
config.timeout_secs,
|
||||
)))
|
||||
}
|
||||
```
|
||||
|
||||
### 3.15 多轮接续方案
|
||||
|
||||
第一版走**全量消息历史模式(模式 A)**:
|
||||
|
||||
1. `convert_request()` 把 `MessageRequest.messages` 全部转换为 `input` items
|
||||
2. System 消息拼接到 `instructions`
|
||||
3. User / Assistant / ToolResult 消息转换为对应的 input items
|
||||
4. 如果 `extra` 中有 `previous_response_id`,也传入请求体
|
||||
|
||||
此模式与 `LlmCycle::submit_with_tools()` 完全兼容——`LlmCycle` 在每次提交时都会填充完整的历史 messages,`OpenaiResponseProvider` 只是把这些 messages 全部序列化为 Response API 格式。无需改动 `LlmCycle`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 实施计划
|
||||
|
||||
实施拆分为 3 个 Phase,9 个 Step。
|
||||
|
||||
### Phase 28:Feature gate + Wire 类型 + Provider 骨架(~140 行)
|
||||
|
||||
#### Step 28.1:Cargo.toml feature 定义
|
||||
**文件操作**:修改 `Cargo.toml`
|
||||
|
||||
```toml
|
||||
# 在 [features] 的 Provider features 区域追加
|
||||
provider-openai-response = ["llm", "reqwest", "bytes", "futures-util"]
|
||||
|
||||
# 在 full 快捷组合中追加
|
||||
full = [
|
||||
"...",
|
||||
"provider-openai-response",
|
||||
]
|
||||
```
|
||||
|
||||
**验证**:`cargo build --features "provider-openai-response"` 编译通过
|
||||
|
||||
#### Step 28.2:Wire 类型定义
|
||||
**文件操作**:新建 `src/llm/provider/openai_response.rs`
|
||||
|
||||
定义 §3.5 中的所有 Wire 类型:
|
||||
- `OpenaiResponseRequest`
|
||||
- `ResponseInputItem`(untagged 枚举:Message / FunctionCall / FunctionCallOutput)
|
||||
- `ResponseInputContent`(tagged 枚举:InputText / InputImage)
|
||||
- `ResponseTool`
|
||||
- `OpenaiResponseBody`
|
||||
- `ResponseOutputItem`
|
||||
- `ResponseContentPart`
|
||||
- `ResponseSseEvent`(完整时序事件枚举)
|
||||
- `ResponseSseMeta`
|
||||
|
||||
**无逻辑代码**,只有 `#[derive(Debug, Clone, Serialize, Deserialize)]` 的结构体和枚举。
|
||||
|
||||
**验证**:`cargo build --features "provider-openai-response"` 编译通过
|
||||
|
||||
#### Step 28.3:Provider 结构体 + from_parts
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||
|
||||
- `OpenaiResponseProvider` 结构体
|
||||
- `from_parts()` 工厂方法
|
||||
- 基础 HTTP 工具函数(`build_request_builder`、`handle_error_response`、`map_reqwest_error`)
|
||||
|
||||
**验证**:`cargo build --features "provider-openai-response"` 编译通过
|
||||
|
||||
#### Step 28.4:Factory 注册 + 模块门控
|
||||
**文件操作**:
|
||||
1. 修改 `src/llm.rs` — 在 cfg 条件中追加 `feature = "provider-openai-response"`
|
||||
2. 修改 `src/llm/provider.rs` — 注册 factory
|
||||
|
||||
在 `src/llm.rs` 中修改现有 Provider features cfg 条件:
|
||||
|
||||
```rust
|
||||
#[cfg(any(
|
||||
feature = "provider-openai",
|
||||
feature = "provider-anthropic",
|
||||
feature = "provider-deepseek",
|
||||
feature = "provider-qwen",
|
||||
feature = "provider-ollama",
|
||||
feature = "provider-openai-response",
|
||||
))]
|
||||
pub mod provider;
|
||||
```
|
||||
|
||||
以及在 `src/llm/provider.rs` 的 `create_provider()` match 中替换当前 `Err` 为真实构造。
|
||||
|
||||
**验证**:
|
||||
- `cargo build --features "provider-openai-response"` 编译通过
|
||||
- `cargo build --features "full"` 编译通过
|
||||
|
||||
---
|
||||
|
||||
### Phase 29:核心 Provider 实现(~680 行)
|
||||
|
||||
#### Step 29.1:convert_request(~200 行)
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||
|
||||
实现 `OpenaiResponseProvider::convert_request(&self, request: MessageRequest) -> Result<OpenaiResponseRequest, LlmError>`。
|
||||
|
||||
处理逻辑:
|
||||
1. 遍历 `request.messages`,按 §3.6 消息类型映射表转换
|
||||
2. Assistant 消息回传时设置 `item_type: Some("message".to_string())`,使序列化结果为 `{type: "message", role: "assistant", content: [...]}`;User 消息保持 `item_type: None`,序列化为 `{role: "user", content: [...]}`(无 `type` 字段)
|
||||
3. `request.tools` → `tools` 数组(`ToolDef` → `ResponseTool::Function`)
|
||||
4. `request.extra` → 解析 `previous_response_id` / `store` / `metadata` / `truncation` / `reasoning` 等
|
||||
5. 标准字段映射(model / max_tokens / temperature / top_p / stop / stream)
|
||||
|
||||
#### Step 29.2:convert_response(~100 行)
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||
|
||||
实现 `OpenaiResponseProvider::convert_response(&self, response: OpenaiResponseBody) -> Result<MessageResponse, LlmError>`。
|
||||
|
||||
处理逻辑:
|
||||
1. 遍历 `response.output`,找到第一个 `type: "message"` 的 item,提取 text
|
||||
2. 其他 items(`function_call` → `ContentBlock::ToolUse`,内置工具 → `ContentBlock::Extension`)
|
||||
3. `response.status` → `StopReason`
|
||||
4. `response.usage` → `Usage`
|
||||
|
||||
#### Step 29.3:非流式 chat()(~80 行)
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||
|
||||
实现 `OpenaiResponseProvider::chat_blocking()`:
|
||||
- `convert_request()` → serde 序列化 → HTTP POST `{base_url}/responses`
|
||||
- Auth header: `Authorization: Bearer {api_key}`
|
||||
- 错误处理映射
|
||||
- 解析响应体 → `convert_response()`
|
||||
|
||||
#### Step 29.4:SSE 事件类型 + 状态机(~230 行)
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||
|
||||
实现 `ResponseSseEventStream` 结构体及其 `Stream` trait:
|
||||
- 字段:`byte_stream`, `buffer`, `partial: PartialMessageResponse`, `block_index: u32`, `saw_terminal: bool`
|
||||
- 行级 SSE 解析:`event:` + `data:` 配对
|
||||
- 事件 → `StreamEvent` 映射
|
||||
- `PartialMessageResponse::apply_to()` 累积
|
||||
- 流结束时 `finalize()` → `MessageComplete`
|
||||
|
||||
#### Step 29.5:流式 chat_stream()(~50 行)
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||
|
||||
实现 `OpenaiResponseProvider::chat_stream_inner()`:
|
||||
- `convert_request()` 设置 `stream: true`
|
||||
- HTTP POST → bytes_stream → 包装为 `ResponseSseEventStream`
|
||||
|
||||
#### Step 29.6:LlmProvider impl(~50 行)
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||
|
||||
实现 `LlmProvider for OpenaiResponseProvider`:
|
||||
- `chat()` → `chat_blocking()`
|
||||
- `chat_stream()` → `chat_stream_inner()`
|
||||
- `capabilities()` → 返回 `ProviderCapabilities`
|
||||
|
||||
#### Step 29.7:单元测试(~70 行)
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs` 的 `#[cfg(test)] mod tests {}`
|
||||
|
||||
| 测试 | 场景 |
|
||||
|------|------|
|
||||
| `convert_request_text_only` | 纯文本输入转换 |
|
||||
| `convert_request_vision` | Vision 输入转换 |
|
||||
| `convert_request_tool_call` | 工具调用输入转换 |
|
||||
| `convert_response_message` | 响应 message item 转换 |
|
||||
| `convert_response_tool_use` | 响应 function_call item 转换 |
|
||||
|
||||
---
|
||||
|
||||
### Phase 30:测试 + CI + 文档(~520 行)
|
||||
|
||||
#### Step 30.1:wiremock 非流式测试(~200 行)
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs` 内联测试
|
||||
|
||||
| 测试 | 场景 | 验证 |
|
||||
|------|------|------|
|
||||
| `response_api_basic_text` | 纯文本响应 | `response.text()` 正确 |
|
||||
| `response_api_tool_call` | 工具调用 | `stop_reason == ToolUse` |
|
||||
| `response_api_multi_turn` | 两轮对话 | 第二轮携带历史 |
|
||||
| `response_api_vision` | 图片输入 | 正确构造 `input_image` |
|
||||
| `response_api_unauthorized` | 401 错误 | `LlmError::Authentication` |
|
||||
| `response_api_rate_limit` | 429 错误 | `LlmError::RateLimit` |
|
||||
| `response_api_server_error` | 500 错误 | `LlmError::Request` |
|
||||
|
||||
#### Step 30.2:wiremock 流式测试(~200 行)
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs` 内联测试
|
||||
|
||||
| 测试 | 场景 | 验证 |
|
||||
|------|------|------|
|
||||
| `response_api_stream_text` | 流式文本 | 完整 SSE 事件序列 |
|
||||
| `response_api_stream_tool` | 流式工具调用 | `FunctionCallArgumentsDelta` 序列 |
|
||||
| `response_api_stream_error` | 流中途失败 | `StreamEvent::Error` |
|
||||
| `response_api_stream_multi_turn` | 流式多轮接续 | 第二轮携带历史消息时的完整 SSE 事件序列 |
|
||||
|
||||
#### Step 30.3:CI 矩阵(~10 行)
|
||||
**文件操作**:修改 `.github/workflows/ci.yml`
|
||||
|
||||
新增测试组合:
|
||||
```yaml
|
||||
- "chat,provider-openai,provider-openai-response"
|
||||
```
|
||||
|
||||
#### Step 30.4:文档更新(~50 行)
|
||||
**文件操作**:修改 `README.md` + `docs/roadmap.md`
|
||||
|
||||
- README feature 表新增 `provider-openai-response`
|
||||
- `docs/roadmap.md` 或 `docs/roadmap-unsorted.md` 新增 v0.3.3 或下版本条目
|
||||
|
||||
#### Step 30.5:Example(~60 行)
|
||||
**文件操作**:新建 `examples/response_api_demo.rs`
|
||||
|
||||
```toml
|
||||
[[example]]
|
||||
name = "response_api_demo"
|
||||
required-features = ["llm", "provider-openai-response"]
|
||||
```
|
||||
|
||||
基础对话示例,展示 Response API 的基本用法:
|
||||
|
||||
```
|
||||
cargo run --example response_api_demo --features "full"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 实施汇总
|
||||
|
||||
| Phase | 内容 | 代码行数估算 | 验证入口 |
|
||||
|-------|------|------------|---------|
|
||||
| 28 | Feature gate + Wire 类型 + Provider 骨架 | ~140 | `cargo build --features "provider-openai-response"` |
|
||||
| 29 | 核心 Provider 实现(转换/HTTP/流式) | ~680 | 5 个单元测试 |
|
||||
| 30 | 测试 + CI + 文档 | ~520 | 10 个 wiremock 测试 + CI 新组合 |
|
||||
| **合计** | | **~1,340** | 全量 `cargo test --features "full"` |
|
||||
|
||||
---
|
||||
|
||||
## 5. 风险评估
|
||||
|
||||
| ID | 风险 | 影响 | 概率 | 缓解措施 |
|
||||
|----|------|------|------|---------|
|
||||
| R1 | Response API 协议快速迭代 | Wire 类型可能需更新 | 中 | Wire 类型集中在单个文件内,更新成本低 |
|
||||
| R2 | `ContentBlock::Extension` 承载内置工具结果 | 下游消费方需适配 | 低 | 这是既有的逃生舱机制,已有消费模式 |
|
||||
| R3 | 全量历史模式 token 开销 | 多轮时 input tokens 增长 | 低 | 功能正确,后续版本可优化为 `previous_response_id` 增量模式 |
|
||||
| R4 | `instructions` 拼接多个 system 消息 | 语义可能与单 system 消息不同 | 低 | 已确认按 OpenAI 推荐方式全量拼接(`\n` 分隔),行为等价 |
|
||||
| R5 | 与 `GenericOpenaiProvider` 的错误映射逻辑重复 | 维护两份相似逻辑 | 低 | 提取复用函数时需注意不影响现有 provider |
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证标准
|
||||
|
||||
### 6.1 编译验证
|
||||
|
||||
| # | 检查项 | 命令 |
|
||||
|---|--------|------|
|
||||
| C1 | 独立 feature 编译 | `cargo build --features "provider-openai-response"` |
|
||||
| C2 | full 组合编译 | `cargo build --features "full"` |
|
||||
| C3 | light 组合不受影响 | `cargo build --features "light"`(不包含新 feature) |
|
||||
| C4 | Clippy 合规 | `cargo clippy --all-features --lib -- -D warnings` |
|
||||
|
||||
### 6.2 测试验证
|
||||
|
||||
| # | 检查项 | 通过条件 |
|
||||
|---|--------|---------|
|
||||
| T1 | 单元测试 | `cargo test --features "full"` 全部通过(+15 新增测试) |
|
||||
| T2 | 非流式 wiremock | 7 个测试覆盖文本/工具/多轮/Vision/401/429/500 |
|
||||
| T3 | 流式 wiremock | 3 个测试覆盖文本流/工具流/错误流 |
|
||||
| T4 | 现有测试无回归 | 使用 `--features "full"` 时已有 427 测试全部通过 |
|
||||
|
||||
### 6.3 CI 验证
|
||||
|
||||
| # | 检查项 | 通过条件 |
|
||||
|---|--------|---------|
|
||||
| I1 | 新增 CI 组合 | 包含新 feature 的组合编译通过 |
|
||||
| I2 | clippy + format | `cargo clippy` + `cargo fmt --check` 通过 |
|
||||
|
||||
### 6.4 Example 验证
|
||||
|
||||
| # | 检查项 | 通过条件 |
|
||||
|---|--------|---------|
|
||||
| E1 | Example 编译 | `cargo build --example response_api_demo --features "full"` 通过 |
|
||||
| E2 | Example 运行 | `cargo run --example response_api_demo --features "full"` 可执行(需 API key) |
|
||||
@@ -0,0 +1,422 @@
|
||||
# OpenAI Response Provider 自定义请求头支持
|
||||
|
||||
## 背景
|
||||
|
||||
OpenAI Responses API 的部分实现(如火山引擎豆包)需要携带特殊的 HTTP 请求头(如 `ark-beta-doubao-app: true`)来启用平台特定功能。当前 `OpenaiResponseProvider` 在 `build_request_builder()` 中只设置了 `Authorization` 头,没有途径注入自定义请求头。
|
||||
|
||||
原方案只覆盖 OpenAI Response Provider。经讨论后扩展为**三 Provider 统一**方案:OpenAI Chat(`GenericOpenaiProvider`)、OpenAI Response(`OpenaiResponseProvider`)、Anthropic(`AnthropicProvider`)。
|
||||
|
||||
核心动机:
|
||||
|
||||
- OpenAI Responses API 的部分实现需要携带特殊 HTTP 请求头来启用平台特定功能
|
||||
- 三种基础协议中,自定义头注入能力不一致
|
||||
- 统一 API 让调用方用 `set_extra("custom_headers", ...)` 即可,与底层协议无关
|
||||
|
||||
## 需求
|
||||
|
||||
### 功能需求
|
||||
|
||||
双层自定义头机制:
|
||||
|
||||
- **Provider 级固定头**:`extra_headers: Vec<(String, String)>`,构造时注入,所有请求自动携带。用于该 provider 所有请求都需要的固定标识头(如平台接入标记)
|
||||
- **请求级临时头**:`extra.custom_headers: HashMap<String, String>`,通过 `set_extra` 注入。用于特定请求需要覆盖或追加的头
|
||||
|
||||
### 约束
|
||||
|
||||
- 不可引入任何平台特定逻辑(火山、豆包等字符串不得出现)
|
||||
- 自定义头仅运行时生效,不进入 JSON 序列化的请求体
|
||||
- 兼容已有的 extra 逃生舱机制(builtin_tools、text_format 等)
|
||||
- agcore 是支持库,不提供运行时敏感头过滤保护(如 Authorization/Cookie),但文档中应说明风险
|
||||
- 不修改 `LlmProvider` trait、`ProviderType` 枚举
|
||||
- `create_provider()` 工厂函数只传 `Vec::new()` 作为 extra_headers 默认值,不暴露配置能力;调用方如需 Provider 级固定头,直接构造 provider 后链式调用 `.with_extra_headers()`
|
||||
|
||||
### 用户故事
|
||||
|
||||
1. 作为集成者,我想对任意 provider 的请求注入自定义 HTTP 头,以启用平台特有功能(请求级)
|
||||
2. 作为集成者,我想在 provider 构造时注入固定头,让所有请求自动携带,避免每次重复指定(Provider 级)
|
||||
3. 作为维护者,我想三种基础协议使用统一的 API,调用方无需关心底层 provider 类型
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 统一设计原则
|
||||
|
||||
```
|
||||
调用方视角(统一 API):
|
||||
request.set_extra("custom_headers", json!({"X-Foo": "bar"}));
|
||||
// 不管底层是 OpenAI Chat / OpenAI Response / Anthropic,都能工作
|
||||
|
||||
构造方视角(Provider 级):
|
||||
OpenaiResponseProvider::from_parts(..., extra_headers).with_extra_headers(...);
|
||||
GenericOpenaiProvider::from_parts(..., extra_headers); // 已有
|
||||
AnthropicProvider::from_parts(..., extra_headers);
|
||||
|
||||
头融合顺序(三 provider 一致):
|
||||
认证头 (Authorization / x-api-key) → Provider 级 extra_headers → 请求级 custom_headers
|
||||
↑ 后者覆盖前者
|
||||
```
|
||||
|
||||
### 改动一:GenericOpenaiProvider(openai.rs)
|
||||
|
||||
**① `OpenaiChatRequest` 新增字段**
|
||||
|
||||
在 `extra_body`(第 147 行)之后:
|
||||
|
||||
```rust
|
||||
/// 请求级别自定义 HTTP 头。运行时注入,不进入 JSON 请求体。
|
||||
/// ⚠️ 与 struct 已有的 `extra_headers: Option<Value>`(OpenAI API 自身的 wire 格式字段)
|
||||
/// 不同——后者是 OpenAI API 参数,本字段是 reqwest 层的 HTTP 头注入。
|
||||
#[serde(skip)]
|
||||
pub custom_headers: HashMap<String, String>,
|
||||
```
|
||||
|
||||
`#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。
|
||||
|
||||
**② `convert_request()` 从 extra 提取**
|
||||
|
||||
在 `parallel_tool_calls`(第 559 行)之后:
|
||||
|
||||
```rust
|
||||
let custom_headers: HashMap<String, String> = request
|
||||
.get_extra_opt("custom_headers")
|
||||
.unwrap_or_default();
|
||||
```
|
||||
|
||||
**③ `build_request_builder()` 签名改具体类型 + 注入逻辑**
|
||||
|
||||
第 454 行,签名从 `&impl Serialize` 改为 `&OpenaiChatRequest`(两处调用点传入的均为该类型,安全):
|
||||
|
||||
```rust
|
||||
fn build_request_builder(
|
||||
&self,
|
||||
url: &str,
|
||||
body: &OpenaiChatRequest, // 从 &impl Serialize 改为具体类型
|
||||
) -> Result<reqwest::RequestBuilder, LlmError> {
|
||||
let mut builder = self
|
||||
.http_client
|
||||
.post(url)
|
||||
.header("Authorization", format!("Bearer {}", self.api_key));
|
||||
|
||||
// 头融合顺序见上方「统一设计原则」。
|
||||
// Provider 级固定头先注入,请求级临时头后注入(后者覆盖前者)。
|
||||
|
||||
Ok(builder.json(body))
|
||||
}
|
||||
```
|
||||
|
||||
两处调用点(`chat_blocking` 第 628 行、`chat_stream_inner` 第 669 行)传入的都是 `&OpenaiChatRequest`,零影响。
|
||||
|
||||
**④ `with_extra_headers()` builder 方法**
|
||||
|
||||
```rust
|
||||
/// 注入 Provider 级别固定头。返回 self 以支持链式调用。
|
||||
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
|
||||
self.extra_headers = headers;
|
||||
self
|
||||
}
|
||||
```
|
||||
|
||||
### 改动二:OpenaiResponseProvider(openai_response.rs)
|
||||
|
||||
**① struct 新增 `extra_headers` 字段**
|
||||
|
||||
第 287 行,`pub struct OpenaiResponseProvider` 增加:
|
||||
|
||||
```rust
|
||||
pub struct OpenaiResponseProvider {
|
||||
// ... 已有字段 ...
|
||||
extra_headers: Vec<(String, String)>,
|
||||
}
|
||||
```
|
||||
|
||||
**② `from_parts()` 新增参数**
|
||||
|
||||
第 299 行:
|
||||
|
||||
```rust
|
||||
pub(crate) fn from_parts(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
http_client: Client,
|
||||
timeout_secs: u64,
|
||||
extra_headers: Vec<(String, String)>, // 新增
|
||||
) -> Self { ... }
|
||||
```
|
||||
|
||||
**③ `with_extra_headers()` builder 方法**
|
||||
|
||||
```rust
|
||||
/// 注入 Provider 级别固定头。返回 self 以支持链式调用。
|
||||
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
|
||||
self.extra_headers = headers;
|
||||
self
|
||||
}
|
||||
```
|
||||
|
||||
**④ `OpenaiResponseRequest` 新增字段**
|
||||
|
||||
第 73 行,`reasoning` 之后:
|
||||
|
||||
```rust
|
||||
/// 请求级别自定义 HTTP 头。序列化时跳过,仅运行时由 build_request_builder 消费。
|
||||
/// stream 模式的修改不影响该字段——header 由 convert_request 在请求构造时注入。
|
||||
#[serde(skip)]
|
||||
pub custom_headers: HashMap<String, String>,
|
||||
```
|
||||
|
||||
`#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。
|
||||
|
||||
**⑤ `convert_request()` 从 extra 提取**
|
||||
|
||||
第 404 行,`reasoning` 之后:
|
||||
|
||||
```rust
|
||||
let custom_headers: HashMap<String, String> = extra
|
||||
.get("custom_headers")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
```
|
||||
|
||||
> **注意**:OpenaiResponseProvider 的 `convert_request` 在顶部 destructure 了 `request`,因此使用 `extra.get()` 而非 `request.get_extra_opt()`。两者语义一致,均反序列化为 `HashMap<String, String>`,失败时静默降级为空 HashMap。
|
||||
|
||||
**⑥ `build_request_builder()` 签名 + 注入逻辑**
|
||||
|
||||
第 319 行,签名从 `&impl Serialize` 改为 `&OpenaiResponseRequest`(两处调用点传入的均为该类型,安全):
|
||||
|
||||
```rust
|
||||
/// 构造 HTTP POST 请求 builder(含认证头与额外请求头)。
|
||||
///
|
||||
/// 头融合顺序:Authorization → Provider 级 extra_headers → 请求级 custom_headers
|
||||
/// 后者覆盖前者。
|
||||
fn build_request_builder(
|
||||
&self,
|
||||
body: &OpenaiResponseRequest, // 从 &impl Serialize 改为具体类型
|
||||
) -> Result<reqwest::RequestBuilder, LlmError> {
|
||||
let mut builder = self
|
||||
.http_client
|
||||
.post(self.endpoint_url())
|
||||
.header("Authorization", format!("Bearer {}", self.api_key));
|
||||
|
||||
for (k, v) in &self.extra_headers {
|
||||
builder = builder.header(k.as_str(), v.as_str());
|
||||
}
|
||||
|
||||
for (key, value) in &body.custom_headers {
|
||||
builder = builder.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
Ok(builder.json(body))
|
||||
}
|
||||
```
|
||||
|
||||
两处调用点(`chat_blocking` 第 708 行、`chat_stream_inner` 第 741 行)传入的都是 `&OpenaiResponseRequest`,零影响。
|
||||
|
||||
### 改动三:AnthropicProvider(anthropic.rs)
|
||||
|
||||
AnthropicProvider 是唯一没有统一 `build_request_builder` 方法的 provider,需要**前置重构**。
|
||||
|
||||
**① struct 新增 `extra_headers` 字段**
|
||||
|
||||
第 36 行:
|
||||
|
||||
```rust
|
||||
pub struct AnthropicProvider {
|
||||
// ... 已有字段 ...
|
||||
extra_headers: Vec<(String, String)>,
|
||||
}
|
||||
```
|
||||
|
||||
**② `from_parts()` 新增参数**
|
||||
|
||||
第 128 行:
|
||||
|
||||
```rust
|
||||
pub(crate) fn from_parts(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
http_client: Client,
|
||||
timeout_secs: u64,
|
||||
extra_headers: Vec<(String, String)>, // 新增
|
||||
) -> Self { ... }
|
||||
```
|
||||
|
||||
**③ `with_extra_headers()` builder 方法**
|
||||
|
||||
```rust
|
||||
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
|
||||
self.extra_headers = headers;
|
||||
self
|
||||
}
|
||||
```
|
||||
|
||||
**④ `AnthropicRequestBody` 新增字段**
|
||||
|
||||
第 450 行,`stream` 之后:
|
||||
|
||||
```rust
|
||||
struct AnthropicRequestBody {
|
||||
model: String,
|
||||
max_tokens: u32,
|
||||
// ... 已有字段 ...
|
||||
/// 请求级别自定义 HTTP 头。运行时注入,不进入 JSON 请求体。
|
||||
#[serde(skip)]
|
||||
custom_headers: HashMap<String, String>,
|
||||
}
|
||||
```
|
||||
|
||||
`#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。
|
||||
|
||||
**⑤ `build_request_body()` 从 extra 提取**
|
||||
|
||||
```rust
|
||||
let custom_headers: HashMap<String, String> = request
|
||||
.get_extra_opt("custom_headers")
|
||||
.unwrap_or_default();
|
||||
```
|
||||
|
||||
**⑥ 提取 `build_request_builder()` 统一方法(前置重构)**
|
||||
|
||||
```rust
|
||||
/// 构造 HTTP POST 请求 builder(含认证头 + 自定义头)。
|
||||
/// 认证头(x-api-key / anthropic-version)已由 Client 的 default_headers 提供。
|
||||
fn build_request_builder(
|
||||
&self,
|
||||
body: &AnthropicRequestBody,
|
||||
) -> Result<reqwest::RequestBuilder, LlmError> {
|
||||
let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
|
||||
let mut builder = self.http_client.post(&url).json(body);
|
||||
|
||||
for (k, v) in &self.extra_headers {
|
||||
builder = builder.header(k.as_str(), v.as_str());
|
||||
}
|
||||
|
||||
for (key, value) in &body.custom_headers {
|
||||
builder = builder.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
Ok(builder)
|
||||
}
|
||||
```
|
||||
|
||||
**⑦ 改造 `chat_blocking()` 和 `chat_stream_inner()`**
|
||||
|
||||
改造前(`chat_blocking`,第 263-269 行):
|
||||
|
||||
```rust
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| self.map_reqwest_error(e))?;
|
||||
```
|
||||
|
||||
改造后:
|
||||
|
||||
```rust
|
||||
let response = self
|
||||
.build_request_builder(&body)?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| self.map_reqwest_error(e))?;
|
||||
```
|
||||
|
||||
`chat_stream_inner`(第 298-304 行)同理。
|
||||
|
||||
### 改动四:create_provider()(provider.rs)
|
||||
|
||||
依据约束「`create_provider()` 工厂函数不暴露配置能力」,三处分支适配 `from_parts` 的新签名时全部传 `Vec::new()`:
|
||||
|
||||
```rust
|
||||
// OpenaiResponse(第 199-207 行)
|
||||
openai_response::OpenaiResponseProvider::from_parts(
|
||||
config.base_url, config.api_key, config.model,
|
||||
client, config.timeout_secs,
|
||||
Vec::new(), // extra_headers 默认空
|
||||
)
|
||||
|
||||
// Anthropic(第 215-221 行)
|
||||
anthropic::AnthropicProvider::from_parts(
|
||||
config.base_url, config.api_key, config.model,
|
||||
client, config.timeout_secs,
|
||||
Vec::new(), // extra_headers 默认空
|
||||
)
|
||||
|
||||
// OpenAI Chat(第 185-194 行)— 已有 Vec::new(),无需改动
|
||||
```
|
||||
|
||||
### 调用方式
|
||||
|
||||
**请求级临时头**(统一 API,三 provider 通用):
|
||||
|
||||
```rust
|
||||
request.set_extra("custom_headers", serde_json::json!({
|
||||
"ark-beta-doubao-app": "true"
|
||||
}));
|
||||
```
|
||||
|
||||
**Provider 级固定头**(构造时注入):
|
||||
|
||||
```rust
|
||||
let provider = OpenaiResponseProvider::from_parts(...)
|
||||
.with_extra_headers(vec![
|
||||
("ark-beta-doubao-app".into(), "true".into()),
|
||||
]);
|
||||
```
|
||||
|
||||
## 风险评估
|
||||
|
||||
### 风险点与缓解措施
|
||||
|
||||
| 风险 | 等级 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| 用户通过 `custom_headers` 覆盖 `Authorization` 等认证头 | 中 | 文档说明:自定义头按遍历顺序注入,同 key 后注入覆盖前注入。agcore 作为支持库不做运行时拦截 |
|
||||
| `serde_json::from_value` 类型错误静默降级为空 HashMap | 低 | 与已有 extra 字段(builtin_tools、text_format)一致的模式,保持行为统一。类型错误时请求正常发出,只是不携带自定义头 |
|
||||
| HashMap 迭代顺序不确定影响测试确定性 | 低 | HTTP 协议不要求 header 顺序,wiremock 按名匹配。无需特殊处理 |
|
||||
| AnthropicProvider 前置重构引入回归 | 低 | 提取 `build_request_builder` 是纯重构,现有测试覆盖其请求构造行为。重构后运行现有测试套件即可验证 |
|
||||
| `build_request_builder` 签名从泛型改为具体类型 | 低 | 已确认两处调用点(chat_blocking / chat_stream_inner)传入的均为具体类型,零影响 |
|
||||
| AnthropicProvider 的 `default_headers`(x-api-key / anthropic-version)与 `extra_headers` 同名头合并行为取决于 reqwest 实现 | 低 | 明确约定 Provider 级固定头不应意图覆盖认证头;`build_request_builder` 的 doc comment 中标注认证头来源 |
|
||||
|
||||
### 设计取舍记录
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| Provider 级 vs 请求级 | 双层都支持 | 满足固定头和临时头两种场景 |
|
||||
| `create_provider` 是否暴露 extra_headers | 不暴露,只传 `Vec::new()` | 保持工厂函数签名简洁,固定头通过 builder 方法注入 |
|
||||
| 敏感头保护 | 不做运行时拦截,文档说明 | agcore 是支持库,不替调用方做保护 |
|
||||
| `OpenaiChatRequest.custom_headers` 命名 | 用 `custom_headers` 而非 `extra_headers` | 避免与已有的 `extra_headers: Option<Value>`(OpenAI API wire 字段)混淆 |
|
||||
|
||||
## 验证标准
|
||||
|
||||
### 单元测试(每 provider 4 个)
|
||||
|
||||
| 测试 | 验证点 |
|
||||
|------|--------|
|
||||
| `*_custom_headers_from_extra` | `convert_request` / `build_request_body` 能从 extra 提取 `custom_headers` |
|
||||
| `*_custom_headers_skipped_in_json` | `#[serde(skip)]` 确保 custom_headers 不进入序列化 JSON body |
|
||||
| `*_custom_headers_invalid_type_fallback` | 传入错误类型(如字符串而非对象)时静默降级为空 HashMap |
|
||||
| `*_extra_headers_from_constructor` | 验证 `from_parts` / `new_with_name_and_headers` 传入的 `extra_headers` 在 `build_request_builder` 中被正确注入到 HTTP 请求头 |
|
||||
|
||||
### 集成测试(每 provider 4 个,wiremock)
|
||||
|
||||
| 测试 | 验证点 |
|
||||
|------|--------|
|
||||
| `*_custom_headers_are_sent` | mock 匹配器验证 HTTP 请求确实携带自定义头 |
|
||||
| `*_provider_level_headers_are_sent` | 验证 Provider 级固定头(通过 `with_extra_headers` 注入)确实出现在 HTTP 请求中 |
|
||||
| `*_custom_headers_override_provider_headers` | 当 Provider 级和请求级设置了相同 key 但不同值时,最终 HTTP 请求携带的是请求级的值 |
|
||||
| `*_custom_headers_can_override_auth_header` | 注入含 `Authorization` 同 key 的 `custom_headers`,验证最终认证头值被覆盖(使行为可见、可预测,与文档风险说明一致) |
|
||||
|
||||
### 回归验证
|
||||
|
||||
1. 运行 `cargo test --features full` 确保所有现有测试通过
|
||||
2. `cargo clippy --features full` 无新警告
|
||||
3. `cargo fmt --check` 格式一致
|
||||
|
||||
## 不涉及的改动
|
||||
|
||||
- 不新增 Feature gate
|
||||
- 不修改 `LlmProvider` trait
|
||||
- 不修改 `ProviderType` 枚举
|
||||
- 不新增任何平台相关代码
|
||||
@@ -0,0 +1,268 @@
|
||||
# Builtin Tools 注入修复方案
|
||||
|
||||
## 背景与目标
|
||||
|
||||
### 问题描述
|
||||
|
||||
agcore 的 OpenaiResponseProvider 在通过 extra 逃生舱注入内置工具(`web_search` / `file_search`)时存在两层缺陷,导致 builtin_tools 完全不生效:
|
||||
|
||||
1. **分支逻辑错位**(`convert_request`,第 615-640 行):builtin_tools 注入代码被嵌套在 `tools_defs` 非空的 `else` 分支内。当调用方只提供 builtin_tools 而不提供 tools_defs 时,分支走 `if tools_defs.is_empty() { None }`,注入代码完全不执行。
|
||||
|
||||
2. **枚举不完整**(`ResponseTool`,第 136-146 行):枚举只有 `Function` 一种变体,非 `function` 类型的 builtin 工具(如 `type: "web_search"`)反序列化失败,退化为 `name: ""` 的空函数定义,API 层面被拒绝。
|
||||
|
||||
### 目标
|
||||
|
||||
- 修复 builtin_tools 注入逻辑,使纯内置工具、混用场景均正常工作
|
||||
- 不破坏现有 tools_defs 功能
|
||||
- 添加回归测试
|
||||
|
||||
## 需求分析
|
||||
|
||||
### 功能需求
|
||||
|
||||
| # | 需求 | 优先级 |
|
||||
|---|------|--------|
|
||||
| F1 | `tools_defs` 为空、`builtin_tools` 非空时,正确注入内置工具 | P0 |
|
||||
| F2 | `tools_defs` 和 `builtin_tools` 同时非空时,合并注入 | P0 |
|
||||
| F3 | 两端均为空时,tools 字段为 None(回归保底) | P0 |
|
||||
| F4 | 无效的 builtin_tools 值不导致崩溃,跳过并告警 | P1 |
|
||||
|
||||
### 非功能需求
|
||||
|
||||
- 不做底层架构改造(extra 逃生舱机制不变)
|
||||
- 不改 `openai.rs`(Chat Completions API 不支持内置工具)
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 总体架构
|
||||
|
||||
修复分三步,对应三层独立但不相互依赖的改动:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ convert_request() │
|
||||
│ │
|
||||
│ [改动二] 重构分支逻辑 │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ tools_defs ──→ 生成 Vec<ResponseTool> │ │
|
||||
│ │ builtin_tools ──→ 追加到同一 Vec │ │
|
||||
│ │ 两者都空 ──→ None; 否则 ──→ Some(items) │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [改动三] 错误处理 │
|
||||
│ unwrap_or_else ──→ match + warn! │
|
||||
└─────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ResponseTool 枚举 │
|
||||
│ │
|
||||
│ [改动一] 添加 Builtin(Value) 变体 │
|
||||
│ 自定义 Serialize/Deserialize 避免信息丢失 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 改动一:扩展 `ResponseTool` 枚举
|
||||
|
||||
**位置**:第 136-146 行
|
||||
|
||||
**现状**:
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum ResponseTool {
|
||||
#[serde(rename = "function")]
|
||||
Function {
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: Value,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**改后**(需要自定义 Serialize/Deserialize):
|
||||
```rust
|
||||
/// NOTE: 仅在请求序列化路径使用(convert_request → build_request_builder → HTTP body)。
|
||||
/// 响应反序列化走 ResponseOutputItem,不经过此类型。
|
||||
/// 自定义 Deserialize 服务于 convert_request 内 extra 字段反序列化。
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum ResponseTool {
|
||||
Function {
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: Value,
|
||||
},
|
||||
/// 非 function 类型的工具(如 web_search / file_search / code_interpreter)。
|
||||
/// 直接透传原始 JSON Value,不做结构化解析,避免信息丢失。
|
||||
Builtin(Value),
|
||||
}
|
||||
|
||||
impl Serialize for ResponseTool {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
match self {
|
||||
ResponseTool::Function { name, description, parameters } => {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("type".into(), Value::String("function".into()));
|
||||
map.insert("name".into(), Value::String(name.clone()));
|
||||
map.insert("description".into(), Value::String(description.clone()));
|
||||
map.insert("parameters".into(), parameters.clone());
|
||||
map.serialize(serializer)
|
||||
}
|
||||
ResponseTool::Builtin(value) => value.serialize(serializer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ResponseTool {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let value = Value::deserialize(deserializer)?;
|
||||
match value.get("type").and_then(|t| t.as_str()) {
|
||||
Some("function") => {
|
||||
let name = value.get("name").and_then(|n| n.as_str()).unwrap_or_default().to_string();
|
||||
let description = value.get("description").and_then(|d| d.as_str()).unwrap_or_default().to_string();
|
||||
let parameters = value.get("parameters").cloned().unwrap_or(Value::Null);
|
||||
Ok(ResponseTool::Function { name, description, parameters })
|
||||
}
|
||||
_ => Ok(ResponseTool::Builtin(value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关键点:
|
||||
- 自定义 `Serialize`:`Builtin` 变体直接输出原始 Value(不包裹额外标记)
|
||||
- 自定义 `Deserialize`:非 `"function"` 类型自动走 `Builtin(Value)` 分支
|
||||
- 保留原始 JSON 结构,避免信息丢失(如 `search_context_size`、`user_location` 等字段)
|
||||
|
||||
### 改动二:修复 `convert_request` 分支逻辑
|
||||
|
||||
**位置**:第 615-640 行
|
||||
|
||||
**现状**(伪代码):
|
||||
```
|
||||
if tools_defs.is_empty() {
|
||||
None // ← builtin_tools 被完全跳过
|
||||
} else {
|
||||
从 tools_defs 生成 Vec<ResponseTool>
|
||||
if let Some(builtin_tools) {
|
||||
for v in extra {
|
||||
items.push(from_value(v)) // ← 只有进了 else 才执行
|
||||
}
|
||||
}
|
||||
Some(items)
|
||||
}
|
||||
```
|
||||
|
||||
**改后**(伪代码):
|
||||
```
|
||||
let mut items: Vec<ResponseTool> = Vec::new();
|
||||
|
||||
// 1. 始终处理 tools_defs
|
||||
items.extend(tools_defs.into_iter().map(|t| ResponseTool::Function { ... }));
|
||||
|
||||
// 2. 始终处理 builtin_tools(与 tools_defs 解耦)
|
||||
if let Some(builtin_tools) = builtin_tools {
|
||||
for v in extra {
|
||||
// 见改动三
|
||||
items.push(serde_json::from_value(v).unwrap_or_else(|_| { ... }));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 两者都空 → None;否则 → Some
|
||||
if items.is_empty() { None } else { Some(items) }
|
||||
```
|
||||
|
||||
### 改动三:改进错误处理
|
||||
|
||||
**位置**:第 630-636 行(`unwrap_or_else` 部分)
|
||||
|
||||
**现状**:
|
||||
```rust
|
||||
items.push(serde_json::from_value(v).unwrap_or_else(|_| {
|
||||
ResponseTool::Function {
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
parameters: Value::Null,
|
||||
}
|
||||
}));
|
||||
```
|
||||
|
||||
**改后**:
|
||||
```rust
|
||||
match serde_json::from_value(v.clone()) {
|
||||
Ok(tool) => items.push(tool),
|
||||
Err(e) => {
|
||||
let raw = serde_json::to_string(&v).unwrap_or_default();
|
||||
warn!(tool = %raw, error = %e, "skipped invalid builtin_tool");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`warn!` 输出被反序列化的 Value 摘要,便于生产排障时定位问题(无需复现调用方输入)。
|
||||
|
||||
`unwrap_or_else` 的错误值(空函数定义)在 OpenAI API 层会被拒绝,无实际价值。替换为 `match` + `warn!` 可明确跳过并记录原因。
|
||||
|
||||
### 改动四:添加测试
|
||||
|
||||
在文件末尾 `#[cfg(test)]` 区域或 `tests/` 目录新增 6 个测试用例:
|
||||
|
||||
| # | 用例名 | 场景 | 验证点 |
|
||||
|---|--------|------|--------|
|
||||
| T1 | `test_builtin_only` | 仅提供 builtin_tools | tools 为 Some,含正确 type |
|
||||
| T2 | `test_mixed_tools` | 同时提供 tools_defs + builtin_tools | 合并后 items 顺序/数量正确 |
|
||||
| T3 | `test_no_tools` | 两端均为空 | tools 为 None |
|
||||
| T4 | `test_invalid_builtin` | builtin_tools 含无效 JSON | 不崩溃,有效项保留 |
|
||||
| T5 | `test_function_wire_format` | ResponseTool::Function 序列化 | JSON 结构与改动前一致(AC7) |
|
||||
| T6 | `test_builtin_roundtrip` | ResponseTool::Builtin 反序列化+序列化 | 原始 JSON 结构保留 |
|
||||
|
||||
## 实现计划
|
||||
|
||||
### 步骤
|
||||
|
||||
| 步骤 | 改动 | 文件 | 估算 |
|
||||
|------|------|------|------|
|
||||
| 1 | 扩展 `ResponseTool` 枚举,添加 `Builtin(Value)` + 自定义 Serialize/Deserialize | `openai_response.rs:136-146` | 40 行 |
|
||||
| 2 | 重构 `convert_request` 分支逻辑,解耦 tools_defs 与 builtin_tools | `openai_response.rs:615-640` | 15 行 |
|
||||
| 3 | 替换 `unwrap_or_else` 为 `match` + `warn!` | `openai_response.rs:630-636` | 5 行 |
|
||||
| 4 | 添加 6 个测试用例 | `openai_response.rs` 末尾 | 70 行 |
|
||||
| 5 | `cargo test` 验证全部通过 | - | - |
|
||||
|
||||
### 优先级
|
||||
|
||||
**P0(核心修复)**:步骤 1 + 2,修复分支逻辑和枚举不完整问题。
|
||||
**P1(健壮性)**:步骤 3,改进错误处理。
|
||||
**P1(质量保障)**:步骤 4 + 5,测试覆盖。
|
||||
|
||||
### 依赖关系
|
||||
|
||||
无外部依赖。全部改动限定在 `openai_response.rs` 一个文件内。
|
||||
|
||||
## 风险评估
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|----------|
|
||||
| 自定义 Serialize/Deserialize 实现遗漏边界情况 | 低 | 中 | 测试覆盖所有分支:Function / Builtin / 无效值 |
|
||||
| 现有 `Function` 序列化格式变化 | 低 | 高 | 自定义 Serialize 保持与原 derive 行为一致,测试覆盖 wire 格式 |
|
||||
| `warn!` 日志在生产环境未配置 logger 导致 panic | 低 | 中 | 使用 `tracing::warn!`(已导入),项目已初始化 tracing logger |
|
||||
|
||||
### 回滚方案
|
||||
|
||||
单文件改动,回滚只需 `git checkout -- src/llm/provider/openai_response.rs`。
|
||||
|
||||
## 验收标准
|
||||
|
||||
| # | 验收条件 | 验证方式 |
|
||||
|---|----------|----------|
|
||||
| AC1 | `tools_defs` 空 + `builtin_tools` 含 `{"type":"web_search"}` → 请求体 `tools` 包含 `{"type":"web_search"}` | 单测 T1 |
|
||||
| AC2 | 混用场景 → tools 数组同时包含 function 和非 function 工具 | 单测 T2 |
|
||||
| AC3 | 两端空 → tools 字段为 null/None | 单测 T3 |
|
||||
| AC4 | 无效 builtin_tools → 不 panic,有效项不受影响 | 单测 T4 |
|
||||
| AC5 | 全部现有测试通过 | `cargo test` |
|
||||
| AC6 | `cargo clippy` 无新增警告 | `cargo clippy` |
|
||||
| AC7 | `ResponseTool::Function` 序列化后的 JSON 结构与改动前一致 | 单测:验证字段顺序和值 |
|
||||
|
||||
---
|
||||
|
||||
**编写人**:Writer Agent
|
||||
**编写日期**:2026-07-20
|
||||
**基于**:agcore builtin_tools 注入问题分析结论
|
||||
@@ -0,0 +1,249 @@
|
||||
# PDD:Usage 字段反序列化容错方案
|
||||
|
||||
**状态**:Draft
|
||||
**作者**:Think Agent
|
||||
**日期**:2026-07-27
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
为 `agcore::Usage` 结构体的必填 token 字段添加 `#[serde(default)]` 反序列化容错,使 OpenAI Response API 和 Chat Completions API 的 blocking 调用在火山平台返回不完整的 `usage` 字段时不再崩溃。
|
||||
|
||||
### 触发场景
|
||||
|
||||
dc-management 项目调用火山引擎 Responses API 时,非流式路径报错:
|
||||
|
||||
```
|
||||
OpenAI Response 响应解析失败 error=missing field 'prompt_tokens' at line 1 column 1581
|
||||
```
|
||||
|
||||
根因是 `Usage` 的三个 token 字段(`prompt_tokens`、`completion_tokens`、`total_tokens`)定义为必填 `u32`,而火山平台的 Responses API 返回的 `usage` 对象中这些子字段偶发缺失。
|
||||
|
||||
### 影响面
|
||||
|
||||
4/5 的 LLM 命令被阻断:
|
||||
|
||||
| 功能 | 命令 | 状态 |
|
||||
|------|------|------|
|
||||
| AI 探索(产品采集) | `llm_collect` → `workflow.rs` `.chat()` | ❌ 不可用 |
|
||||
| 普通聊天 | `llm_chat` → `llm.rs` `.chat()` | ❌ 不可用 |
|
||||
| 要素值正则化 | `llm_normalize_values` → `normalizer.rs` `.chat()` | ❌ 不可用 |
|
||||
| 数据验证 | `validator.rs` `.chat()` | ❌ 不可用 |
|
||||
| 连接验证 | `verify_llm_connection` → `chat_stream()` | ✅ 幸免(流式) |
|
||||
|
||||
## 2. 需求推演概要
|
||||
|
||||
### 需求拆解
|
||||
|
||||
- **核心需求**:blocking 路径下 `Usage` 反序列化不因缺失子字段而崩溃
|
||||
- **范围边界**:只改 serde 反序列化行为,不改字段类型,不改流式路径
|
||||
- **质量属性**:最小变更(±3 行)、零副作用、测试可验证
|
||||
|
||||
### 关键假设
|
||||
|
||||
| 假设 | 依据 | 验证方式 |
|
||||
|------|------|---------|
|
||||
| `Usage` 已 derive `Default` | 代码确认 | 编译通过 |
|
||||
| 缺失字段默认 0 对所有消费者安全 | `CostTracker::add` 使用 `saturating_add`;流式路径已有 `unwrap_or(0)` 行为 | 代码审查 |
|
||||
| `#[serde(default)]` 不影响序列化 | serde 明确语义 | 代码审查(serde 明确语义) |
|
||||
| `usage: null` 不会出现 | 当前未观察到,留 `ponytail:` 注释 | 生产观察 |
|
||||
| Chat Completions blocking 路径存在同源缺失风险 | 两路径共用火山平台底层 API 基础设施 | 在 §5.4 添加 `#[serde(default)]` 预防性加固(经 PRD 作者推演阶段确认) |
|
||||
|
||||
## 3. 当前问题分析
|
||||
|
||||
### 根因
|
||||
|
||||
- `src/llm/types/usage.rs:4-12`:`Usage` 结构体的 `prompt_tokens`、`completion_tokens`、`total_tokens` 定义为 **必填 `u32`**,非 `Option<u32>`
|
||||
- `src/llm/provider/openai_response.rs:207`:`OpenaiResponseBody` 内嵌 `usage: Usage`,也是必填
|
||||
- `src/llm/provider/openai.rs:245`:`OpenaiChatResponse` 内嵌 `usage: Usage`,同样必填
|
||||
- 火山平台 Responses API 返回的 `usage` 对象中,子字段缺失 → serde 反序列化 `missing field` 错误
|
||||
- 流式路径不受影响:其 `PartialUsage` 所有字段均为 `Option<u32>`,缺失时通过 `unwrap_or(0)` 兜底
|
||||
|
||||
### 已知线索
|
||||
|
||||
- dc-management 的 `verify_llm_connection` 已预见到此问题,注释特意说明使用流式规避
|
||||
- 这是已知的设计约束——流式已容错但 blocking 路径未同步加固
|
||||
|
||||
## 4. 架构决策记录
|
||||
|
||||
| 决策 | 选项 | 选择 | 理由 |
|
||||
|------|------|------|------|
|
||||
| 容错机制 | `#[serde(default)]` vs 改为 `Option<u32>` vs 自定义 `Deserialize` | `#[serde(default)]` | 最小变更,不改类型语义 |
|
||||
| 覆盖范围 | 仅 Responses API vs 同时覆盖 Chat Completions | 同时覆盖 | 增量成本≈0,防患于未然(经 PRD 作者推演阶段确认) |
|
||||
| 测试范围 | 2 个场景 vs 4 个场景 | 4 个场景 | 必要的边界覆盖(缺失 key / 缺失字段 / 空对象 / 完整回归) |
|
||||
|
||||
## 5. 设计方案
|
||||
|
||||
### 5.1 变更概览
|
||||
|
||||
**3 行 serde attribute + 4 个测试用例,零逻辑变更。**
|
||||
|
||||
### 5.2 变更一:`Usage` 结构体加 struct-level `#[serde(default)]`
|
||||
|
||||
**文件**:`src/llm/types/usage.rs:3`
|
||||
|
||||
```diff
|
||||
- #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
+ #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
+ #[serde(default)]
|
||||
pub struct Usage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completion_tokens_details: Option<CompletionTokensDetails>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
}
|
||||
```
|
||||
|
||||
**作用**:JSON 中缺失 `Usage` 的 **任何** 子字段时,自动使用 `Default::default()` 取值:
|
||||
- 三个 token 字段(`u32`):缺失时默认 `0`
|
||||
- 两个 `Option` 详情字段:缺失时默认 `None`
|
||||
|
||||
**为什么 struct-level 够用**:struct-level 对子字段统一生效,不需要为每个字段单独标注。
|
||||
|
||||
### 5.3 变更二:`OpenaiResponseBody.usage` 加 `#[serde(default)]`
|
||||
|
||||
**文件**:`src/llm/provider/openai_response.rs:207`
|
||||
|
||||
```diff
|
||||
pub(crate) struct OpenaiResponseBody {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub output: Vec<ResponseOutputItem>,
|
||||
+ #[serde(default)]
|
||||
pub usage: Usage,
|
||||
pub status: String,
|
||||
}
|
||||
```
|
||||
|
||||
**作用**:整个 `usage` 键在 JSON 中完全缺失时,自动默认 `Usage::default()`。
|
||||
|
||||
### 5.4 变更三:`OpenaiChatResponse.usage` 加 `#[serde(default)]`
|
||||
|
||||
**文件**:`src/llm/provider/openai.rs:245`
|
||||
|
||||
```diff
|
||||
pub(crate) struct OpenaiChatResponse {
|
||||
pub id: String,
|
||||
pub object: String,
|
||||
pub created: u64,
|
||||
pub model: String,
|
||||
pub choices: Vec<Choice>,
|
||||
+ #[serde(default)]
|
||||
pub usage: crate::llm::types::usage::Usage,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_fingerprint: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub service_tier: Option<ServiceTier>,
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 自动受益路径(无需变更)
|
||||
|
||||
| 路径 | 字段 | 为何已安全 |
|
||||
|------|------|-----------|
|
||||
| `OpenaiChatChunk.usage` | `Option<Usage>` | `Option` 天然兜底 `None`;`Some({不全})` 被 `Usage` struct-level `#[serde(default)]` 兜住。注意:此路径仅用于流式反序列化,API 流式 last chunk 的 usage 通常完整,受益场景概率极低 |
|
||||
| `MessageResponse.usage` | `Usage`(IR 层) | IR 层不直接反序列化 JSON,只从 provider 传递已解析的值 |
|
||||
|
||||
### 5.6 未覆盖的已知边界
|
||||
|
||||
```rust
|
||||
// ponytail: #[serde(default)] handles missing key; null usage not observed from API,
|
||||
// but if it appears, add deserialize_with to map null → Usage::default()
|
||||
```
|
||||
|
||||
`"usage": null` 会导致 serde 尝试将 `null` 反序列化为 `Usage` 结构体,当前方案无法兜底。当前未观察到该行为,暂不处理。
|
||||
|
||||
### `total_tokens` 缺失时的语义不一致
|
||||
|
||||
当 API 返回 `{"prompt_tokens": 10, "completion_tokens": 5}` 但缺失 `total_tokens` 时,struct-level `#[serde(default)]` 使 `total_tokens = 0`,而非 `10 + 5 = 15`。`CostTracker::add` 使用 `saturating_add` 不会崩溃,但聚合统计中 `total_tokens` 可能不等于 `prompt_tokens + completion_tokens` 之和。此行为与流式 `PartialUsage::into_usage()` 的 `unwrap_or(0)` 一致,属于已知的简化取舍。
|
||||
|
||||
### Responses API 流式路径 usage 恒为零(pre-existing)
|
||||
|
||||
`ResponseSseMeta`(`openai_response.rs:335-340`)不包含 `usage` 字段,导致流式 `response.completed` 事件的 token 用量信息未被捕获,`MessageComplete.full_response.usage` 恒为零。这是一个独立于本次变更的 pre-existing 缺陷。阻塞路径加 `#[serde(default)]` 后,两条路径行为一致(皆为零值),不引入新差异。此问题建议作为后续独立跟踪项处理。
|
||||
|
||||
### 5.7 效应链路
|
||||
|
||||
```
|
||||
Usage struct-level #[serde(default)]
|
||||
├─ OpenaiResponseBody.usage: Usage ← 子字段缺失兜住
|
||||
│ └─ field-level #[serde(default)] ← 整个 key 缺失兜住
|
||||
├─ OpenaiChatResponse.usage: Usage ← 子字段缺失兜住(自动受益)
|
||||
│ └─ field-level #[serde(default)] ← 整个 key 缺失兜住(新增)
|
||||
└─ OpenaiChatChunk.usage: Option<Usage> ← 子字段缺失兜住(自动受益,无需改动)
|
||||
└─ Option 已有 skip_serializing_if ← None 时跳过
|
||||
```
|
||||
|
||||
## 6. 实施步骤
|
||||
|
||||
| 步骤 | 文件 | 操作 | 验证 |
|
||||
|------|------|------|------|
|
||||
| 1 | `src/llm/types/usage.rs:3` | 加 `#[serde(default)]` | `cargo build` |
|
||||
| 2 | `src/llm/provider/openai_response.rs:207` | 加 `#[serde(default)]` | `cargo build` |
|
||||
| 3 | `src/llm/provider/openai.rs:245` | 加 `#[serde(default)]` | `cargo build` |
|
||||
| 4 | `openai_response.rs` tests 模块 | 加 4 个测试用例 | `cargo test` |
|
||||
| 5 | 全量检查 | `cargo test && cargo clippy` | 无失败/新增警告 |
|
||||
|
||||
**预计时长**:30 分钟(含测试编写与验证)。
|
||||
|
||||
## 7. 验证标准
|
||||
|
||||
- [ ] `Usage` 结构体三个 token 字段在 JSON 缺失时不报错,默认值为 `0`
|
||||
- [ ] `OpenaiResponseBody.usage` 键在 JSON 中完全缺失时不报错,默认 `Usage::default()`
|
||||
- [ ] `OpenaiChatResponse.usage` 键在 JSON 中完全缺失时不报错,默认 `Usage::default()`
|
||||
- [ ] 序列化行为不受影响(输出 JSON 仍包含全部 token 字段)
|
||||
- [ ] `cargo test` 全部通过
|
||||
- [ ] `cargo clippy` 无新增警告
|
||||
- [ ] dc-management 项目 `llm_collect`(AI 探索)命令正常返回结果(agcore 发布后由 dc-management 侧执行)
|
||||
- [ ] dc-management 项目 `llm_chat`(普通聊天)命令正常响应(agcore 发布后由 dc-management 侧执行)
|
||||
- [ ] 序列化后反序列化 roundtrip 验证字段值不变
|
||||
- [ ] `CostTracker` 和 `session.usage()` 的聚合逻辑不依赖 `total_tokens == prompt_tokens + completion_tokens` 恒等式
|
||||
|
||||
### 测试清单
|
||||
|
||||
| 测试名 | 场景 | 输入 | 断言 |
|
||||
|--------|------|------|------|
|
||||
| `deserialize_missing_usage_fields` | usage 存在但缺子字段 | `"usage": {"prompt_tokens": 5}` | 缺省字段默认 0 |
|
||||
| `deserialize_missing_usage_key` | 整个 usage key 缺失 | 无 `usage` 字段 | `Usage::default()` |
|
||||
| `deserialize_empty_usage_object` | `usage: {}` | 空对象 | 全字段默认 0/None |
|
||||
| `deserialize_full_usage_with_details` | 完整 usage(含 details) | 含所有字段 | 正确解析,类型不变 |
|
||||
| `deserialize_usage_missing_prompt_tokens` | 精确复现报错场景:usage 存在但缺 `prompt_tokens` | `"usage": {"completion_tokens": 5, "total_tokens": 5}` | `prompt_tokens` 默认 0 |
|
||||
| `deserialize_response_body_missing_usage_key` | `OpenaiResponseBody` 上下文中 usage key 完全缺失 | 完整 `OpenaiResponseBody` JSON 无 `usage` | `usage == Usage::default()` |
|
||||
| `deserialize_chat_response_missing_usage_key` | `OpenaiChatResponse` 上下文中 usage key 完全缺失 | 完整 `OpenaiChatResponse` JSON 无 `usage` | `usage == Usage::default()` |
|
||||
| `deserialize_chat_response_missing_usage_fields` | `OpenaiChatResponse` 的 usage 存在但缺子字段 | 完整 `OpenaiChatResponse` JSON,`usage` 仅含 `prompt_tokens` | 缺省字段默认 0 |
|
||||
| `serialize_deserialize_roundtrip` | 序列化后反序列化,验证字段值不变 | 完整 `Usage` 结构体 | roundtrip 后字段值一致 |
|
||||
|
||||
## 8. 回滚方案
|
||||
|
||||
逐个 revert 三个文件中的 `#[serde(default)]` 行,删除对应的测试用例。回滚后功能恢复原状(无数据迁移、无配置变更)。
|
||||
|
||||
## 9. 非目标
|
||||
|
||||
- 不修改 `Usage` 字段类型(保持 `u32`,不改为 `Option<u32>`)
|
||||
- 不修改流式路径(`PartialUsage` / `ResponseSseMeta` 的 usage 捕获问题单独处理)
|
||||
- 不修改其他 provider(`Anthropic` / `Ollama`,它们有自己的反序列化逻辑)
|
||||
- 不处理 `"usage": null` 边界(未观察到,留 `ponytail:` 注释)
|
||||
|
||||
## 10. 风险评估
|
||||
|
||||
| 风险 | 影响 | 可能性 | 应对方向 |
|
||||
|------|------|--------|---------|
|
||||
| `"usage": null` 反序列化失败 | 崩溃 | 低 | 未观察到;留 ponytail 注释 |
|
||||
| 零值 Usage 掩盖 API 异常 | 计费数据不全 | 低 | `CostTracker::add` 可加 `warn!` 日志 |
|
||||
| `prompt + completion ≠ total` | 聚合语义不一致 | 低 | `PartialUsage` 已有同样行为,接受 |
|
||||
| 下游依赖升级后反序列化行为变化 | 无 | 极低 | 类型不变、字段名不变 |
|
||||
|
||||
## 11. 术语表
|
||||
|
||||
| 术语 | 定义 | 说明 |
|
||||
|------|------|------|
|
||||
| blocking 路径 | `LlmProvider::chat()` 非流式调用 | 单次 HTTP 请求,完整 JSON 响应后一次性解析 |
|
||||
| streaming 路径 | `LlmProvider::chat_stream()` 流式调用 | 通过 SSE 逐事件推送,usage 可选 |
|
||||
| `#[serde(default)]` | serde 属性宏 | 反序列化时缺失字段使用类型的 `Default` 实现填充 |
|
||||
|
||||
## 12. 历史版本
|
||||
|
||||
| 版本 | 日期 | 变更说明 |
|
||||
|------|------|---------|
|
||||
| v1 | 2026-07-27 | 初始版本,基于 PRD 1 推演 |
|
||||
@@ -0,0 +1,222 @@
|
||||
# 模板编译器 UTF-8 编码修复方案
|
||||
|
||||
- 状态:Approved(第 1 轮审查通过,已按审查结论修正)
|
||||
- 作者:think
|
||||
- 日期:2026-08-03
|
||||
- 关联 PRD:`design/prd/2-模板编译器UTF-8编码修复需求.md`
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
agcore 的 `src/prompt/template.rs` 模板编译器有 6 处 `bytes[i] as char`(将 UTF-8 字节逐字节强转为 Unicode 码点,等价 Latin-1 解码),导致含中文等多字节字符的模板经 `compile → render` 后产生 mojibake 乱码,且乱码会原样发送给 LLM。下游 dc-management 的 system 提示词(采集策略 validator / collector)已全部乱码,属静默失败——模型在容忍乱码的情况下继续工作,但提示词中的规则约束已被破坏。
|
||||
|
||||
目标(对齐 PRD §1、§4 v1):
|
||||
|
||||
- 统一修复 6 处逐字节强转,非 ASCII 文本逐字符正确保留
|
||||
- 补充回归测试(模板编译器首次补测试)
|
||||
- 版本 0.3.6 → 0.3.7,patch 发布
|
||||
- 公开 API 不变、ASCII 模板行为零变化
|
||||
|
||||
## 2. 需求推演概要
|
||||
|
||||
### 2.1 需求拆解
|
||||
|
||||
v1 必做三项:6 处修复(含 parse_tag 内标签内容)、回归测试、版本号更新。v2 无。
|
||||
|
||||
非目标:不重构语法、不加新功能、不改公开 API、不修改其他模块(全库已扫描确认无同类风险点)、不涉及下游回切决策。裸闭合标签(顶层出现 `{{/xxx}}`)的静默截断行为(template.rs L248-249 既有 `tag.starts_with("/") => break`)不在本次修复范围,后续单独评估。
|
||||
|
||||
### 2.2 边界识别
|
||||
|
||||
- 模板语法标签(`{{`、`}}`、`#if`、`#each`、`#raw`)均为 ASCII,字节比较判断语法安全,保持不变
|
||||
- 非 ASCII 文本必须逐字符保留(修复目标是编译输出与模板原文一致)
|
||||
- ASCII 模板行为零变化是硬约束(向后兼容,需回归测试保障)
|
||||
|
||||
### 2.3 关键假设
|
||||
|
||||
1. **UTF-8 结构性保证**:多字节字符的 continuation bytes 恒在 0x80–0xBF,首字节 ≥ 0xC0,而 `{`=0x7B、`}`=0x7D、`#`=0x23——任何多字节字符的任何字节都不可能等于语法字符,故 `bytes[i]` 字节比较永远不会在多字节字符内部误命中
|
||||
2. **字符边界不变量**:循环中 i 起始为 0(边界);`{{`/`}}` 检测命中后 `end = i + 2`(`{`/`}` 各 1 字节,保持边界);字符推进按 `len_utf8()`(保持边界)。因此 `template[i..]` 切片不会 panic
|
||||
3. 6 处 `as char` 是全库唯一编码风险点(grep `as char|char::from` 确认仅 template.rs 6 处)
|
||||
4. 模板文件经 `include_str!` 加载有编译期 UTF-8 校验,编码问题不可能存在;日志/发送链路无转码——问题仅存在于模板编译器的内存字符串处理
|
||||
|
||||
## 3. 当前问题分析
|
||||
|
||||
### 3.1 根因
|
||||
|
||||
6 处 `bytes[i] as char` 明细:
|
||||
|
||||
| 行号 | 函数 | 破坏内容 |
|
||||
|------|------|---------|
|
||||
| 255 | `compile_fragments` literal 分支 | 模板纯文本(主要破坏点) |
|
||||
| 275 | `parse_tag` | `{{ 标签 }}` 内部内容(当前 ASCII 变量名未触发,中文变量名同样损坏) |
|
||||
| 324 | `parse_block` else_body | `#if` else 分支块体 |
|
||||
| 326 | `parse_block` body | `#if` 分支块体 |
|
||||
| 364 | `parse_each_block` body | `#each` 块体 |
|
||||
| 389 | `parse_raw_block` content | `#raw` 块内容 |
|
||||
|
||||
中文每字 3 字节(emoji 4 字节)被拆成多个 Latin-1 字符(0xE4→ä、0xBD→½),产生 mojibake。
|
||||
|
||||
### 3.2 关键代码结构观察
|
||||
|
||||
- `parse_block` / `parse_each_block` / `parse_raw_block` 遇到 `{{` 标签时用 `template[i..end]` 原样字符串切片推回(该路径天然保留 UTF-8),只有逐字节累积路径被破坏
|
||||
- `parse_tag` 当前签名 `fn parse_tag(bytes: &[u8], start: usize)` 只收字节切片,无法按字符边界推进,需要改签名
|
||||
- 4 个调用点:223(compile_fragments)/ 296(parse_block)/ 344(parse_each_block)/ 380(parse_raw_block),且宿主函数均已持有 `template: &str` 参数,调用点改换为纯机械替换
|
||||
- `bytes` 局部变量在各函数中仍被 `{{` 判断使用,不可删除
|
||||
|
||||
## 4. 架构决策记录
|
||||
|
||||
### ADR-1:采用「字节索引 + 字符边界推进」(方案 A)
|
||||
|
||||
| 方案 | 描述 | 结论 |
|
||||
|------|------|------|
|
||||
| A. 字节索引 + 字符推进(采纳) | 保持 `bytes[i]` ASCII 判断,字符累积改 `template[i..].chars().next()` + `len_utf8()` 推进 | ✅ 改动最小,标签检测、`template[i..end]` 切片、递归编译逻辑零变动,正确性有结构性论证 |
|
||||
| B. 整体重构 chars 迭代器(否决) | 编译器是「索引 + 原样子串回填」混合模型,迭代器消费性导致 4 个函数的位置换算全部重写、嵌套 depth 管理重做 | ❌ 回归风险远高于收益 |
|
||||
| C. 手写 UTF-8 长度表(否决) | 避免 chars() 解码 | ❌ 引入手写 0xC0/0xE0/0xF0 分支,标准库更可靠,性能差异可忽略 |
|
||||
|
||||
### ADR-2:`parse_tag` 签名 `&[u8]` → `&str`
|
||||
|
||||
- 理由:类型系统强制 UTF-8 保证,未来维护者想再写 `bytes[i] as char` 必须显式 `as_bytes()`,从类型层面降低复发概率;改动成本几乎为零(1 处签名 + 4 处调用点机械替换)
|
||||
- 否决替代:内部 `from_utf8(bytes)` 转换以保持 `&[u8]` 签名——引入不可能触发的错误分支和 O(n) 校验,语义绕
|
||||
|
||||
### ADR-3:加 `debug_assert!(template.is_char_boundary(i))`
|
||||
|
||||
- 零成本(仅 debug 构建生效)故障信号,防未来索引推进逻辑回归
|
||||
|
||||
## 5. 设计方案
|
||||
|
||||
### 5.1 修复模式(6 处统一)
|
||||
|
||||
保持 `bytes[i]` 对 ASCII 语法字符判断不变;字符累积分支统一改为:
|
||||
|
||||
```rust
|
||||
} else {
|
||||
debug_assert!(template.is_char_boundary(i));
|
||||
match template[i..].chars().next() {
|
||||
Some(ch) => {
|
||||
// 推入对应 String(literal / content / body / else_body)
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
None => return Err(PromptError::Parse("模板包含非法字符序列".to_string())),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **`None` 分支语义统一为显式失败**:6 处全部返回 `Err(PromptError::Parse("模板包含非法字符序列"))`。特别地,`compile_fragments` 的 `None` 分支**绝不能**写 `break`——顶层循环 break 后会以 `Ok(fragments)` 返回,静默丢弃模板剩余部分;其余 5 处 break 虽会落到函数末尾的 `Err`(非静默),但错误消息为「未闭合」不准确。统一显式 `Err` 使防御失效时可观测,且消息一致准确
|
||||
- **None 分支为纯防御,结构性保证下不可达**:`while i < len` + 字符边界不变量保证 `template[i..]` 非空;且 `template[i..]` 在非字符边界处切片会先 panic(str 切片要求字符边界),`chars().next()` 返回 None 实际不会发生。该分支的价值在于:未来维护者若将索引逻辑改写为宽容 API(如 `get(i..)`),防御分支仍能保证显式失败而非静默错误
|
||||
- 每字符常数开销约 2–3 倍于原逐字节路径,但模板编译是低频一次性操作(register 时编译 / register_lazy 首次 render),渲染热路径走 Fragment AST 与此无关,无需优化;纯 ASCII 快速路径属于过度设计,明确不做
|
||||
|
||||
### 5.2 `parse_tag` 签名变更
|
||||
|
||||
```rust
|
||||
fn parse_tag(template: &str, start: usize) -> Result<(String, usize), PromptError> {
|
||||
let bytes = template.as_bytes(); // {{ 判断仍用字节比较
|
||||
// ...内容累积同样按 5.1 模式按字符边界推进
|
||||
}
|
||||
```
|
||||
|
||||
- 4 个调用点(223 / 296 / 344 / 380 行):`parse_tag(bytes, i)` → `parse_tag(template, i)`
|
||||
- 改动后 grep `parse_tag(` 复核 4 处调用点无遗漏
|
||||
|
||||
### 5.3 测试设计(`#[cfg(test)] mod tests` 新建于 template.rs)
|
||||
|
||||
测试清单(对应 PRD §4 清单 + 评审增量,共 16 个用例):
|
||||
|
||||
| # | 测试 | 覆盖点 |
|
||||
|---|------|--------|
|
||||
| 1 | 纯中文模板 compile+render 与原文逐字符一致 | 主破坏路径 literal 恒等 |
|
||||
| 2 | 真实故障素材:PRD §3.1 原文「你是采集策略专家,负责审查已采集的产品编码结果,决策下一轮搜索方向。」及含 ★、→、中文引号「」、全角标点的样本 | 故障现场固化回归(增量 B) |
|
||||
| 3 | 中文 + `{{ var }}` 变量插值混合 | 混合渲染 |
|
||||
| 4 | 中文位于 `#if` body | 块内中文 |
|
||||
| 5 | 中文位于 `#if` else 分支 | 块内中文 |
|
||||
| 6 | 中文位于 `#each` body | 块内中文(循环变量固定 `{{item}}`,Array 用 `TemplateContext::from_json(&json!(...))` 构造) |
|
||||
| 7 | 中文位于 `#raw` 内容 | 块内中文 |
|
||||
| 8 | emoji 等 4 字节字符 | 4 字节字符保留 |
|
||||
| 9 | 多字节字符紧邻 `{{` / `}}` 边界 | 边界解析 |
|
||||
| 10 | 模板以中文结尾(EOF 边界) | 尾部边界 |
|
||||
| 11 | 空模板 | 边界 |
|
||||
| 12 | 中文 + 未闭合 `{{` → 返回 Err 且不 panic | 错误路径(增量 A),断言 `matches!(err, PromptError::Parse(_))`;None 防御分支结构性不可达、不单独设用例,本用例确保索引推进边界改动不引入 panic |
|
||||
| 13 | 纯 ASCII 模板渲染与修复前一致 | 向后兼容回归:渲染结果等于预定义期望输出(PRD §7 要求,勿做快照对比);以 composer.rs 既有 4 个 ASCII 测试为硬基线 |
|
||||
| 14 | 中文变量名 `{{ 问候 }}` | 标签内非 ASCII 内容(增量,PRD 已有) |
|
||||
| 15 | 中文 `#if` 条件 | 标签内非 ASCII 内容(增量,PRD 已有) |
|
||||
| 16 | 嵌套块:外层 `#if` 分支内含 `#each` + 中文文本 | 嵌套块中多字节字符正确保留(审查观察补充) |
|
||||
|
||||
测试写法约定(增量 D):
|
||||
|
||||
- 错误断言必须用 `matches!`(`PromptError` 只 derive 了 `Error, Debug`,无 `PartialEq`,`assert_eq!` 不可用——本模块首个测试文件最易踩的坑)
|
||||
- `#each` 循环变量硬编码为 `item`(渲染器 `child_ctx.vars.insert("item", ...)` 固定)
|
||||
- Array 构造沿用 composer.rs 既有模式:`TemplateContext::from_json(&serde_json::json!({...}))`
|
||||
- 避免对含 `TemplateValue::Object` 的渲染输出做全等断言(HashMap 无序迭代,Display 输出不稳定)
|
||||
- 测试函数签名可用 `-> Result<(), PromptError>` + `?` 或与 composer.rs 一致的 unwrap 风格
|
||||
- 测试 12 的未闭合标签错误断言:`tpl.unwrap_err()` 对 compile 返回值合法可调用(要求 `PromptTemplate: Debug`,Ok 时 panic),但 `PromptError` 无 `PartialEq`,拿 err 后无法 `assert_eq!` 比对变体;建议直接 `assert!(PromptTemplate::compile("中文{{未闭合").is_err())` 或 `matches!(tpl.unwrap_err(), PromptError::Parse(_))`
|
||||
|
||||
### 5.4 版本号
|
||||
|
||||
- `Cargo.toml` version 0.3.6 → 0.3.7
|
||||
- 若仓库提交 Cargo.lock,确认 lock 中 agcore 条目随 `cargo build` 更新并一并提交
|
||||
|
||||
## 6. 实施步骤
|
||||
|
||||
| 步骤 | 操作 | 验证 |
|
||||
|------|------|------|
|
||||
| 1 | 跑基线:`cargo test` | composer.rs 4 个 ASCII 模板测试通过(硬基线) |
|
||||
| 2 | 改 `parse_tag` 签名 + 4 调用点 | `cargo build` 通过 |
|
||||
| 3 | 6 处字符推进修复 + debug_assert | `grep -n "as char\|char::from" src/` 无结果 |
|
||||
| 4 | 新增 `#[cfg(test)] mod tests`(16 个用例) | `cargo test` 全绿 |
|
||||
| 5 | Cargo.toml 0.3.6 → 0.3.7 + Cargo.lock 同步 | 版本号确认 |
|
||||
| 6 | `cargo clippy` | 无新增警告 |
|
||||
| 7 | 步骤 2–6 的改动合并为一次提交(避免「可编译但含缺陷」的中间态单独提交),打 tag v0.3.7(发布动作由维护者执行) | tag 描述含验证指引 |
|
||||
|
||||
## 7. 验证标准
|
||||
|
||||
对齐 PRD §7 验收标准,并补充增量 A:
|
||||
|
||||
- [ ] 中文模板经 compile + render 后与原文逐字符一致(含纯文本、变量插值混合、真实故障素材)
|
||||
- [ ] 中文位于 #if / #each / #raw 块体内时正确保留
|
||||
- [ ] emoji 等 4 字节字符正确保留
|
||||
- [ ] 多字节字符紧邻 {{ / }} 边界时解析正确
|
||||
- [ ] 模板以中文结尾、空模板等边界场景不 panic
|
||||
- [ ] 中文 + 未闭合标签 → 返回 Err 且不 panic(增量 A)
|
||||
- [ ] 纯 ASCII 模板渲染结果与修复前完全一致(composer.rs 4 个测试为基线全部通过,新测试断言等于预定义期望输出)
|
||||
- [ ] 6 处逐字节强转全部消除(grep `as char|char::from` 于 `src/` 无结果)
|
||||
- [ ] cargo test 全部通过(含新增 16 个测试)
|
||||
- [ ] cargo clippy 无新增警告
|
||||
- [ ] Cargo.toml 版本为 0.3.7
|
||||
|
||||
## 8. 发布计划
|
||||
|
||||
| 阶段 | 范围 | 说明 |
|
||||
|------|------|------|
|
||||
| v1 | 6 处修复 + 16 个回归测试 + 版本号 0.3.7 | 本方案范围 |
|
||||
| 发布 | 打 tag v0.3.7,推送 origin | 实际发布动作由维护者执行 |
|
||||
| 发布说明 | `git tag -a v0.3.7` tag 描述(增量 E): | 不更新 CHANGELOG |
|
||||
| 下游升级 | dc-management 升级 agcore 引用至 v0.3.7 | 下游自行评估是否回切模板链路 |
|
||||
|
||||
tag 描述建议内容(增量 E,写入发布说明):
|
||||
|
||||
1. 修复说明:模板编译器 UTF-8 编码修复(6 处逐字节强转改为按字符边界推进),中文模板经 compile+render 后与原文一致
|
||||
2. 最小验证代码(可复制):
|
||||
|
||||
```rust
|
||||
use agcore::prompt::{PromptTemplate, TemplateContext};
|
||||
let tpl = PromptTemplate::compile("你是助手:{{msg}}").unwrap();
|
||||
let mut ctx = TemplateContext::new();
|
||||
ctx.insert("msg", "你好");
|
||||
assert_eq!(tpl.render(&ctx).unwrap(), "你是助手:你好");
|
||||
```
|
||||
|
||||
3. 行为变更提示:修复后 LLM 将从「读乱码提示词」变为「读正确提示词」,此前被破坏的规则约束(编码格式、品牌限定等)恢复生效,模型输出可能明显变化——升级后建议跑一轮真实采集对比验证后再正式切换
|
||||
|
||||
## 9. 回滚方案
|
||||
|
||||
- 代码回滚:`git revert` 该修复 commit,回到 0.3.6 行为
|
||||
- 下游回退:dc-management 将 agcore 依赖回退至 0.3.6 即可恢复原行为(无 API 变更,回退无迁移成本)
|
||||
- 注意:回滚即恢复乱码行为,仅作为应急手段;正确路径是验证后继续使用 0.3.7
|
||||
- 版本号冲突:`git revert` 后 Cargo.toml 回到 0.3.6,若应急后需重新发布,将撞上已发布的 v0.3.6/v0.3.7 tag,应升级至 0.3.8
|
||||
|
||||
## 10. 历史版本
|
||||
|
||||
| 版本 | 日期 | 变更说明 |
|
||||
|------|------|---------|
|
||||
| v1 | 2026-08-03 | 首版方案(基于 PRD v1 + 双顾问评审增量) |
|
||||
| v2 | 2026-08-03 | 第 1 轮审查结论修正:None 分支统一显式 Err(消除静默截断)、测试 13 断言措辞对齐 PRD、新增嵌套块测试 16、回滚补版本号冲突说明、grep 范围标注 src/ |
|
||||
| v3 | 2026-08-03 | 第 2 轮复审修正:§6 步骤 4 用例数 15→16 全文统一、unwrap_err 断言表述订正、§7 第 7 条补「预定义期望输出」对齐 PRD、§2.1 非目标补充裸闭合标签静默截断不在范围 |
|
||||
@@ -0,0 +1,181 @@
|
||||
# PRD:Usage 字段反序列化容错
|
||||
|
||||
**状态**:Draft
|
||||
**作者**:proposal
|
||||
**日期**:2026-07-27
|
||||
|
||||
## 1. 核心目标
|
||||
|
||||
为 `agcore::Usage` 结构体的必填 token 字段添加 `#[serde(default)]` 反序列化容错,使 OpenAI Response API 的 blocking(非流式)调用在火山平台返回不完整的 `usage` 字段时不再崩溃。
|
||||
|
||||
## 2. 目标用户与场景
|
||||
|
||||
| 用户角色 | 使用场景 | 核心诉求 |
|
||||
|---------|---------|---------|
|
||||
| dc-management 使用者 | AI 探索(产品信息采集) | 点击「AI 探索」后能正常返回结果,不因 usage 缺失而报错 |
|
||||
| dc-management 使用者 | LLM 聊天 | 普通聊天功能正常响应 |
|
||||
| dc-management 使用者 | 要素值正则化(Phase 2) | 数据归一化流程不因 usage 解析失败而中断 |
|
||||
| agcore 下游 crate | 任何使用 `agcore::llm` 且后端可能不返回完整 usage 的项目 | 反序列化鲁棒性提升 |
|
||||
|
||||
## 3. 问题描述
|
||||
|
||||
### 3.1 报错信息
|
||||
|
||||
dc-management 项目调用火山引擎 Responses API 时,非流式路径报错:
|
||||
|
||||
```
|
||||
OpenAI Response 响应解析失败 error=missing field 'prompt_tokens' at line 1 column 1581
|
||||
```
|
||||
|
||||
### 3.2 影响面
|
||||
|
||||
经排查,以下功能被阻断(4/5 的 LLM 命令受影响):
|
||||
|
||||
| 功能 | 命令 | 状态 |
|
||||
|------|------|------|
|
||||
| AI 探索(产品采集) | `llm_collect` → `workflow.rs` `.chat()` | ❌ 不可用 |
|
||||
| 普通聊天 | `llm_chat` → `llm.rs` `.chat()` | ❌ 不可用 |
|
||||
| 要素值正则化 | `llm_normalize_values` → `normalizer.rs` `.chat()` | ❌ 不可用 |
|
||||
| 数据验证 | `validator.rs` `.chat()` | ❌ 不可用 |
|
||||
| 连接验证 | `verify_llm_connection` → `chat_stream()` | ✅ 幸免(流式) |
|
||||
|
||||
### 3.3 根因
|
||||
|
||||
- **`llm/types/usage.rs:4-12`**:`Usage` 结构体的 `prompt_tokens`、`completion_tokens`、`total_tokens` 定义为 **必填 `u32`**,非 `Option<u32>`
|
||||
- **`llm/provider/openai_response.rs:207`**:`OpenaiResponseBody` 内嵌 `usage: Usage`,也是必填
|
||||
- 火山平台的 Responses API 返回的 `usage` 对象中,三个必填 token 子字段**缺失**(`usage` 键存在但子字段不完整),触发了 serde 反序列化的 `missing field` 错误
|
||||
- 流式路径不受影响:其 `PartialUsage` 所有字段均为 `Option<u32>`,缺失时通过 `unwrap_or(0)` 兜底
|
||||
|
||||
### 3.4 已知线索
|
||||
|
||||
- dc-management 的 `verify_llm_connection`(连接验证函数)**已预见到此问题**,注释明确说明特意使用流式来规避(`llm.rs:368-371`)
|
||||
- 这是已知的设计约束——流式已容错但 blocking 路径未同步加固
|
||||
|
||||
## 4. 功能清单
|
||||
|
||||
### v1 必做
|
||||
|
||||
- **`Usage` 结构体加 struct-level `#[serde(default)]`**(`src/llm/types/usage.rs:3`):
|
||||
```diff
|
||||
- #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
+ #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
+ #[serde(default)]
|
||||
pub struct Usage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completion_tokens_details: Option<CompletionTokensDetails>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
}
|
||||
```
|
||||
- 作用:JSON 中缺失 `Usage` 的**任何**子字段时,自动使用 `Default::default()` 取值
|
||||
- 三个 token 字段:缺失时默认 `0`
|
||||
- 两个 `Option` 详情字段(`completion_tokens_details` / `prompt_tokens_details`):缺失时默认 `None`
|
||||
- **类型全部保持不动**,不改为 `Option`,不丢失语义
|
||||
- 等效代码行数:1 行
|
||||
|
||||
- **`OpenaiResponseBody.usage` 加 `#[serde(default)]`**(`src/llm/provider/openai_response.rs:207`):
|
||||
```diff
|
||||
pub(crate) struct OpenaiResponseBody {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub output: Vec<ResponseOutputItem>,
|
||||
+ #[serde(default)]
|
||||
pub usage: Usage,
|
||||
pub status: String,
|
||||
}
|
||||
```
|
||||
- 作用:整个 `usage` 键在 JSON 中完全缺失时,自动默认 `Usage::default()`
|
||||
- 等效代码行数:1 行
|
||||
|
||||
**变更合计:2 行 serde 属性宏,零逻辑变更,零类型变更。**
|
||||
|
||||
### v1 可选
|
||||
|
||||
- 无(上述两项即可完整修复)
|
||||
|
||||
### v2 考虑
|
||||
|
||||
- 无——struct-level `#[serde(default)]` 已覆盖所有子字段,`completion_tokens_details` / `prompt_tokens_details` 的 `#[serde(default)]` 需求已在 v1 中一并解决
|
||||
- 流式路径的 `ResponseSseMeta` 不包含 `usage` 字段(`src/llm/provider/openai_response.rs:335-340`),`response.completed` 事件的 usage 信息未被捕获。这是一个独立的缺陷,与当前问题无关,建议单独处理
|
||||
|
||||
### 非目标
|
||||
|
||||
- 不修改 `Usage` 字段类型(保持 `u32`,不改为 `Option<u32>`)
|
||||
- 不修改流式路径(`PartialUsage` 的 usage 捕获问题单独处理)
|
||||
- 不修改其他 provider(`OpenaiChat` / `Anthropic`),它们有自己的反序列化逻辑
|
||||
|
||||
## 5. 边界与假设
|
||||
|
||||
| 边界 / 假设 | 来源 | 说明 |
|
||||
|-------------|------|------|
|
||||
| `Usage` 已 derive `Default` | 代码确认 | `#[serde(default)]` 直接使用 `Default` 实现(全字段 `0`) |
|
||||
| 缺失字段默认 0 对所有消费者安全 | 代码审查 | 所有消费方(`CostTracker::add`、`MessageResponse` 使用处)都在做 `saturating_add`,`0` 是安全值。流式路径的 `PartialUsage.into_usage()` 已经 `unwrap_or(0)`,行为一致 |
|
||||
| 序列化行为不受影响 | serde 语义 | `#[serde(default)]` 仅在反序列化缺失字段时生效,不影响序列化输出 |
|
||||
| 报错提示的 `missing field` 确认是子字段缺失而非整个 usage 缺失 | 错误消息分析 | 错误消息 `missing field 'prompt_tokens'` 说明 JSON 路径 `usage.prompt_tokens` 不存在即键 `usage` 存在但子字段缺失,因此 `#[serde(default)]` 加在子字段上是必要条件 |
|
||||
|
||||
## 6. 术语表
|
||||
|
||||
| 术语 | 定义 | 说明 |
|
||||
|------|------|------|
|
||||
| blocking 路径 | `LlmProvider::chat()` 非流式调用 | 发送单个 HTTP 请求,等待完整 JSON 响应后一次性解析 |
|
||||
| streaming 路径 | `LlmProvider::chat_stream()` 流式调用 | 通过 SSE 逐事件推送,usage 可选 |
|
||||
| `#[serde(default)]` | serde 属性宏 | 反序列化时如果字段缺失,使用类型的 `Default` 实现填充 |
|
||||
| Responses API | OpenAI 标准 `POST /responses` 协议 | 区别于 Chat Completions(`POST /chat/completions`) |
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
- [ ] `Usage` 结构体三个 token 字段在 JSON 缺失时不报错,默认值为 `0`
|
||||
- [ ] `OpenaiResponseBody.usage` 键在 JSON 中完全缺失时也不报错,默认值为 `Usage::default()`
|
||||
- [ ] dc-management 的 `llm_collect`(AI 探索)命令正常返回结果
|
||||
- [ ] dc-management 的 `llm_chat`(普通聊天)命令正常响应
|
||||
- [ ] 已有序列化行为不受影响(输出 JSON 仍包含全部 token 字段)
|
||||
- [ ] `cargo test` 全部通过
|
||||
- [ ] `cargo clippy` 无新增警告
|
||||
- [ ] 补两条反序列化测试:
|
||||
- `response_api_missing_usage_fields` — mock 响应中 `usage` 对象缺失 `prompt_tokens` / `completion_tokens` / `total_tokens`,验证不报错且 usage 字段全为 0
|
||||
- `response_api_missing_usage_key` — mock 响应中完全没有 `usage` 键,验证不报错且默认 `Usage::default()`
|
||||
|
||||
## 8. 风险评估
|
||||
|
||||
| 风险 | 影响 | 可能性 | 应对方向 |
|
||||
|------|------|--------|---------|
|
||||
| 下游 crate 升级 agcore 后依赖 usage 字段不缺失 | 无——`#[serde(default)]` 不改变已有行为 | 极低 | 类型不变、字段名不变 |
|
||||
| 序列化时 `#[serde(default)]` 影响输出 | 无——`#[serde(default)]` 只影响反序列化 | 极低 | serde 明确语义 |
|
||||
| 测试覆盖不足 | 当前 mock 响应均携带完整 usage,不会触发新路径 | 中 | 建议加一条 usage 缺失的测试用例 |
|
||||
|
||||
## 9. 发布计划(可选)
|
||||
|
||||
| 阶段 | 范围 | 时间 |
|
||||
|------|------|------|
|
||||
| v1 | 两处变更(usage.rs 3 字段 + openai_response.rs 1 字段) | 即日 |
|
||||
| 发布 | 打 tag(如 `v0.3.6` 或 `v0.3.5-usage-fix`),更新 dc-management 引用 | 即日 |
|
||||
|
||||
## 10. 历史版本
|
||||
|
||||
| 版本 | 日期 | 变更说明 |
|
||||
|------|------|---------|
|
||||
| v1 | 2026-07-27 | 人工种子(原始) |
|
||||
|
||||
### 种子内容
|
||||
|
||||
发起方:dc-management 项目 `src/routes/products/ai-explore/` 页面「AI 探索」功能报错
|
||||
|
||||
报错信息:
|
||||
|
||||
```
|
||||
OpenAI Response 响应解析失败 error=missing field 'prompt_tokens' at line 1 column 1581
|
||||
```
|
||||
|
||||
根因:
|
||||
|
||||
- `src/llm/types/usage.rs` 中 `Usage` 结构的 `prompt_tokens`, `completion_tokens`, `total_tokens` 为必填 `u32`
|
||||
- `src/llm/provider/openai_response.rs` 的 `OpenaiResponseBody` 内嵌 `usage: Usage`
|
||||
- 火山平台 Responses API 返回的 usage 子字段缺失 → serde 反序列化失败
|
||||
|
||||
修复方向:
|
||||
|
||||
- `Usage` 三字段加 `#[serde(default)]`(缺失时默认 0)
|
||||
- `OpenaiResponseBody.usage` 加 `#[serde(default)]`(整个缺失时默认 Usage::default())
|
||||
@@ -0,0 +1,172 @@
|
||||
# PRD:模板编译器 UTF-8 编码修复
|
||||
|
||||
**状态**:Draft
|
||||
**作者**:proposal
|
||||
**日期**:2026-08-03
|
||||
|
||||
## 1. 核心目标
|
||||
|
||||
修复 `agcore::prompt::PromptTemplate` 模板编译器(`src/prompt/template.rs`)对非 ASCII(UTF-8)文本的编码破坏问题:当前 6 处 `bytes[i] as char` 将 UTF-8 字节逐字节强转为 Unicode 码点(等价 Latin-1 解码),导致所有含中文等多字节字符的模板经 `compile → render` 后输出乱码(mojibake),且该乱码会原样发送给 LLM。本次修复使模板编译器对非 ASCII 文本逐字符正确保留,同时保持 ASCII 模板行为完全不变。
|
||||
|
||||
## 2. 目标用户与场景
|
||||
|
||||
| 用户角色 | 使用场景 | 核心诉求 |
|
||||
|---------|---------|---------|
|
||||
| agcore 下游 crate(如 dc-management) | 使用 `PromptTemplate` 编译中文 system prompt 模板(validator / collector 采集策略) | 模板文本经编译渲染后与原文一致,不产生乱码 |
|
||||
| agcore 下游 crate | 使用 `PromptTemplate` 的 `#if` / `#each` / `#raw` 块语法,块体内含中文 | 块内容逐字符正确保留 |
|
||||
| agcore 维护者 | 修复后发布 patch 版本,下游可升级 | 公开 API 不变、ASCII 行为不变、有回归测试保障 |
|
||||
|
||||
## 3. 问题描述
|
||||
|
||||
### 3.1 现象
|
||||
|
||||
dc-management 项目的探索调试日志(`log/explore-20260803-013158915066.txt`)显示:发送给 LLM 的 system 提示词全部乱码:
|
||||
|
||||
```
|
||||
ä½ æ¯éä¾ç¥ç¥ä¸å®¶ï¼è´è´£å®¡æ¥å·²é产åç¼ç ç»æï¼å³çä¸ä¸è½®æç´¢æ¹åã
|
||||
```
|
||||
|
||||
(原文为「你是采集策略专家,负责审查已采集的产品编码结果,决策下一轮搜索方向。」)
|
||||
|
||||
日志中的 user 消息与 LLM 响应均为正常中文,证明:乱码发生在**内存字符串**中(模板编译阶段),实际发送给 LLM 的请求体就是损坏文本;日志文件本身(纯 UTF-8 直写)只是如实记录了已损坏的内容。该缺陷为静默失败——模型在容忍乱码的情况下继续工作,但提示词中的规则约束(编码格式、品牌限定等)已被破坏,直接影响下游采集质量。
|
||||
|
||||
### 3.2 根因
|
||||
|
||||
`src/prompt/template.rs` 共 6 处 `bytes[i] as char`(将 UTF-8 字节逐字节强转为 Unicode 码点,等价 Latin-1 解码):
|
||||
|
||||
| 行号 | 位置 | 破坏内容 |
|
||||
|------|------|---------|
|
||||
| 255 | `compile_fragments` literal 分支 | 模板纯文本(**主要破坏点**) |
|
||||
| 275 | `parse_tag` | `{{ 标签 }}` 内部内容(当前为 ASCII 变量名,未触发;若使用中文变量名同样损坏) |
|
||||
| 324 | `parse_block` else_body | `#if` 分支的 else 块体 |
|
||||
| 326 | `parse_block` body | `#if` 分支块体 |
|
||||
| 364 | `parse_each_block` body | `#each` 块体 |
|
||||
| 389 | `parse_raw_block` content | `#raw` 块内容 |
|
||||
|
||||
中文 UTF-8 编码每字 3 字节(emoji 等 4 字节),被拆成多个 Latin-1 字符(如 `0xE4 → ä`、`0xBD → ½`),产生 mojibake。所有经 `PromptTemplate::compile → render` 链路的非 ASCII 模板文本均被破坏。
|
||||
|
||||
### 3.3 影响面
|
||||
|
||||
- 所有使用 `PromptTemplate` 且模板含非 ASCII 文本的 agcore 下游项目
|
||||
- dc-management 的采集策略(validator)与产品信息采集(collector)两条 system prompt 链路全部受影响
|
||||
- 已确认模板文件本身为合法 UTF-8、日志写出链路无转码、发送链路无转码——问题仅存在于模板编译器
|
||||
|
||||
### 3.4 已知线索
|
||||
|
||||
- 下游使用 `include_str!` 宏加载模板文件(如 dc-management 的 `src-tauri/src/llm/prompts.rs`),该宏具有编译期 UTF-8 校验:非法编码的模板文件在编译期即报错,故模板文件编码问题不可能存在
|
||||
- agcore 仓库 `src/prompt/template.rs` 当前没有任何测试(无 `#[cfg(test)]` 模块),本次为模板编译器首次补测试
|
||||
- 模板语法标签(`{{ }}`、`#if`、`#each`、`#raw`)均为 ASCII,字节比较判断语法是安全的,无需改动
|
||||
|
||||
## 4. 功能清单
|
||||
|
||||
### v1 必做
|
||||
|
||||
- **统一修复 6 处 `bytes[i] as char`**(`src/prompt/template.rs` 255 / 275 / 324 / 326 / 364 / 389 行):
|
||||
- 保持现有 `bytes[i]` 对 ASCII 语法字符(`b'{'` / `b'}'` 等)的字节判断不变
|
||||
- 字符累积改为按 UTF-8 字符边界推进:从当前位置取完整字符(如 `template[i..].chars().next()`),再按该字符的 UTF-8 长度推进索引(`i += ch.len_utf8()`)
|
||||
- 效果:非 ASCII 文本(中文 / emoji / 任意多字节字符)逐字符正确保留;ASCII 文本行为与修复前完全一致
|
||||
|
||||
- **补充回归测试**(`src/prompt/template.rs` 新建 `#[cfg(test)] mod tests`):
|
||||
- 纯中文模板 compile + render 后与原文逐字符一致(主破坏路径 literal)
|
||||
- 中文文本 + `{{ var }}` 变量插值混合渲染正确
|
||||
- 中文文本位于 `#if` / `#each` / `#raw` 块体内时正确保留
|
||||
- emoji 等 4 字节字符正确保留
|
||||
- 多字节字符紧邻 `{{` / `}}` 标签边界的解析正确性
|
||||
- 纯 ASCII 模板渲染结果与修复前一致(向后兼容回归)
|
||||
- 标签内部含非 ASCII 内容(中文变量名如 `{{ 问候 }}`、中文 `#if` 条件)的解析与渲染正确 ← PM Advisor
|
||||
|
||||
- **版本号更新**:`Cargo.toml` version `0.3.6` → `0.3.7`
|
||||
|
||||
### v1 可选
|
||||
|
||||
- 无(上述三项即可完整修复)
|
||||
|
||||
### v2 考虑
|
||||
|
||||
- 无。CHANGELOG 记录暂不做(本次需求明确暂不记录),后续版本若需要可单独补充
|
||||
|
||||
### 非目标
|
||||
|
||||
- 不重构模板语法(标签体系、块结构保持现状)
|
||||
- 不新增模板功能(新标签、新渲染特性)
|
||||
- 不改动公开 API(`PromptTemplate::compile` / `render` 签名与语义不变)
|
||||
- 不修改其他模块(已扫描 `as char` / `from_utf8_lossy` 等模式,项目中无同类编码风险点)
|
||||
- 不涉及下游 dc-management 是否回切模板链路的决策(由下游另行评估)
|
||||
|
||||
## 5. 边界与假设
|
||||
|
||||
| 边界 / 假设 | 来源 | 说明 |
|
||||
|-------------|------|------|
|
||||
| ASCII 语法判断可保留 | 语法分析 | 模板标签均为 ASCII(`{`、`}`、`#`、`/`、`>`),`bytes[i]` 字节比较对 ASCII 安全 |
|
||||
| 非 ASCII 文本必须逐字符保留 | 需求确认 | 修复目标是编译输出与模板原文一致 |
|
||||
| ASCII 模板行为零变化 | 需求确认 | 向后兼容是硬约束,需回归测试保障 |
|
||||
| 公开 API 不变 | 需求确认 | `compile` / `render` 签名不变,下游无需改代码 |
|
||||
| 修复范围 6 处统一 | 用户确认 | 「尽可能全面的修复」,包括 parse_tag 内的变量名内容 |
|
||||
| 测试纳入本次范围 | 用户确认 | 用户明确要求补充测试 |
|
||||
| 版本为 patch(0.3.7) | 用户确认 | bug 修复走 patch 版本,尽快让下游升级 |
|
||||
| CHANGELOG 暂不记录 | 用户确认 | 本次发布不更新 CHANGELOG |
|
||||
|
||||
## 6. 术语表
|
||||
|
||||
| 术语 | 定义 | 说明 |
|
||||
|------|------|------|
|
||||
| mojibake | 乱码 | 文本因编码解码不匹配产生的字符错乱,此处为 UTF-8 字节被按 Latin-1 逐字节解码 |
|
||||
| Latin-1 | ISO-8859-1 单字节编码 | 0x00–0xFF 每个字节对应一个 Unicode 码点;`u8 as char` 等价于该解码 |
|
||||
| `len_utf8()` | Rust 标准库方法 | 返回 `char` 的 UTF-8 编码长度(1–4 字节),用于按字符边界推进索引 |
|
||||
| `PromptTemplate` | agcore 提示词模板引擎 | 支持变量插值 `{{ var }}`、条件渲染 `#if`、循环 `#each`、原始块 `#raw` |
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
- [ ] 中文模板经 `compile` + `render` 后与原文逐字符一致(含纯文本、变量插值混合)
|
||||
- [ ] 中文位于 `#if` / `#each` / `#raw` 块体内时正确保留
|
||||
- [ ] emoji 等 4 字节字符正确保留
|
||||
- [ ] 多字节字符紧邻 `{{` / `}}` 边界时解析正确
|
||||
- [ ] 纯 ASCII 模板渲染结果与修复前完全一致(以 `src/prompt/composer.rs` 既有 4 个 ASCII 编译测试为基线全部通过,新测试断言等于预定义期望输出)← PM Advisor
|
||||
- [ ] 6 处逐字节强转全部消除(grep 模式 `as char|char::from` 验证)← PM Advisor
|
||||
- [ ] `cargo test` 全部通过(含新增测试)
|
||||
- [ ] `cargo clippy` 无新增警告
|
||||
- [ ] `Cargo.toml` 版本为 0.3.7
|
||||
|
||||
## 8. 风险评估
|
||||
|
||||
| 风险 | 影响 | 可能性 | 应对方向 |
|
||||
|------|------|--------|---------|
|
||||
| 索引推进改动引入边界回归(模板尾部、空模板、`{{` 未闭合) | 解析错误或 panic | 中 | 新增测试覆盖边界;保持 ASCII 判断逻辑不变 |
|
||||
| parse_tag 内标签内容的解码方式与 literal 不一致 | 中文变量名场景损坏残留 | 低 | 6 处统一采用同一修复模式 |
|
||||
| 下游对 0.3.7 的升级未验证中文模板 | 下游继续受影响 | 中 | tag 描述中明确修复内容与最小验证步骤(编译含中文的模板对比输出)← PM Advisor |
|
||||
| 未来新增模板代码时再次引入逐字节强转 | 同类 bug 复发 | 低 | 回归测试(中文断言)可捕获此类问题 |
|
||||
|
||||
## 9. 发布计划
|
||||
|
||||
| 阶段 | 范围 | 说明 |
|
||||
|------|------|------|
|
||||
| v1 | 6 处修复 + 回归测试 + 版本号 0.3.7 | 本 PRD 范围 |
|
||||
| 发布 | 打 tag `v0.3.7`,推送 origin | 实际发布动作由维护者执行 |
|
||||
| 发布说明 | 以 `git tag -a v0.3.7` 的 tag 描述作为发布说明载体(附最小验证:编译含中文的模板并对比输出)← PM Advisor | 不更新 CHANGELOG,tag 描述随仓库分发 |
|
||||
| 下游升级 | dc-management 升级 agcore 引用至 v0.3.7 | 下游自行评估是否回切模板链路 |
|
||||
|
||||
## 10. 历史版本
|
||||
|
||||
| 版本 | 日期 | 变更说明 |
|
||||
|------|------|---------|
|
||||
| v1 | 2026-08-03 | 人工种子(原始) |
|
||||
|
||||
### 种子内容
|
||||
|
||||
发起方:dc-management 项目排查探索调试日志 `log/explore-20260803-013158915066.txt`,发现发送给 LLM 的 system 提示词乱码。
|
||||
|
||||
原始需求描述(一字不改):
|
||||
|
||||
「检查一下当前日志 log 目录中保存的日志文件,似乎现在发送给LLM的提示词存在乱码?」
|
||||
|
||||
根因:
|
||||
|
||||
- `src/prompt/template.rs` 中 6 处 `bytes[i] as char`(255 / 275 / 324 / 326 / 364 / 389 行)将 UTF-8 字节逐字节强转为 Unicode 码点(等价 Latin-1 解码),非 ASCII 模板文本被破坏
|
||||
- 日志文件、发送链路、模板文件编码均无问题,乱码发生在内存字符串(模板编译阶段)
|
||||
|
||||
修复方向(用户确认):
|
||||
|
||||
- 6 处统一修复:保持 ASCII 语法判断不变,字符累积按 UTF-8 字符边界推进
|
||||
- 补充回归测试(中文模板恒等、混合插值、块内中文、emoji、边界、ASCII 回归)
|
||||
- `Cargo.toml` 0.3.6 → 0.3.7,打 tag `v0.3.7`
|
||||
- CHANGELOG 暂不记录
|
||||
@@ -0,0 +1,200 @@
|
||||
# AG Core Roadmap — Unsorted
|
||||
|
||||
> 本文件存放**尚未归到任何具体版本**的 roadmap 内容:跨版本的全局视图、面向未来的展望、风险与建议、阶段总回顾。
|
||||
>
|
||||
> **已分版本的内容**:请查阅
|
||||
> - [`roadmap-v0.1.0.md`](./roadmap-v0.1.0.md) — Phase 0–4c + v0.1.0 Release
|
||||
> - [`roadmap-v0.2.0.md`](./roadmap-v0.2.0.md) — Phase 5–12 + v0.2.0-rc.1
|
||||
> - [`roadmap-v0.3.0.md`](./roadmap-v0.3.0.md) — Phase 13–19(全部完成)
|
||||
> - [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md) — Phase A-E 多 Agent 编排路线图
|
||||
>
|
||||
> 返回总入口:[`roadmap.md`](./roadmap.md)
|
||||
|
||||
---
|
||||
|
||||
## 全局愿景
|
||||
|
||||
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
|
||||
|
||||
**当前状态**:v0.3.5。Phase 0-30 全部完成。v0.4.0 规划已确定,覆盖 5 个增量 Phase(A-E):Swarm 编排抽象、结果聚合、Human-in-the-loop + 用户 Steering、TokenJuice 语义压缩、自动校正。目标是从"多 Agent 基础系统"升级为"多 Agent 多职责编排系统"。
|
||||
|
||||
---
|
||||
|
||||
## 模块完整性评估
|
||||
|
||||
| 功能领域 | 方案状态 | 文档位置 | 实现优先级 |
|
||||
|---------|---------|---------|-----------|
|
||||
| LLM 调用周期 | ✅ 完整 | `specs/llm-call-lifecycle.md` | P0 |
|
||||
| 提示词工程 | ✅ 完整 | `docs/4-prompt-engineering.md` | P1 |
|
||||
| 工具系统 + 权限 | ✅ 完整 | `docs/5-tool-system.md` | P1 |
|
||||
| 记忆检索 | ✅ 完整 | `docs/6-memory-system.md` | P2 |
|
||||
| Agent 运行时(4a 胶水层) | ✅ 已实现 | `docs/7-agent-runtime.md` | P2 |
|
||||
| 生命周期钩子 | ✅ 完整 | `docs/3-phase0-remaining.md` | P0(LLM Cycle 扩展) |
|
||||
| Provider 注册发现 | ✅ 完整 | `docs/3-phase0-remaining.md` | P0(Provider 接口扩展) |
|
||||
| 流式事件系统 | ✅ 完整 | `docs/3-phase0-remaining.md` | P0(流式接口前置) |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## v0.4.0 规划
|
||||
|
||||
v0.4.0 的完整规划已移入独立的 [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md),包含 5 个增量 Phase:
|
||||
|
||||
| Phase | 内容 | 状态 |
|
||||
|-------|------|------|
|
||||
| **Phase A** | Swarm 编排(Star/Sequential/Hierarchical + Subgraph) | 📋 待实施 |
|
||||
| **Phase B** | 结果聚合 + 编排模式完善 | 📋 待实施 |
|
||||
| **Phase C** | Human-in-the-loop + 用户 Steering | 📋 待实施 |
|
||||
| **Phase D** | TokenJuice 语义压缩(工具结果/历史/跨 Agent) | 📋 待实施 |
|
||||
| **Phase E** | 自动校正 / Reflection | 📋 待实施 |
|
||||
|
||||
### 未来版本(v0.5+)
|
||||
|
||||
以下功能已从 v0.4 范围移出:
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| Agent 自动创生 | LLM 自主决定何时派发子 agent — 设计复杂,v0.4 专注显式声明式编排 |
|
||||
| 分布式 session 共享(Redis 后端) | 与编排正交,多数用户单进程即可 |
|
||||
| 精确 tokenizer 计数(tiktoken-rs) | 依赖引入,不在 v0.4 核心范围内 |
|
||||
| 增量 Checkpoint | 存储优化,当前全量 JSON 够用 |
|
||||
| 路线 B(StateGraph 通用图引擎) | 预留为路线 A 的未来升级路径 |
|
||||
| RL 轨迹导出 | 专项需求 |
|
||||
|
||||
### 明确不做(agcore 范围外)
|
||||
|
||||
| 功能 | 原因 |
|
||||
|------|------|
|
||||
| TUI / 多平台 Gateway | 应用层职责(Feishu / Telegram / Discord 桥接) |
|
||||
| 配置自动加载(config/figment) | 配置来源策略应由上游应用决定,agcore 不定义配置格式 |
|
||||
| 提示词自动优化 | 属于智能层,不应内建于 core 库 |
|
||||
|
||||
---
|
||||
|
||||
## 风险与建议
|
||||
|
||||
1. **持久化依赖**:`rusqlite` + `bundled` 零外部依赖编译,但 SQLite 不适配所有场景(分布式/高并发写)。`MemoryStore` trait 的抽象层允许下游自行实现 Redis / PostgreSQL 后端
|
||||
2. **ContextSlot 心智负担**:`ContextSlot` 引入了一等抽象的复杂度。建议通过 `AgentBuilder` 默认创建 `"default"` slot,让简单场景无感使用
|
||||
3. **向量检索规模上限**:v0.3 的 `PersistentVectorStore` 全量加载到内存做余弦搜索,适合 ≤10 万条向量。超出此规模需换用专用向量库。v0.4 可以评估引入
|
||||
4. **Scope 蔓延**:v0.3 新增 `agent/summary` `document/` `engine/` `memory/vector_store` 模块,功能覆盖扩展到多 Agent 基础系统。始终保持 trait + reference impl 的边界,业务循环留给上层(Phase 16 已交付 `agent/summary` 摘要生产端 + `format_messages_as_text` 简洁版格式化 + 30K 字符整体截断保留最新;Phase 18 已交付 `engine/switch_agent` 热切换 + `engine/sub_agent` 调度全栈(dispatch / dispatch_all / dispatch_stream);实施后两轮审查 PASS,0 🔴 阻塞)
|
||||
5. **API 稳定性**:v0.3 引入 `Checkpointer`、`SessionManager`、`VectorStore` 等新公开 API,v0.2 已有的 `#[non_exhaustive]` 和 `#[deprecated]` 机制继续沿用
|
||||
6. **Checkpointer 存储效率**:v0.3 使用全量 JSON 序列化存储 checkpoint,每轮对话约几百 KB。`fork` 从历史 checkpoint 创建新 session 时也会复制全量。等实际使用中发现存储瓶颈时再改为增量模式
|
||||
|
||||
---
|
||||
|
||||
## 下一步行动
|
||||
|
||||
1. **v0.4.0 启动**:按 [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md) 规划,从 Phase A(Swarm 编排)开始实施
|
||||
2. **Phase A 实施**:engine/supervisor.rs + tools/builtin.rs + Swarm::star/sequential/hierarchical
|
||||
3. **示例先行**:每个 Phase 交付时同步提交对应的示例程序
|
||||
4. **里程碑追踪**:以 M16-M20 为目标里程碑,逐 Phase 推进
|
||||
|
||||
---
|
||||
|
||||
**已完成 / 进行中阶段**:
|
||||
- ✅ Phase 0 Foundation — 全部交付物已完成
|
||||
- ✅ Phase 1 Prompt Engineering — 全部交付物已完成
|
||||
- ✅ Phase 2 Tool System — 全部交付物已完成
|
||||
- ✅ Phase 3 Memory System — 全部交付物已完成
|
||||
- ✅ Phase 4a Core Glue — 全部交付物已完成
|
||||
- ✅ Phase 4b Task Execution — 全部交付物已完成
|
||||
- ✅ Phase 4c Session Memory — 全部交付物已完成
|
||||
- ✅ Phase 5 Warmup — ProviderConfig::from_env + OllamaProvider + `#[non_exhaustive]` 前置标记(ProviderType / StopReason / FinishReason / EvictionPolicy)
|
||||
- ✅ Phase 6 ToolDefinition IR — `ToolDef` 新类型 + 双向 `From` 转换 + 别名彻底移除 + `#[allow(deprecated)]` 清理(cycle/registry/mcp/agent);Anthropic 零改动;roundtrip 测试覆盖
|
||||
- ✅ Phase 7 SqliteStore — `rusqlite 0.32` + WAL 模式 + `Arc<Mutex<Connection>>` + `spawn_blocking`;`memory/store.rs` → `store/{in_memory,sqlite_store}.rs` 模块化;9 个内联测试覆盖 CRUD/upsert/过滤/10×10 并发/持久化 round-trip;`InMemoryStore ↔ SqliteStore` trait-box 互换兼容
|
||||
- ✅ **Phase 8 MVP 集成出口** — 14 个公开枚举追加 `#[non_exhaustive]`(P0 核心 IR + P0 Error + P1 其他) + `StepStatus::Completed(ChatResponse)` → `Completed(MessageResponse)` 迁移 + CHANGELOG v0.2.0-rc.1 + 2 个新示例(`quick_start` 60 行 + `end_to_end` 246 行),10 个离线示例全部 exit 0;**v0.2.0-rc.1 标签已打**;实施后三方审查发现 6 项问题(1 🔴 + 2 🟡 + 3 💭)已全部修复
|
||||
- ✅ **Phase 9 流式体验增强** — `AgentSession::submit_turn_stream` 流式事件序列 + `LlmCycle::submit_with_tools_stream` spawn + mpsc 状态机 + `StreamEvent::ToolExecutionStarted`/`Completed` 新变体 + 9 单元测试 + 2 集成测试(含 `submit_turn_stream_end_to_end` 端到端 mock 验证 + `submit_turn_stream_triggers_turn_hooks` Hook 触发验证),全量 200 → 211;`CycleConfig` 加 `Clone` derive;方案文档 `docs/16-phase9-streaming-experience.md`(821 行)
|
||||
- ✅ **Phase 10 ContextSlot 上下文管理** — `src/agent/context.rs` 新增 `ContextSlot` 核心类型(Full / Focused / Readonly 三种模式,New / Derived / Static 三种来源)+ JSON blob 批次持久化(每 slot 3-4 条 MemoryItem,`slot_config` key 自恢复支持旧版本兼容);`AgentSession` 扩展 slots 字段 + 5 个管理方法(`create_slot` / `switch_slot` / `list_slots` / `derive_slot` / `delete_slot`,自动创建 `"default"` slot,`delete_slot` 双重保护禁止删 default/最后一个);`submit_turn`/`finalize_turn` 改造为基于当前 slot 的增量追加写回(`cycle.messages()[input_len..]` 提取本轮新增消息,确保 Focused 模式"读时过滤"语义不丢失数据);`finalize_turn` 签名变更(新增 `new_messages_from_cycle: Vec<Message>` 参数,返回 `Result<(), AgentError>`);`agent/error.rs` 新增 3 个 Slot 错误变体(`SlotReadonly` / `SlotNotFound` / `SlotAlreadyExists`);`examples/context_slot_demo.rs` 新增分支对话示例(法律咨询入口 → 两个派生方向 → 切换 → 隔离验证 → 删除保护);方案文档 `docs/17-phase10-contextslot.md`(1227 行,含 §5 推荐方案、§6 实施建议、§9 实施计划,经过 4 轮方案/计划/实施审查 + 1 轮非阻塞建议修复);全量 211 → 254(+43 新测试),clippy 0 警告,doc 0 warning,11 个离线示例全部 exit 0
|
||||
- ✅ **Phase 11 测试与检索补强** — `src/memory/vector.rs` 新增 `VectorRetriever` trait(index + search 抽象)+ `InMemoryVectorRetriever` 引用实现(HashMap + 全量余弦相似度扫描 + 零依赖 `dot()`),6 个内联测试覆盖 basic/empty/zero-vector/k=0/2 个并发;wiremock Provider roundtrip 测试 12 个(OpenAI 8 + Anthropic 4)覆盖请求体/header/401/429/500/529/流式 usage-only/流式错误/ToolUse/结构化错误体;`MemoryStore` 并发测试 5 个(InMemoryStore 3 + SqliteStore 2)覆盖 100 并发写、5 写+5 读混合 2 秒、15 写者容量淘汰;`openai.rs` `handle_error_response` 修复 429 retry-after 解析(5 行,与 anthropic 对齐);方案文档 `docs/18-phase11-testing-and-retrieval.md`(647 行,含 10 项架构决策 + 2 条实施偏差记录 #6 mid-stream mock 模式 + #7 retry-after 修复);全量 254 → 277(+23 新测试),clippy 0 警告,doc 0 warning,并发测试 3 次稳定无 flaky
|
||||
- ✅ **Phase 13 热身清理 + ContextSlot fork/merge** — 3 个旧 types 文件删除(`request.rs` 187 行 + `response.rs` 177 行 + `old_stream.rs` 45 行),所有 OpenAI wire-format 类型迁入 `provider/openai.rs` 可见性 `pub(crate)`(Breaking Change:原 `agcore::llm::types::OpenaiChatRequest/Response/Chunk` 公共 re-export 路径已删除);`ChatResponse` 自 v0.1.0 标记 `#[deprecated]` 后在 Phase 13 整体删除;`ToolChoice` 从 `request.rs` 迁入 `tool.rs`(公共 `agcore::llm::types::ToolChoice` 路径不变);`ContextSlot::fork()` 派生独立子 slot(`SlotSource::Derived { parent_id, strategy }` 血缘可追溯)+ `ContextSlot::merge(child, MergeStrategy)` 合入父 slot(`Append` / `Replace` 两种策略,`#[non_exhaustive]` 预留扩展);`MergeStrategy` 防御性检查(self-merge / 跨 session / Readonly 目标全部阻断);`AgentSession::derive_slot` 重构复用 `fork()` 消除重复;`agent.rs` 追加 `MergeStrategy` re-export;9 个 fork/merge 内联测试覆盖 happy path 与 error path;`stream.rs` 简化为 module doc + `pub use` 重导出(保持 `use crate::llm::stream::StreamEvent` 路径兼容);方案文档 `docs/19-phase13-cleanup-and-fork-merge.md`(640 行);全量 277 → 286(+9 新测试),clippy 0 警告,doc 0 warning
|
||||
- ✅ Provider IR 重构 — 统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen/Ollama 适配
|
||||
- ✅ LlmCycle 简化 — IR 消息类型切换 + Phase 0 桥接层移除
|
||||
- ✅ v0.1 Release — 技术债扫清、MockProvider 公开化、8 个离线示例(含 `simple_visit`)、README + 错误消息友好化、CHANGELOG 初始化
|
||||
- ✅ **v0.2 规划细化完成** — 8 个增量 Phase(Phase 5-12),17 个可验证 Step,覆盖 P0-P2 全部 12 项功能 + ContextSlot
|
||||
- ✅ **v0.3.0 Phase 13 完成** — 技术债清理(3 旧 types 文件 + ChatResponse 删除)+ ContextSlot fork/merge(9 新测试),M9 里程碑达成
|
||||
- ✅ **v0.3.0 Phase 14 完成** — Document 类型(id/content/metadata/mime_type)+ `RecursiveCharacterSplitter` 两阶段算法(按 separator 优先级递归分割 + 贪心合并 overlap,全部 `chars_len()` 字符级比较)+ `Embedding` trait(async + `LlmError` 复用)+ `MockEmbedding`(sin-hash 零依赖伪随机 + L2 归一化)+ 19 Document 测试 + 6 Embedding 测试(含 1 个 split_multibyte_utf8_boundary CJK 边界测试);`src/document.rs`(580 行)+ `src/llm/embedding.rs`(183 行)+ `examples/document_demo.rs`(74 行);`pub use document::Document` 在 lib.rs 重导出;CJK 分隔符(`。`/`?`/`!`)加入 `DEFAULT_SEPARATORS`;方案文档 `docs/20-phase14-document-and-embedding.md`(1417 行);全量 286 → 313(+27 新测试,0 失败),clippy 0 警告,doc 0 warning,零新外部依赖;M10 里程碑达成
|
||||
- ✅ **v0.3.0 Phase 15 完成** — `VectorStore` trait(`add`/`search`/`remove`/`add_one`,返回 `(Document, f32)` 消除调用方 id→Document 维护开销)+ `InMemoryVectorStore`(`Mutex<HashMap>` + 余弦全量扫描 + 预计算 L2 norm 缓存)+ `PersistentVectorStore`(构造时全量加载,先写持久化后写内存,持久化失败时内存不污染重启自动恢复,`remove` 幽灵数据窗口已知)+ `RagPipeline` 组合器(ingest: split→embed→store.add / retrieve: embed→store.search,`splitter: Option<RecursiveCharacterSplitter>` 灵活切换);`src/memory/vector_store.rs`(937 行,19 个内联测试覆盖 14 场景含 2 个性能基准)+ 零新外部依赖(纯 Rust `dot()` 余弦);旧 `VectorRetriever`/`InMemoryVectorRetriever` 标注 `#[deprecated(since = "0.3.0")]` 迁移路径清晰;`search_orthogonal_vectors` 返回 1 条 score≈0(文档已同步修正不过滤低分向量);方案文档 `docs/21-phase15-vector-store-persistence.md`(1570 行,经 3 轮审查 + 文档-代码一致化修复);全量 313 → 335(+22 新测试),clippy 0 警告,doc 0 warning;M11 里程碑达成
|
||||
- ✅ **v0.3.0 Phase 16 完成** — `SummaryConfig` 配置结构体(6 个字段:`trigger_token_ratio=0.75` / `max_context_tokens=32_000` / `summary_prompt` / `debounce_turns=3` / `summary_model=None` / `max_tool_result_chars=500`,默认 `None` 沿用主模型避断裂非 OpenAI 用户)+ `AgentBuilder::summary_config(cfg)` 链式方法 + `AgentConfig.summary_config: Option<SummaryConfig>` 字段;`AgentSession` 新增 `last_summary_turn: Option<u32>` 字段(首次不受防抖约束,`should_summarize` 用 `Option` 哨兵实现)+ `maybe_summarize(current_turn)` 内联检查点(OnTurnEnd 之后 / `turn_index` 之前,对称 `submit_turn` / `finalize_turn` 两个入口,流式路径 `saturating_sub(1)` 修正)+ 关联函数 `generate_summary`(构造独立 `LlmCycle` 调 `submit_messages` 传 `vec![Message::user_text(prompt)]`,`max_tokens=1024`,空消息守卫直接返回空串)+ 公开 API `get_conversation_summary()`;`src/agent/summary.rs`(~240 行,含 8 个 SummaryConfig/`format_messages_as_text` 内联测试——默认值/空输入/系统用户助理/ToolResult(含 `tool_call_id`)/工具调用/Unicode 安全截断/整体 30K 截断保留最新;有效字符数截断多字节安全,droptest 验证保留尾部消息)+ `src/agent/session.rs` 注入 10 个摘要集成测试(默认值不触发 / 超阈值触发 / 防抖阻止重复 / SessionMemory 写入 / Full 模式不注入 / 失败不阻断主流程 / 流式路径触发 / 默认配置零影响 / **Focused `summary_override` 写入正向验证** / **空消息不调用 LLM** / **巨型 `max_context_tokens` 永不触发**);`format_messages_as_text` 简洁版消息格式化(`[Tool: name]` + `Tool Result [id]:` + ToolResult 字符级 `chars().take(max_tool_result_chars)` 截断 + 整段 30K 总长度截断从头部保留最新);所有错误静默(失败用 `tracing::error!`,成功用 `tracing::info!(turn, summary_len)`);`MergeStrategy` 注释中过时 "Summarize 指向"与 `context.rs:78` "v0.3 将支持 Hook 驱动" 过时注释在实施时同步移除/更新;方案文档 `docs/22-phase16-summary-auto-generation.md`(471 行),实施后**两轮审查 PASS**:第一轮 PM/SA 审查 11 项问题修复 + 第二轮实施审查 9 项问题修复(🔴 `generate_summary` 空消息 bug + 🟡 W4 流式路径防抖 + 🟡 W2 模型硬编码 + 🟡 W5 Full 模式无谓 save + 🟡 W3 30K 截断 + 🟡 W6 成功无日志 + 🟡 W1/W7 测试补全 + 💭 注释同步);零新外部依赖;全量 335 → **353**(+18 新测试,含二次审查增补 4 个),clippy 0 警告,doc 0 warning,`quick_start` 示例正常 exit 0;**M12 里程碑达成** + 第二轮审查门禁 PASS
|
||||
- ✅ **v0.3.0 Phase 17 完成** — 新建 `src/engine/` 模块(5 文件:`mod.rs`/`error.rs`/`snapshot.rs`/`checkpointer.rs`/`session_manager.rs`),实现 **SessionManager**(10 个公开方法:`create`/`create_child`/`get`/`recover`/`replace`/`children`/`parent`/`destroy`/`submit_turn`/`submit_turn_stream`/`finalize_turn_stream`,内部 `RwLock<HashMap>` + `Arc<tokio::sync::Mutex<AgentSession>>` + `Checkpointer` 组合)和 **Checkpointer**(5 个公开方法:`checkpoint`/`rollback_load`/`list_checkpoints`/`delete_all`/`latest_snapshot`);`SessionSnapshot` 独立 struct 避开 `Arc<dyn Agent>` 不可序列化,配套 `SessionMemoryEntry` 保留 metadata/created_at;`AgentSession` 扩展三段式快照(`to_snapshot` async 读 MemoryStore + `from_snapshot` 纯同步构造 + `restore_memory` &mut self async 写回持久层);`SessionMemory` 新增 `list_entries()` 和 `set_with_meta()` 方法(恢复时保留完整 entry 数据);存储 key 风格统一为 `session:{id}:meta` / `ckpt:{id}:{ckpt_id}`(与 `slot_data:` 风格一致);`EngineError` 6 个变体(含 `Memory(#[from] MemoryError)` 透传 + `Agent(#[from] AgentError)`);`CkptMeta` 加 `created_at_nanos` 字段确保同秒内精确降序排序;ckpt_id 用纳秒+单调计数器生成(零外部依赖,ponytail);session_id 用纳秒+计数器自动生成(统一策略,UUID v4 备选);自动 checkpoint 失败 `tracing::error!` 不阻断主流程(不提供强持久化保证);流式 checkpoint 仅在 `finalize_turn_stream` 创建(不留半成品污染);孤儿策略:`destroy()` 不递归删除子 session,父被销毁后 `parent()` 返回 `Ok(None)`;3 处 derive 改动(`CostTracker` + `ContextSlot` + `MergeStrategy` 加 serde,`CostTracker` 额外加 `Clone`);`SessionManager::recover` + `replace` 内部自动 `restore_memory` 写回持久层;零新外部依赖;方案文档 `docs/23-phase17-agent-execution-engine.md`(775 行,经两轮 PM+SA 审查 + 实施后第三轮 PM+SA+Code Reviewer 三方联合审查),实施后**两轮审查门禁 PASS**:第一轮修复 6 🔴 + 第二轮修复 2 🔴(to_snapshot 同步→async + Roadmap 同步)+ 实施后修复 8 个 🟡(restore_memory metadata/created_at 完整恢复 + &mut self 签名 + 死代码清理 + 3 个边界测试 + tracing 补全 + 文档语义统一 + 示例 rollback 一致性 assert);15 个 `SessionManager` 内联测试(CRUD/recover/replace/树形/孤儿/auto_checkpoint on-off)+ 6 个 `Checkpointer` 内联测试(roundtrip/不存在的 ckpt/同秒降序/delete_all 幂等/latest/隔离)+ 1 个 `snapshot_deserialize_with_minimal_fields` 序列化兼容测试;全量 353 → **374**(+21 新测试),clippy 0 警告,doc 0 warning,`engine_demo` 示例端到端演示 create→submit_turn→checkpoint→rollback→replace→destroy 全链路并验证 rollback 一致性;**M13 里程碑达成** + 两轮审查门禁 PASS
|
||||
- ✅ **v0.3.0 Phase 18 完成** — 新增 `src/engine/switch.rs`(222 行)实现 `SessionManager::switch_agent()` 热切换(替换 `Arc<dyn Agent>`,slot 历史 / `turn_index` / `session_memory` / `cost_so_far` 全部保留,同步更新 `SessionMeta.agent_name` 到持久层,`created_at` / `parent_id` 保持原始不可变)+ 新增 `src/engine/sub_agent.rs`(1071 行)实现 4 个公开方法(`dispatch` / `dispatch_all` / `dispatch_stream` 与前述 `switch_agent` 共 4 个 Phase 18 核心 API)+ 3 个公开类型(`DispatchConfig` / `SubTaskResult` / `SubTaskStreamEvent`);`DispatchConfig` 4 字段(`max_concurrency=10` / `inherit_session_memory=true` / `bridge_keys=None` / `shared_namespace=None`)+ 三态 `bridge_keys` 语义(`None` = 不继承 / `Some(vec![])` = 全部 / `Some(keys)` = 指定 keys)+ 约定式 `shared_namespace` 子↔子共享(`shared:{prefix}:{key}`)不触发自动注入;`dispatch` 流程:`create_child` → `inherit_session_memory`(快照语义)→ `submit_turn` → 返回 `SubTaskResult`;`dispatch_all` `tokio::sync::Semaphore` 并发控制 + `Vec<Result<...>>` 部分成功语义按输入顺序 indexed 收集;`dispatch_stream` `unbounded_channel` + spawn task 消息重建 + `finalize_turn` 后台落库(明确不参与 `auto_checkpoint` 防重复);`SubTaskStreamEvent` 事件序列:`ChildCreated` → `Stream(StreamEvent) × N` → `Completed(SubTaskResult)` 或 `Error { child_id, error }`;`EngineError` 新增 `DispatchFailed(#[source] String)` 变体 + `CostTracker` 加 `From<Usage>` 转换;`save_session_meta` / `load_session_meta` 改 `pub(crate)` 供 `switch.rs` 调用;4 个端到端示例:`agent_switch_demo`(115 行)+ `sub_agent_dispatch_demo`(141 行)+ `bridge_keys_demo`(197 行)+ `dispatch_stream_demo`(121 行)全部 exit 0;17 个内联测试(4 switch + 5 dispatch + 4 dispatch_all + 4 dispatch_stream);零新外部依赖;方案文档 `docs/24-phase18-agent-switch-and-dispatch.md`(700 行);全量 374 → **391**(+17 新测试,0 失败),clippy 0 警告,doc 0 warning;**M14 里程碑达成**
|
||||
- ✅ **v0.3.0 Phase 19 完成** — 知识图谱 + 双通道检索,详见 `docs/25-phase19-knowledge-graph-and-retrieval.md`;全量 391 → **427 passed / 0 failed**(+36 新测试);**M15 里程碑达成**
|
||||
- ✅ **v0.3.2 Phase 20-27 全部完成** — Cargo features 拆分(16 模块级 + 5 provider + 4 快捷组合),详见 `docs/roadmap-v0.3.2.md`;全量 427 → **427 passed**(不变,门控验证)
|
||||
- ✅ **Phase 28-30 OpenAI Response API Provider 完成** — 独立 feature `provider-openai-response`,全量约 450 passed
|
||||
- 📋 **v0.4.0 规划完成** — 5 个增量 Phase(A-E)覆盖多 Agent 编排、HITL + Steering、TokenJuice、自动校正。详见 [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md)
|
||||
|
||||
---
|
||||
|
||||
## 设计笔记
|
||||
|
||||
### Checkpointer 分层存储模型
|
||||
|
||||
> 来源:v0.4.0 规划讨论中涉及增量 Checkpoint 的技术推演。当前全量 JSON checkpoint 够用,但为未来优化预留设计方案。
|
||||
|
||||
#### 分层叠加模型(OverlayFS 模式)
|
||||
|
||||
受容器分层文件系统启发,增量 Checkpoint 可以借鉴 overlayfs 的"底层只读 + 上层可写叠加"设计:
|
||||
|
||||
**全量基座(只读)**:
|
||||
```rust
|
||||
pub struct SnapshotBase {
|
||||
pub checkpoint_id: String,
|
||||
pub session_id: String,
|
||||
pub snapshot: SessionSnapshot, // 完整 JSON 化状态
|
||||
}
|
||||
```
|
||||
|
||||
**增量层(叠加 diff)**:
|
||||
```rust
|
||||
pub struct SnapshotLayer {
|
||||
pub base_checkpoint_id: String,
|
||||
pub applies_to_id: String, // 在哪个 checkpoint 上叠加
|
||||
pub diff: Vec<DiffOp>, // JSON Patch 操作集合
|
||||
}
|
||||
|
||||
pub enum DiffOp {
|
||||
MessageAppended { message: Message },
|
||||
SlotChanged { slot_id: String, diff: serde_json::Value },
|
||||
TurnIndexIncremented { from: u32, to: u32 },
|
||||
CostUpdated { diff: CostTracker },
|
||||
}
|
||||
```
|
||||
|
||||
**重建路径**:
|
||||
```
|
||||
rollback_load("session_x", 6)
|
||||
→ 读取 "ckpt:{session_x}:base"(全量)
|
||||
→ 读取 "ckpt:{session_x}:layer:1" ~ "ckpt:{session_x}:layer:6"
|
||||
→ 依次应用 layer.1 → layer.2 → ... → layer.6
|
||||
→ 得到 session_6 的状态
|
||||
```
|
||||
|
||||
**层折叠(类似 docker squash)**:
|
||||
```
|
||||
layer.1 → layer.2 → layer.3 → layer.4 → layer.5
|
||||
↓ 合并
|
||||
base.ckpt'(包含 layer.1-3)→ layer.4 → layer.5
|
||||
```
|
||||
|
||||
#### Shadow FS 模型(运行中保护)
|
||||
|
||||
与分层模型互补,shadow 模型适用于运行中的 session 保护而非长期存储:
|
||||
|
||||
```rust
|
||||
// submit_turn 在 shadow session 上执行,commit 时才原子切换
|
||||
let shadow = current_session.fork(); // 复用 ContextSlot::fork
|
||||
let result = shadow.submit_turn(input).await;
|
||||
if result.is_ok() {
|
||||
current_session.commit(shadow); // 原子替换
|
||||
} else {
|
||||
drop(shadow); // 丢弃,当前 session 完好无损
|
||||
}
|
||||
```
|
||||
|
||||
#### 适用场景对比
|
||||
|
||||
| 模型 | 适合场景 | 不适合场景 |
|
||||
|------|---------|-----------|
|
||||
| **分层叠加(OverlayFS)** | Checkpoint 链长期存储、time-travel、多版本回退 | session 较小(< 10KB/轮)时复杂度不值得 |
|
||||
| **Shadow FS(CoW)** | 运行中 session 保护、防止 submit_turn 失败污染 | 不能替代 checkpoint 链、不支持多时间点回退 |
|
||||
|
||||
**触发条件**:当单 session checkpoint 超过 500KB 且频繁保存导致性能瓶颈时,考虑实现分层模型。
|
||||
@@ -0,0 +1,242 @@
|
||||
# AG Core Roadmap — v0.1.0
|
||||
|
||||
> 本文件聚焦 **v0.1.0 版本** 的规划与交付(Phase 0–4c),已于 2026-07-04 完成发布。
|
||||
> 返回总入口:[`roadmap.md`](./roadmap.md)
|
||||
|
||||
## v0.1.0 愿景
|
||||
|
||||
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
|
||||
|
||||
## v0.1.0 总体范围
|
||||
|
||||
**总体规模**:5 个主体 Phase(Phase 0–4c)+ Provider IR 重构 + LlmCycle 简化 + v0.1 Release 收尾,182 个测试全绿,clippy 0 警告,7 个离线示例全 exit 0。
|
||||
|
||||
---
|
||||
|
||||
### Phase 0 — Foundation(基础设施)
|
||||
|
||||
**目标**:实现 LLM 调用周期的核心功能,作为所有上层模块的基础。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `llm/types.rs` — 核心数据类型(Message, ContentBlock, ChatRequest/Response, ToolDefinition, StopReason)
|
||||
2. ✅ `llm/error.rs` — 错误体系(LlmError 枚举,可重试/不可重试判断)
|
||||
3. ✅ `llm/provider.rs` + `llm/provider/openai.rs` — Provider 接口 + OpenAI 兼容实现
|
||||
4. ✅ `llm/provider/registry.rs` — ProviderRegistry(多 Provider 注册发现)
|
||||
5. ✅ `llm/cycle.rs` + `llm/cycle/{retry,usage}.rs` — 生命周期引擎(重试策略 + 用量追踪)
|
||||
6. ✅ `llm/hooks.rs` — HookExecutor 接口(生命周期钩子)
|
||||
7. ✅ `llm/stream.rs` — StreamEvents 流式事件系统(AssistantTextDelta, ToolExecutionStarted 等)
|
||||
8. ✅ `llm/compact.rs` — Auto-compaction(上下文自动压缩)
|
||||
9. ✅ `Cargo.toml` — 添加依赖(tokio, reqwest, serde, thiserror, async-trait, tracing)
|
||||
|
||||
**依赖**:无
|
||||
|
||||
**优先级**:Must Have
|
||||
|
||||
**预估规模**:约 1000 行核心代码
|
||||
|
||||
**状态**:✅ Phase 0 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 — Prompt Engineering(提示词工程)
|
||||
|
||||
**目标**:提供提示词的组合、模板化与优化能力。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `prompt.rs` + `prompt/` 模块
|
||||
2. ✅ `PromptTemplate` — 模板引擎(支持变量插值、条件渲染)
|
||||
3. ✅ `PromptComposer` — 提示词组合器(拼接 system/user/assistant 消息)
|
||||
4. ✅ `docs/4-prompt-engineering.md` — 方案文档
|
||||
|
||||
**依赖**:无(可与 Phase 0 并行)
|
||||
|
||||
**优先级**:Should Have
|
||||
|
||||
**预估规模**:约 400 行代码
|
||||
|
||||
**状态**:✅ Phase 1 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Tool System(工具系统)
|
||||
|
||||
**目标**:实现 MCP 协议集成与自定义工具注册、调用、权限控制。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `tools.rs` + `tools/` 模块(base/registry/permission/mcp/error)
|
||||
2. ✅ `ToolRegistry` — 工具注册表(注册、发现、调用、并行执行、超时控制)
|
||||
3. ✅ `BaseTool` trait — 工具抽象接口(含 ToolContext 执行上下文)
|
||||
4. ✅ `McpClient` — MCP 协议客户端(stdio transport,StreamableHttp 预留)
|
||||
5. ✅ `PermissionChecker` — 工具执行权限检查(白名单/黑名单/自定义权限)
|
||||
6. ✅ `docs/5-tool-system.md` — 方案设计文档
|
||||
7. ✅ 扩展 `llm/cycle.rs` 支持自动 tool 循环(`submit_with_tools()` + `submit_request()` + `maybe_compact()`)
|
||||
8. ✅ `ToolError` — 结构化错误体系(含 `is_recoverable()` 分类)
|
||||
|
||||
**依赖**:Phase 0(LlmProvider 接口传递 tool definitions)、Phase 1(提示词可能需要注入工具描述)
|
||||
|
||||
**优先级**:Should Have
|
||||
|
||||
**预估规模**:约 900 行代码(实际约 1500 行)
|
||||
|
||||
**状态**:✅ Phase 2 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Memory System(记忆系统)
|
||||
|
||||
**目标**:提供对话记忆的存储、检索与管理能力。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `memory.rs` + `memory/` 模块(store / conversation / knowledge / retriever / error / types)
|
||||
2. ✅ `MemoryStore` trait + `InMemoryStore` — 记忆存储抽象(可插拔后端)+ 默认实现
|
||||
3. ✅ `ConversationMemory` — 对话记忆管理(sliding window / 全量),复用 `llm::compact`
|
||||
4. ✅ `KnowledgeStore` — 知识页面存储(具体 struct,非 trait,基于 MemoryStore)
|
||||
5. ✅ `MemoryRetriever` — 记忆检索器(TextOverlap Dice 系数评分,单通道)
|
||||
6. ✅ `docs/6-memory-system.md` — 方案设计文档
|
||||
7. ✅ `docs/note-knowledge-graph-design.md` — KnowledgeGraph 等 Phase 4 备用设计
|
||||
8. ✅ `EvictionPolicy` — 支持 None / Ttl / Capacity 三种淘汰策略
|
||||
|
||||
**依赖**:Phase 0(llm::compact 复用)、Cargo.toml 新增 `time` 依赖
|
||||
|
||||
**优先级**:Could Have
|
||||
|
||||
**预估规模**:约 700 行代码(实际约 1242 行,含测试)
|
||||
|
||||
**状态**:✅ Phase 3 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
### Phase 4a — Agent Core Glue(核心胶水层)
|
||||
|
||||
**目标**:提供最小可用的 Agent Runtime——把 Phase 0-3 的能力"装配"成 `AgentSession::submit_turn`。上层可基于 4a 构建多轮对话应用。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `agent.rs` + `agent/` 模块(7 个文件:agent/error/runtime/builder/session/task + 模块根)
|
||||
2. ✅ `Agent` trait — 智能体角色定义(name / system_prompt / tool_definitions)
|
||||
3. ✅ `AgentSession` — 会话实例(绑定 `Arc<dyn Agent>` + `RuntimeBundle` + 内联 HashMap session_data)
|
||||
4. ✅ `RuntimeBundle` — 显式依赖注入容器(不含 session_memory_backend)
|
||||
5. ✅ `AgentBuilder` — 链式构造入口(不含 session_memory_backend)
|
||||
6. ✅ `AgentError` — 统一错误类型(7 个变体:Llm / Tool / Memory / HookBlocked / LimitExceeded / Config / Other;不含 PlanParse)
|
||||
7. ✅ `Plan` / `Step` / `StepStatus` — 纯数据结构(不含任何解析逻辑)
|
||||
8. ✅ Hook 事件扩展:OnTurnStart / OnTurnEnd + turn_index 字段
|
||||
9. ✅ `docs/7-agent-runtime.md` — 方案设计文档(含 4a/4b/4c 分阶段计划)
|
||||
|
||||
**实际新增**:
|
||||
- 新增文件 7 个(agent.rs + agent/{agent, error, runtime, builder, session, task}.rs)
|
||||
- 修改文件 3 个(lib.rs +1 行;llm/hooks.rs +13 行追加变体/字段;llm/cycle.rs 内部字段 Box→Arc + 新增 `new_with_arc` 公共方法)
|
||||
- 实际代码量约 800 行(含测试;纯实现约 470 行——略高于方案预估 440 行,因 AgentSession 的 tests 模块内联 MockProvider/StubAgent 等辅助结构)
|
||||
- 新增内联测试 22 个;全量测试 84 → 109(0 失败)
|
||||
- clippy 0 警告(agent 模块)
|
||||
- 无新增外部依赖
|
||||
|
||||
**依赖**:Phase 0, 1, 2, 3
|
||||
|
||||
**优先级**:Could Have
|
||||
|
||||
**预估规模**:约 440 行代码
|
||||
|
||||
**状态**:✅ Phase 4a 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
### Phase 4b — Task Execution(任务执行)
|
||||
|
||||
**目标**:在 Phase 4a 基础上,赋予智能体"拆解目标 → 逐步执行"的能力。
|
||||
|
||||
**前置条件**:Phase 4a 已完成。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `TaskAgent` trait — `run(goal)` 自主式 + `execute_plan(plan)` 外部驱动式
|
||||
2. ✅ `PlanParser` trait + `JsonPlanParser` 参考实现
|
||||
3. ✅ `AgentError` 追加 PlanParse 变体(共 7 个变体)
|
||||
4. ✅ Hook 事件扩展:OnPlanStepComplete + plan_step_index 字段
|
||||
|
||||
**依赖**:Phase 4a
|
||||
|
||||
**优先级**:Could Have
|
||||
|
||||
**预估规模**:约 200 行代码(增量)
|
||||
|
||||
**实际新增**:
|
||||
- 修改文件 2 个(llm/hooks.rs +5 行;agent/error.rs +10 行)
|
||||
- 新增代码约 150 行(含测试;纯实现约 90 行)
|
||||
- 新增内联测试 4 个;全量测试 109 → 113(0 失败)
|
||||
- clippy 0 警告
|
||||
- 无新增外部依赖
|
||||
|
||||
**状态**:✅ Phase 4b 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
### Phase 4c — Session Memory(会话级记忆)
|
||||
|
||||
**目标**:提供会话级 key-value 记忆,作为 session 内各 context 之间的信息桥接通道。
|
||||
|
||||
**前置条件**:Phase 4a 已完成(可与 Phase 4b 并行)。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `SessionMemory` struct — 基于 `MemoryStore`,按 session_id namespace 隔离
|
||||
2. ✅ `RuntimeBundle` + `AgentBuilder` 扩展 `session_memory_backend` 字段
|
||||
3. ✅ `AgentSession` 替换内联 HashMap 为完整 `SessionMemory`
|
||||
|
||||
**依赖**:Phase 4a(Phase 3 MemoryStore)
|
||||
|
||||
**优先级**:Could Have
|
||||
|
||||
**预估规模**:约 115 行代码(增量)
|
||||
|
||||
**实际新增**:
|
||||
- 新增文件 1 个(agent/session_memory.rs)
|
||||
- 修改文件 4 个(agent/runtime.rs +5 行;agent/builder.rs +10 行;agent/session.rs +30 行;agent.rs +2 行)
|
||||
- 新增代码约 180 行(含测试;纯实现约 100 行)
|
||||
- 新增内联测试 3 个;全量测试 113 → 116(0 失败)
|
||||
- clippy 0 警告
|
||||
- 无新增外部依赖
|
||||
|
||||
**状态**:✅ Phase 4c 全部交付物已完成
|
||||
|
||||
---
|
||||
```mermaid
|
||||
graph BT
|
||||
P0["<b>Phase 0: Foundation</b><br/>LLM Cycle<br/>ProviderRegistry<br/>HookExecutor<br/>StreamEvents<br/>Auto-compaction"]:::done
|
||||
P1["<b>Phase 1: Prompt Engineering</b><br/>PromptTemplate<br/>PromptComposer"]:::done
|
||||
P2["<b>Phase 2: Tool System</b><br/>Tool Registry<br/>PermissionChecker<br/>MCP Client"]:::done
|
||||
P3["<b>Phase 3: Memory System</b><br/>MemoryStore<br/>ConversationMemory<br/>KnowledgeStore"]:::done
|
||||
P4a["<b>Phase 4a: Core Glue</b><br/>AgentSession<br/>RuntimeBundle<br/>Plan/Step 纯数据"]:::done
|
||||
P4b["<b>Phase 4b: Task Execution</b><br/>TaskAgent<br/>PlanParser<br/>JsonPlanParser"]:::done
|
||||
P4c["<b>Phase 4c: Session Memory</b><br/>SessionMemory"]:::done
|
||||
|
||||
P1 --> P0
|
||||
P2 --> P0
|
||||
P3 --> P0
|
||||
P2 --> P1
|
||||
P4a --> P1
|
||||
P4a --> P2
|
||||
P4a --> P3
|
||||
P4b --> P4a
|
||||
P4c --> P4a
|
||||
|
||||
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
|
||||
classDef pending fill:#fbbf24,stroke:#d97706,color:#1a1a1a
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.1 发布里程碑(2026-07-04)
|
||||
|
||||
**质量基线**:
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| `cargo build --all-targets` | ✅ 通过 |
|
||||
| `cargo test --all-targets` | ✅ **182 passed / 0 failed** |
|
||||
| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 |
|
||||
| 离线示例(`cargo run --example`) | ✅ 7 个全部 exit 0 |
|
||||
|
||||
**关键交付**:
|
||||
1. **Provider IR 重构** — 统一 `Message` / `ContentBlock` / `MessageRequest` / `MessageResponse` 类型层;4 个 Provider 适配(OpenAI Chat / Anthropic Messages / DeepSeek / Qwen);`LlmProvider` trait 签名同步切换
|
||||
2. **LlmCycle 简化** — `LlmCycle` 内部消息类型切到 IR 层;移除 Phase 0 的 `OpenaiChatMessage ↔ Message` 桥接;测试从 116 → 182(含 provider 测试)
|
||||
3. **`MockProvider` 公开化** — `agcore::llm::mock::MockProvider` 支持 `chat` + `chat_stream`,无需 API key 即可运行示例
|
||||
4. **7 个离线示例** — `prompt_composer` / `custom_tool` / `agent_session_demo` / `task_agent_demo` / `conversation_memory_demo` / `knowledge_search_demo` / `streaming_events_demo`
|
||||
5. **错误消息友好化** — `AgentError` / `LlmError` / `ToolError` / `MemoryError` / `PromptError` 全部面向最终用户改写(给出可操作的建议)
|
||||
6. **文档完整** — README 完整版(快速上手 + 架构图 + 环境变量)、Apache-2.0 LICENSE
|
||||
@@ -0,0 +1,378 @@
|
||||
# AG Core Roadmap — v0.2.0
|
||||
|
||||
> 本文件聚焦 **v0.2.0 版本** 的规划与交付(Phase 5–12)。已打 `v0.2.0-rc.1` 标签。
|
||||
> 返回总入口:[`roadmap.md`](./roadmap.md)
|
||||
|
||||
## v0.2.0 愿景
|
||||
|
||||
从"LLM 调用工具箱"升级为"生产可用的 Agent 服务"。解决 Rust Agent 工具箱从"能跑"到"能被人依赖"的鸿沟——持久化、配置层、上下文管理三大块补齐后,开发者可在 30 分钟内写出生产可用的 Agent 服务。
|
||||
|
||||
## v0.2.0 总体范围
|
||||
|
||||
**总体规模**:8 个增量 Phase(Phase 5–12),17 个可验证 Step,约 2000+ 行新增代码,测试 182 → 277+。
|
||||
|
||||
---
|
||||
|
||||
## v0.2.0 — 生产就绪(Production-Ready Core)
|
||||
|
||||
**目标**:解决 Rust Agent 工具箱从"能跑"到"能被人依赖"的鸿沟。持久化、配置层、上下文管理三大块补齐后,开发者可在 30 分钟内写出生产可用的 Agent 服务。
|
||||
|
||||
**总体规模**:8 个增量 Phase(Phase 5-12),17 个可验证 Step。
|
||||
|
||||
### 功能清单
|
||||
|
||||
#### P0 — 必须交付
|
||||
|
||||
| # | 功能 | 模块 | 方案要点 |
|
||||
|---|------|------|---------|
|
||||
| 1 | SqliteStore | `memory` | `rusqlite` + `bundled` feature,`MemoryStore` 的 SQLite 实现,进程重启数据不丢 |
|
||||
| 2 | ProviderConfig 扩展 + `from_env()` | `llm` | 补全 `timeout_secs` / `max_retries` 字段;`AG_LLM_*` 环境变量辅助函数 |
|
||||
| 3 | ToolDefinition IR 正式化 | `tools` | 移除 deprecated OpenAI wire 格式,替换为自定义 `ToolDef` 结构体 |
|
||||
| 4 | API 稳定性管理 | `*` | 公开枚举加 `#[non_exhaustive]`;CHANGELOG 记录 Breaking Changes;废弃 API 用 `#[deprecated]` 标记 |
|
||||
| 5 | Quick Start + 端到端示例 | `examples/` | 30 行 `main.rs` 快速开始;一个"SQLite 持久化 + Provider + 工具调用 + 多轮对话"的可运行示例(`cargo run --example`) |
|
||||
|
||||
#### P1 — 重要但不阻塞
|
||||
|
||||
| # | 功能 | 模块 | 方案要点 |
|
||||
|---|------|------|---------|
|
||||
| 6 | Ollama Provider | `llm/provider` | OpenAI Compat,本地 LLM 支持,实现量极小 |
|
||||
| 7 | VectorRetriever trait | `memory` | 语义检索 trait 抽象(`index` / `search`),不绑定后端实现 |
|
||||
| 8 | 流式 `submit_turn_stream` | `agent` | `AgentSession` 新增 `submit_turn_stream()`,返回 `Stream<Item = StreamEvent>` |
|
||||
| 9 | 测试补强 | `*` | wiremock Provider roundtrip 测试;多线程并发写入 MemoryStore 测试 |
|
||||
|
||||
#### P2 — 有时间再做
|
||||
|
||||
| # | 功能 | 模块 | 备注 |
|
||||
|---|------|------|------|
|
||||
| 10 | MCP StreamableHttp | `tools` | 当前仅预留枚举变体 |
|
||||
| 11 | Gemini Provider | `llm/provider` | 协议差异大,实现成本较高 |
|
||||
| 12 | 文件系统 MemoryStore 后端 | `memory` | JSON/JSONL 轻量持久化 |
|
||||
|
||||
### ContextSlot 上下文管理
|
||||
|
||||
**模块归属**:`src/llm/context.rs`(与 `compact.rs` 同级)
|
||||
|
||||
**核心概念**:`ContextSlot` 是一段带策略配置的消息列表,以 `slot_id` 为 namespace 独立持久化到 `MemoryStore`。支持三种模式、三种来源和派生关联(记录 `parent_id`)。
|
||||
|
||||
**核心类型**:
|
||||
|
||||
```rust
|
||||
pub struct ContextSlot { id, session_id, config, messages, store }
|
||||
pub struct SlotConfig { mode: SlotMode, source: SlotSource, budget, compact }
|
||||
pub enum SlotMode {
|
||||
Full, // 完整对话历史
|
||||
Focused(FocusedConfig), // 聚焦:保持 LLM 注意力
|
||||
Readonly, // 只读参考上下文
|
||||
}
|
||||
pub struct FocusedConfig { keep_system, recent_turns, inject_summary }
|
||||
pub enum SlotSource {
|
||||
New, // 全新空槽,独立持久化
|
||||
Derived { parent_id, strategy: DeriveStrategy }, // 从父 slot 派生
|
||||
Static(Vec<Message>), // 预置消息,不持久化
|
||||
}
|
||||
pub enum DeriveStrategy { Full, Focused(FocusedConfig) }
|
||||
pub struct ContextBudget { system, history, tools, tool_results, reserve }
|
||||
```
|
||||
|
||||
**持久化 Key 命名**:
|
||||
- `slot_msg:{session_id}:{slot_id}:{index}` → 消息内容
|
||||
- `slot_meta:{session_id}:{slot_id}` → `SlotMeta`(含 `parent_id`)
|
||||
- `slot_rel:{session_id}:{child_id}:parent` → `"{parent_id}"`
|
||||
|
||||
**`AgentSession` 扩展**:
|
||||
- `create_slot(id, config)` — 创建新 slot
|
||||
- `switch_slot(id)` — 切换当前 slot
|
||||
- `list_slots()` — 列出所有 slot
|
||||
- `derive_slot(id, parent_id, strategy)` — 从父 slot 派生
|
||||
|
||||
**与 `ConversationMemory` 的关系**:保留不废除。`ConversationMemory` 继续服务传统对话场景。
|
||||
|
||||
**v0.2 不做**:
|
||||
- ❌ `slot.fork()` / `merge()` — 分支方法推迟到 v0.3+
|
||||
- ❌ `inject_summary` 自动生成 — v0.2 仅消费端(从 `SessionMemory` 读取),生成在 v0.3+
|
||||
- ❌ 血缘关系图遍历 — 只存 `parent_id`,不做查询
|
||||
|
||||
**依赖**:Phase 0(MemoryStore trait)、Phase 3(MemoryStore 持久化)
|
||||
**优先级**:P1
|
||||
|
||||
---
|
||||
|
||||
### v0.2.0 实施计划 — 8 个增量 Phase
|
||||
|
||||
> **编号说明**:Phase 5-12 接续 v0.1 的 Phase 0-4c,按开发顺序排列。
|
||||
|
||||
#### Phase 5: 热身准备(Warmup)
|
||||
|
||||
**目标**:快速交付三个互不依赖的独立改动,建立交付节奏。
|
||||
|
||||
| Step | 内容 | 文件范围 | 验证标准 |
|
||||
|------|------|---------|---------|
|
||||
| **5.1** ✅ | `ProviderConfig` 扩展:补 `timeout_secs`(def=30) + `max_retries`(def=3);新增 `ProviderConfig::from_env(prefix)` | `llm/provider.rs` + 各 Provider `new()` 构造函数 | `cargo test` + `from_env()` 单元测试 |
|
||||
| **5.2** ✅ | `OllamaProvider`:基于 `GenericOpenaiProvider` 包装,改 base_url 为 `http://localhost:11434`;`ProviderType` 新增 `Ollama` | `llm/provider/provider.rs` + `llm/provider/ollama.rs`(新增) | `cargo build` — 纯类型级验证 |
|
||||
| **5.3** ✅ | 公开枚举 `#[non_exhaustive]` 前置标记:`ProviderType` / `StopReason` / `FinishReason` / `EvictionPolicy` / `SlotMode`(预置) | 各枚举定义处 | 编译通过 + `cargo clippy` 0 警告 |
|
||||
|
||||
**实际新增**(2026-07-05 commit `98dfe6c`):
|
||||
- 新增文件 1 个(`llm/provider/ollama.rs`,72 行)
|
||||
- 修改文件 2 个(`llm/provider.rs` 加 `from_env` + `Default` + 4 个字段;`memory/store.rs` EvictionPolicy 加 `#[non_exhaustive]`)
|
||||
- `ProviderType::Ollama` 变体 + `FromStr` 解析("ollama" → Ollama)
|
||||
- `OllamaProvider::new(base_url, api_key, model, timeout_secs)` + `with_client()` 构造函数
|
||||
- `ProviderConfig::from_env(prefix)` 解析 `{prefix}_API_KEY` / `{prefix}_BASE_URL` / `{prefix}_MODEL` 环境变量
|
||||
- 全量测试 182 → 190(+8,phase 5 新增 from_env 与 Ollama 相关单测)
|
||||
- clippy 0 警告
|
||||
|
||||
**依赖**:无(三个 Step 互不冲突)
|
||||
**优先级**:P0(5.1)+ P1(5.2)+ P0 前置(5.3)
|
||||
**为何独立成 Phase**:三个改动零文件重叠,可以并行推进。它们是后续所有 Phase 的"门把手"——先做完热身再进入核心工作。
|
||||
**状态**:✅ Phase 5 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
#### Phase 6: ToolDefinition IR 正式化
|
||||
|
||||
**目标**:引入 `ToolDef` 新类型,替换已标记 `#[deprecated]` 的 `ToolDefinition`(`OpenaiToolDefinition` 别名)。
|
||||
|
||||
**这是 v0.2 技术风险最高的 Phase**,影响 4 个模块约 8 个文件。通过 5 个 Step 逐文件切割确保每步可编译。
|
||||
|
||||
| Step | 内容 | 验证标准 |
|
||||
|------|------|---------|
|
||||
| **6.1** ✅ | `types/tool.rs` 新增 `ToolDef` 结构体 + `From<ToolDef> for OpenaiToolDefinition` + 反向 `From` | 单元测试 roundtrip |
|
||||
| **6.2** ✅ | `types/mod.rs` 切别名 `pub type ToolDefinition = ToolDef`;`MessageRequest.tools` 改 `Vec<ToolDef>` | `cargo build` 编译断点 |
|
||||
| **6.3** ✅ | `cycle.rs` 4 个方法签名 + `registry.rs` `definitions()` 签名更新 | `cargo build` |
|
||||
| **6.4** ✅ | Provider 适配层(openai.rs / anthropic.rs / openai_compat.rs):`build_request()` 内做 `ToolDef → wire-format` 转换 | `cargo test` 每个 provider 测试 |
|
||||
| **6.5** ✅ | 所有测试/示例中 `ToolDefinition` → `ToolDef` 修复;移除旧 `#[deprecated]` alias | `cargo test --all-targets` 全绿 |
|
||||
|
||||
**边界切割技巧**:
|
||||
- Step 6.1 → 6.2 之间是安全 checkpoint:新类型存在但旧代码照常编译
|
||||
- Provider 层不改序列化逻辑,只加一层 `From` 转换
|
||||
- 当前代码中 `ToolDefinition` 已是 `#[deprecated(since = "0.1.0")]`,用户已有迁移预期
|
||||
|
||||
**依赖**:无(仅与 Phase 5.3 有枚举兼容关系)
|
||||
**优先级**:P0
|
||||
|
||||
**实际新增**(2026-07-05 commit `4cf5918` / `9da9b83` / `b187519`,详见 `docs/13-phase6-tooldef-ir.md`):
|
||||
- 修改文件 8 个:`llm/types/tool.rs`、`llm/types/mod.rs`、`llm/types/request_v2.rs`、`llm/cycle.rs`、`llm/provider/openai.rs`、`tools/registry.rs`、`tools/mcp.rs`、`agent/agent.rs`
|
||||
- `ToolDef` IR(name / description / parameters,无 `strict`)新增于 `types/tool.rs`,配套双向 `From` 转换
|
||||
- `OpenaiToolDefinition` 降级为 `#[doc(hidden)]`,仅供 OpenAI 适配层内部消费
|
||||
- `MessageRequest.tools` 切换为 `Vec<ToolDef>`
|
||||
- `ToolDefinition` 别名最终完全移除(直接使用 `ToolDef`)
|
||||
- 4 处 `#[allow(deprecated)]` 抑制点全部清理(cycle/registry/mcp/agent);残留 `#[allow(deprecated)]` 均与 `ChatResponse` / `with_system_prompt` 等其他弃用项无关
|
||||
- 新增 roundtrip 测试 `message_request_with_tools_roundtrip`(断言 `strict` 字段不泄漏到序列化输出)
|
||||
- Anthropic 适配层字段名一致零改动;openai_compat/ollama 委托 `GenericOpenaiProvider` 零改动
|
||||
- 全量测试 190 → 191(+1,Phase 6 新增 roundtrip);clippy 0 警告
|
||||
|
||||
**状态**:✅ Phase 6 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
#### Phase 7: SqliteStore 持久化
|
||||
|
||||
**目标**:实现 `MemoryStore` 的 SQLite 后端,进程重启数据不丢。
|
||||
|
||||
**与 Phase 6 无耦合,可重叠开发。**
|
||||
|
||||
| Step | 内容 | 文件 | 验证标准 |
|
||||
|------|------|-----|---------|
|
||||
| **7.1** ✅ | 新增 `memory/store/sqlite.rs`:`Mutex<Connection>` + `spawn_blocking`,实现 `save/get/delete/list` + prefix 过滤 | `memory/store/sqlite.rs` + `Cargo.toml`(add `rusqlite`) | 单元测试 CRUD + prefix 查询 |
|
||||
| **7.2** ✅ | WAL 模式 + 并发安全 + 集成测试(`tokio::spawn` 10 个并发 task) | `sqlite.rs` 扩展 | 并发写入 100 轮无 race |
|
||||
|
||||
**设计决策**:
|
||||
- 用 `Mutex<Connection>` 而非连接池(ponytail:一个连接够用就不加 r2d2)
|
||||
- WAL 模式:`PRAGMA journal_mode=WAL` 解决读写锁
|
||||
|
||||
**依赖**:`MemoryStore` trait(v0.1 Phase 3 已就绪)
|
||||
**优先级**:P0
|
||||
|
||||
**实际新增**(2026-07-05 commit `13edacd` / `c8a91f6` / `c82af60`,详见 `docs/14-phase7-sqlite-store.md`):
|
||||
- 方案文档:`docs/14-phase7-sqlite-store.md`(526 行,Phase 7 设计推演与权衡记录)
|
||||
- 结构重组:`src/memory/store.rs` 单体文件 → `src/memory/store/{mod.rs(in_memory.rs, sqlite_store.rs)}` 模块目录;外部导入路径 `crate::memory::store::MemoryStore` 不变
|
||||
- 新增文件 2 个:`src/memory/store/sqlite_store.rs`(545 行 SqliteStore 实现 + 9 个内联测试)、`src/memory/store/in_memory.rs`(266 行,结构搬移)
|
||||
- 核心实现要点:
|
||||
- `Arc<Mutex<Connection>>` 串行化所有 IO;`spawn_blocking` 卸载到阻塞线程池
|
||||
- WAL 模式 + `synchronous=NORMAL` + `busy_timeout=5s` + `wal_autocheckpoint=1000`
|
||||
- `PRAGMA user_version` schema 版本管理(`INITIAL_USER_VERSION = 1`)
|
||||
- `created_at` 归一化为 UTC 的 RFC 3339 TEXT,字典序等价时间序
|
||||
- 错误精细映射:`SqliteFailure` / `InvalidQuery` → `InvalidInput`;`FromSqlConversionFailure` → `Serialization`;其他 → `Storage`
|
||||
- 9 个内联测试覆盖:CRUD、upsert、prefix / since / offset+limit 过滤、10 写者 × 10 次并发写入、持久化 round-trip(重启连接不丢数据)、`InMemoryStore ↔ SqliteStore` trait-box 互换兼容性
|
||||
- 依赖:`rusqlite = { version = "0.32", features = ["bundled"] }`;`time` 增补 `parsing` / `formatting` / `macros` features;`dev-dependencies` 新增 `tempfile = "3"`
|
||||
- 全量测试 191 → 200(+9,Phase 7 新增 SqliteStore 单测);clippy 0 警告
|
||||
|
||||
**状态**:✅ Phase 7 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
#### Phase 8: MVP 集成出口(v0.2.0-rc.1 候选)
|
||||
|
||||
**目标**:P0 五项全部交付。开发者 clone 仓库后 10 分钟跑起持久化 Agent。
|
||||
|
||||
| Step | 内容 | 验证标准 |
|
||||
|------|------|---------|
|
||||
| **8.1** ✅ | API 稳定性扫尾:`#[non_exhaustive]` × 14 公开枚举 + `StepStatus::Completed` 切 `MessageResponse` + CHANGELOG v0.2.0-rc.1 + Cargo.toml 0.2.0-rc.1 | `cargo doc --no-deps` 0 warning + 零 deprecated warning |
|
||||
| **8.2** ✅ | Quick Start 示例(57 行 `main.rs`):MockProvider + EchoTool + submit_turn 真实工具调用 | `cargo run --example quick_start` exit 0 |
|
||||
| **8.3** ✅ | 端到端示例:SqliteStore + AG_LLM_* from_env 自动检测 + 3 工具 + 3 轮对话 + 持久化跨连接验证 | `cargo run --example end_to_end`(Mock fallback,无需 API key)|
|
||||
|
||||
**Phase 8 全部完成**。**已打 `v0.2.0-rc.1` 标签**。
|
||||
|
||||
**实际新增**(2026-07-05,7 commits):
|
||||
- `feat(core)` —— 14 个公开枚举追加 `#[non_exhaustive]`(P0 核心 IR + P0 Error + P1 其他)
|
||||
- `refactor(agent)` —— `StepStatus::Completed(ChatResponse)` → `Completed(MessageResponse)` + `task_agent_demo.rs` 清理 3 处废弃类型
|
||||
- `docs` —— CHANGELOG v0.2.0-rc.1 条目 + Cargo.toml version 0.1.0 → 0.2.0-rc.1 + README 示例列表 7 → 10
|
||||
- `test(core)` —— 验证 commit 1-3 零回归(test 200 passed + clippy 0 警告 + doc 0 warning)
|
||||
- `feat(examples)` —— `quick_start.rs`(60 行)+ `end_to_end.rs`(246 行)
|
||||
- `docs(roadmap)` —— 标记 Phase 8 全部完成 + M4 里程碑 ✅
|
||||
- `fix(examples)` —— 实施后 PM/SA/Code Reviewer 三方审查发现 6 项问题(🔴 CalcTool 除零 panic + 🟡 drop 注释准确性 + 🟡 EchoTool 错误处理 + 💭 断言一致性 + 💭 工具两端语义统一 + 💭 trailing newline),全部修复
|
||||
|
||||
**依赖**:Phase 5(ProviderConfig from_env)+ Phase 6(ToolDef)+ Phase 7(SqliteStore)
|
||||
**优先级**:P0
|
||||
**状态**:✅ Phase 8 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
#### Phase 9: 流式体验增强
|
||||
|
||||
**目标**:Agent 会话支持流式输出,开发者看到实时 token。
|
||||
|
||||
| Step | 内容 | 文件 | 验证标准 |
|
||||
|------|------|-----|---------|
|
||||
| **9.1** ✅ | `AgentSession::submit_turn_stream(user_input) -> impl Stream<Item=StreamEvent>` | `agent/session.rs` | 单元测试验证流事件序列:`TextDelta → ... → MessageComplete` |
|
||||
|
||||
**注意**:tool 自动循环时流中插入 `ToolExecutionStarted` 事件,用户端 UI 显示"正在调用工具..."。
|
||||
|
||||
**依赖**:Phase 6(ToolDef)+ `LlmProvider.chat_stream`(v0.1 已有)
|
||||
**优先级**:P1
|
||||
|
||||
**实际新增**(2026-07-06 commit `212cfcc`,详见 `docs/16-phase9-streaming-experience.md`):
|
||||
- 方案文档:`docs/16-phase9-streaming-experience.md`(821 行,含状态机设计推演与边界情况)
|
||||
- 修改文件 3 个:`src/agent/session.rs`(+208,含 `submit_turn_stream` / `finalize_turn`)、`src/llm/cycle.rs`(+784,含 `submit_with_tools_stream` / `run_tool_loop` spawn + mpsc 状态机)、`src/llm/types/response_v2.rs`(+21,含 `StreamEvent::ToolExecutionStarted`/`Completed` 变体 + `apply_to` 元事件)
|
||||
- 关键设计:`CycleConfig` 加 `Clone` derive 以支持 spawn 跨 task;`finalize_turn` 手动同步状态(`submit_turn_stream` 返回流前不落库,避免半成品被 hook 误读)
|
||||
- 测试:新增 9 个单元测试 + 2 个集成测试(含 `submit_turn_stream_end_to_end` 端到端 mock provider 流消费 + `submit_turn_stream_triggers_turn_hooks` Hook 触发验证),全量 200 → 211(+11,0 失败)
|
||||
- clippy 0 警告
|
||||
- 无新增外部依赖
|
||||
|
||||
**状态**:✅ Phase 9 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
#### Phase 10: ContextSlot 上下文管理
|
||||
|
||||
**目标**:支持多上下文分区管理,Agent 可在不同 slot 之间切换。
|
||||
|
||||
| Step | 内容 | 验证标准 |
|
||||
|------|------|---------|
|
||||
| **10.1** ✅ | `src/agent/context.rs`:`ContextSlot` + `SlotConfig` / `SlotMode` / `FocusedConfig` / `SlotSource` / `DeriveStrategy` / `ContextBudget` / `SlotMeta` 核心类型 | `cargo build --all-targets` |
|
||||
| **10.2** ✅ | ContextSlot 持久化:基于 `MemoryStore` trait(不绑定 SqliteStore)实现 save/load/list/delete + slot 命名空间 key 策略 + `load_messages()` Focused 读时过滤 + `append_messages()` Readonly 阻断 + colon 注入防护 | 单元测试:持久化 roundtrip / session 隔离 / Focused 边界 / delete 保护 / 派生 / load_messages() |
|
||||
| **10.3** ✅ | `AgentSession` 扩展:`create_slot` / `switch_slot` / `list_slots` / `derive_slot` / `delete_slot` + `new()` 自动创建 `"default"` slot + `submit_turn`/`finalize_turn` 改造为基于当前 slot 的增量追加写回 + 新示例 `context_slot_demo` | 集成测试 + `cargo run --example context_slot_demo` exit 0 |
|
||||
|
||||
**如何保证简单场景无感**:`AgentSession::new()` 内部检查,自动创建 `"default"` slot → `submit_turn` 默认写到 default slot。
|
||||
|
||||
**实际新增**(2026-07-07 commit `6359422`,详见 `docs/17-phase10-contextslot.md`):
|
||||
- 方案文档:`docs/17-phase10-contextslot.md`(1227 行,含 §5 推荐方案、§6 实施建议、§9 实施计划,经过 4 轮方案/计划/实施审查 + 1 轮非阻塞建议修复)
|
||||
- 新增文件 3 个:`src/agent/context.rs`(~430 行 ContextSlot 核心类型 + 持久化方法 + 22 个测试)、`src/agent/context.rs` 中的 `ContextSlot::filter_focused` 静态方法(被 `load_messages` 和 `derive_slot` 复用,消除代码重复)、`examples/context_slot_demo.rs`(~160 行分支对话示例:法律咨询 → 派生两个方向 → 切换 → 隔离验证 → 删除保护)
|
||||
- 修改文件 3 个:`src/agent.rs`(+5 行 module 声明 + re-export)、`src/agent/error.rs`(+56 行:3 个新变体 `SlotReadonly`/`SlotNotFound`/`SlotAlreadyExists` + 4 个测试)、`src/agent/session.rs`(+825/-197 行:slots 字段 + 6 个管理方法 + submit_turn/finalize_turn 改造 + 17 个测试)
|
||||
- 关键设计:
|
||||
- **模块归属**:`agent/context.rs`(零新依赖方向,遵循 `agent → memory` 已有依赖)
|
||||
- **持久化**:JSON blob 批次存储,每 slot 3-4 条 `MemoryItem`(`slot_data` / `slot_meta` / `slot_config` / `slot_rel`)
|
||||
- **submit_turn 签名不变**:方案 A(内部 `current_slot_id` 状态),向后兼容
|
||||
- **Focused 模式读时过滤**:`load_messages() -> Vec<Message>`,避免 Rust 借用检查问题
|
||||
- **增量追加写回**:`cycle.messages()[input_len..]` 提取本轮新增消息,确保 Focused 模式数据不丢失
|
||||
- **delete_slot 双重保护**:禁止删 `"default"` + 至少保留一个 slot
|
||||
- **colon 注入防护**:`assert_no_colon` 在 key 构造时 panic
|
||||
- **错误传播**:`serde_json` / `MemoryStore` 所有错误用 `?` 传播,无静默吞掉
|
||||
- 验证:211 → 254 测试(+43 新测试),clippy 0 警告,doc 0 warning,10 + 1 示例全部 exit 0
|
||||
- finalize_turn 签名变更(破坏性):新增 `new_messages_from_cycle: Vec<Message>` 参数,返回从 `()` 改为 `Result<(), AgentError>`——影响 Phase 9 的 `submit_turn_stream_triggers_turn_hooks` 和 `submit_turn_stream_end_to_end` 2 个测试,已适配
|
||||
|
||||
**依赖**:Phase 5(`#[non_exhaustive]` 预置 SlotMode 等枚举)、Phase 7(SqliteStore 推荐持久化后端;`MemoryStore` trait 即可)
|
||||
**优先级**:P1
|
||||
**状态**:✅ Phase 10 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
#### Phase 11: 测试与检索补强
|
||||
|
||||
**目标**:补全测试覆盖 + 语义检索抽象。
|
||||
|
||||
| Step | 内容 | 验证标准 |
|
||||
|------|------|---------|
|
||||
| **11.1** ✅ | `VectorRetriever` trait:`index(id, embeddings)` + `search(query, k)` | 编译 + mock 测试 |
|
||||
| **11.2** ✅ | wiremock Provider roundtrip 测试:模拟 OpenAI/Anthropic HTTP 端点 | `cargo test` 新增 10+ roundtrip 测试 |
|
||||
| **11.3** ✅ | 并发测试补强:InMemoryStore + SqliteStore 多线程写入验证 | 跑 100 轮无 race |
|
||||
|
||||
**实际新增**(2026-07-06 commit `71abe88` / `b4e5c7d`,详见 `docs/18-phase11-testing-and-retrieval.md`):
|
||||
- 方案文档:`docs/18-phase11-testing-and-retrieval.md`(647 行,含 11.1/11.2/11.3 设计 + 10 项架构决策 + 实施后补充 2 条偏差记录 #6 mid-stream mock 模式 + #7 429 retry-after 修复)
|
||||
- 新增文件 1 个:`src/memory/vector.rs`(237 行 — `VectorRetriever` trait + `InMemoryVectorRetriever` 引用实现 + `dot()` 零依赖 + 6 个内联测试)
|
||||
- 修改文件 5 个:
|
||||
- `src/memory.rs`(+2 行:module 声明 + re-export)
|
||||
- `src/llm/provider/openai.rs`(+8 wiremock 测试 + `handle_error_response` 429 retry-after 解析修复 5 行)
|
||||
- `src/llm/provider/anthropic.rs`(+4 wiremock 测试)
|
||||
- `src/memory/store/in_memory.rs`(+3 并发测试:100 并发写、5 写+5 读混合、15 写者容量淘汰)
|
||||
- `src/memory/store/sqlite_store.rs`(+2 并发测试:100 并发写、5 写+5 读混合)
|
||||
- 关键设计:
|
||||
- **零依赖 dot()**:手写点积/范数,零新增 crate 依赖
|
||||
- **Wiremock 测试自包含**:每个测试独立 `MockServer::start()`,沿用现有模式
|
||||
- **429 retry-after 修复**:`openai.rs` 与 `anthropic.rs` 行为对齐(5 行代码)
|
||||
- **偏差记录**:方案文档「已否决的方案 #6/#7」记录两处实施偏差,便于后续审计追溯
|
||||
- 验证:254 → 277 测试(+23 个新测试),clippy 0 警告,doc 0 warning;并发测试连续 3 次运行稳定无 flaky
|
||||
- **依赖**:无(与方案一致)
|
||||
- **状态**:✅ Phase 11 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
#### Phase 12: P2 锦上添花(可选)
|
||||
|
||||
**目标**:时间允许时按优先级交付。
|
||||
|
||||
| 优先级 | 功能 | 实现量估计 | 备注 |
|
||||
|--------|------|-----------|------|
|
||||
| **12.1** | 文件系统 MemoryStore(JSON/JSONL) | ~80 行 | 最简单,适合练手 |
|
||||
| **12.2** | MCP StreamableHttp 传输 | ~150 行 | 协议还在演进 |
|
||||
| **12.3** | Gemini Provider | ~300 行 | 协议差异大,建议推迟到 v0.3 |
|
||||
|
||||
**依赖**:无(独立交付)
|
||||
|
||||
---
|
||||
|
||||
### v0.2.0 Phase 依赖关系图
|
||||
|
||||
```mermaid
|
||||
graph BT
|
||||
P5["<b>Phase 5: 热身准备</b><br/>ProviderConfig::from_env<br/>Ollama Provider<br/>#[non_exhaustive] 标记"]:::done
|
||||
P6["<b>Phase 6: ToolDef IR</b><br/>Provider 无关工具定义"]:::done
|
||||
P7["<b>Phase 7: SqliteStore</b><br/>rusqlite + WAL<br/>9 个内联测试<br/>持久化 round-trip"]:::done
|
||||
P8["<b>Phase 8: MVP 出口</b><br/>rc.1 标签<br/>14 枚举 #[non_exhaustive]<br/>StepStatus IR 迁移<br/>quick_start + end_to_end"]:::done
|
||||
P9["<b>Phase 9: 流式体验增强</b><br/>submit_turn_stream<br/>submit_with_tools_stream<br/>9 单元测试 + 2 集成测试"]:::done
|
||||
P10["<b>Phase 10: ContextSlot</b><br/>ContextSlot 类型<br/>JSON blob 持久化<br/>AgentSession 集成<br/>43 个新测试"]:::done
|
||||
P11["<b>Phase 11: 测试与检索补强</b><br/>VectorRetriever trait<br/>12 wiremock tests<br/>5 并发测试"]:::done
|
||||
P12["Phase 12<br/>P2 锦上添花"]:::p2
|
||||
|
||||
P8 --> P5
|
||||
P8 --> P6
|
||||
P8 --> P7
|
||||
|
||||
P9 --> P6
|
||||
|
||||
P10 --> P7
|
||||
P10 --> P8
|
||||
|
||||
P11 -.-> P7
|
||||
|
||||
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
|
||||
classDef warmup fill:#e2e8f0,stroke:#94a3b8
|
||||
classDef core fill:#fbbf24,stroke:#d97706
|
||||
classDef mvp fill:#4ade80,stroke:#16a34a
|
||||
classDef p1 fill:#93c5fd,stroke:#2563eb
|
||||
classDef p2 fill:#c4b5fd,stroke:#7c3aed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 关键里程碑
|
||||
|
||||
| 里程碑 | Phase 完成条件 | 可验证指标 | 状态 |
|
||||
|--------|---------------|-----------|------|
|
||||
| **M1** | Phase 5 | 热身三项完成:`from_env()` 可用 / Ollama 类型存在 / `#[non_exhaustive]` 就位 | ✅ 2026-07-05 |
|
||||
| **M2** | Phase 6 | `ToolDef` 全量切换,`cargo test --all-targets` 全绿 | ✅ 2026-07-05 |
|
||||
| **M3** | Phase 7 | SqliteStore CRUD + 并发测试通过,进程重启数据不丢 | ✅ 2026-07-05 |
|
||||
| **M4** | **Phase 8 (rc.1)** | P0 五项全部交付,`cargo run --example quick_start` 跑通 | ✅ 2026-07-05 |
|
||||
| **M5** | Phase 9 | `submit_turn_stream` 流式事件序列验证通过 | ✅ 2026-07-06 |
|
||||
| **M6** | Phase 10 | ContextSlot 创建/切换/派生集成测试通过 | ✅ 2026-07-07 |
|
||||
| **M7** | Phase 11 | wiremock + 并发测试补强,测试总量 200+ | ✅ 2026-07-06 |
|
||||
| **M8** | Phase 12(可选) | P2 功能按需交付 | ⏳ |
|
||||
@@ -0,0 +1,367 @@
|
||||
# AG Core Roadmap — v0.3.0
|
||||
|
||||
> 本文件聚焦 **v0.3.0 版本** 的规划与交付(Phase 13–19)。Phase 13-19 全部完成,v0.3.0 交付完毕。
|
||||
> 返回总入口:[`roadmap.md`](./roadmap.md)
|
||||
|
||||
## v0.3.0 愿景
|
||||
|
||||
从"LLM 调用工具箱"升级为"能构建多 Agent 协作、RAG、长记忆 Agent 产品的基础系统"。补齐 LangChain 7 大组件中缺失的 Document 和 VectorStore 能力,落地笔记设计中的 ContextSlot fork/merge、摘要自动生成、知识图谱,建立 engine 引擎层(会话树 + time-travel Checkpointer + SubAgent Dispatch + Agent Switch),为即将开发的多 Agent 产品提供完整基础。
|
||||
|
||||
## v0.3.0 总体范围
|
||||
|
||||
**总体规模**:7 个增量 Phase(Phase 13–19),总新增代码约 2600 行,测试从 277 → 427。7 个 Phase 全部完成(M9-M15 已达成),v0.3.0 交付完毕。
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0 — 多 Agent 基础系统(Multi-Agent Foundation)
|
||||
|
||||
**目标**:从"LLM 调用工具箱"升级为"能构建多 Agent 协作、RAG、长记忆 Agent 产品的基础系统"。补齐 LangChain 7 大组件中缺失的 Document 和 VectorStore 能力,落地笔记设计中的 ContextSlot fork/merge、摘要自动生成、知识图谱,建立 engine 引擎层(会话树 + time-travel Checkpointer + SubAgent Dispatch + Agent Switch),为即将开发的多 Agent 产品提供完整基础。
|
||||
|
||||
**总体规模**:7 个增量 Phase(Phase 13-19),总新增代码约 2600 行,测试从 277 → 427。
|
||||
|
||||
### 功能清单
|
||||
|
||||
#### P0 — 必须交付
|
||||
|
||||
| # | 功能 | 模块 | 方案要点 |
|
||||
|---|------|------|---------|
|
||||
| 1 | 技术债清理(旧 types 文件) | `llm/types` | `request.rs` / `response.rs` / `old_stream.rs` 三个 Phase 0 旧文件删除;内部类型移入 `provider/openai.rs` |
|
||||
| 2 | ContextSlot fork/merge | `agent/context` | `fork(child_id, strategy)` 别名 + `merge(child, MergeStrategy)` 三种策略(Append/Replace/Summarize) |
|
||||
| 3 | Document 系统 | `document/`(新模块) | `Document` 核心类型 + `RecursiveCharacterSplitter`(递归字符分割,支持 chunk_size/chunk_overlap/separators) |
|
||||
| 4 | Embedding 抽象 | `llm/embedding` | `Embedding` trait(`embed` / `dim`)+ `MockEmbedding` 测试实现 |
|
||||
| 5 | 向量存储持久化 | `vector/`(新模块) | `VectorStore` trait + `InMemoryVectorStore`(读写)+ `PersistentVectorStore`(SqliteStore 后端)+ `RagPipeline` 组合器 |
|
||||
| 6 | 摘要自动生成 | `agent` / `llm/hooks` | `SummaryConfig` 配置 + `OnTurnEnd` Hook 自动检测 token 水位 → 调 LLM 生成摘要 → `SessionMemory::set("conversation_summary", ...)` |
|
||||
| 7 | SessionManager + 会话树 | `engine/`(新模块) | Session 工厂(`create`/`create_child`)+ 按 ID 恢复(`get`)+ 子树管理(`children`/`parent`/`destroy_subtree`)+ 元数据持久化(MemoryStore) |
|
||||
| 8 | Time-travel Checkpointer | `engine/checkpointer` | `checkpoint(session)` 全量序列化 + `rollback(session_id, ckpt_id)` 回滚 + `fork(session_id, ckpt_id, new_id)` 分支 + `list_checkpoints` |
|
||||
| 9 | Agent Switch | `engine/switch` | 热切换 `session.agent`(替换 `Arc<dyn Agent>`),slot 历史 / turn_index / session_memory 全保留 |
|
||||
| 10 | SubAgent Dispatch | `engine/sub_agent` | `dispatch(parent, sub_agent, task, config)` 单任务 + `dispatch_all(parent, tasks, config)` 并行派发(Semaphore 并发控制)+ 子 SessionMemory 继承 + `SubTaskResult` 结构化回传 |
|
||||
| 11 | 知识图谱 | `memory/graph` | `KnowledgeGraph` trait(`add_entity` / `add_relation` / `get_related` / `find_by_keywords`)+ `InMemoryGraph` 实现 + `tag_index` 标签管理 |
|
||||
| 12 | 双通道检索 | `memory/retriever` | `MemoryRetriever` 扩展为双通道(`KnowledgeStore` + `KnowledgeGraph`)+ `RetrievalStrategy::Hybrid` |
|
||||
|
||||
### 实施计划 — 7 个增量 Phase
|
||||
|
||||
> **编号说明**:Phase 13-19 接续 v0.2 的 Phase 5-12,按开发顺序排列。
|
||||
|
||||
#### Phase 13: 热身清理 + ContextSlot fork/merge
|
||||
|
||||
**目标**:清除 Phase 0 遗留的旧 types 文件,交付超低价功能建立节奏。
|
||||
|
||||
| Step | 内容 | 文件范围 | 验证标准 |
|
||||
|------|------|---------|---------|
|
||||
| **13.1** | `OpenaiChatRequest` 移入 `provider/openai.rs`,`types/request.rs` 删除 | `llm/types/request.rs` + `llm/provider/openai.rs` | `cargo build --all-targets` |
|
||||
| **13.2** | `OpenaiChatResponse/Chunk` 移入 `provider/openai.rs`,`types/response.rs` 删除 | `llm/types/response.rs` + `llm/provider/openai.rs` | `cargo build --all-targets` |
|
||||
| **13.3** | `old_stream.rs` 删除 + `types/mod.rs` 中 `ChatResponse` 删除 | `llm/types/old_stream.rs` + `llm/types/mod.rs` | `cargo build` + 确认 3 个旧文件不存在 |
|
||||
| **13.4** | `ToolChoice` 从 `request.rs` 搬到 `tool.rs` | `llm/types/tool.rs` + `llm/types/request_v2.rs` | `cargo test --all-targets` 全绿 |
|
||||
| **13.5** | `ContextSlot::fork(child_id, strategy)` 别名 + `merge(child, MergeStrategy)` | `agent/context.rs` | 单元测试:fork → 子 slot 消息 = 父 slot 副本;merge(Append) → 消息按序追加 |
|
||||
|
||||
**依赖**:无
|
||||
**优先级**:P0
|
||||
**预估规模**:约 200 行
|
||||
**状态**:✅ Phase 13 全部交付物已完成(2026-07-08)
|
||||
|
||||
---
|
||||
|
||||
#### Phase 14: Document 系统 + Embedding 抽象
|
||||
|
||||
**目标**:补齐 LangChain 7 大组件中最明显的缺口——Document 类型和分割器。不搞 Loader 框架,用户用 `fs::read_to_string` 自行加载。
|
||||
|
||||
**交付物**:
|
||||
1. `src/document.rs` 新模块(`Document` 类型 + `RecursiveCharacterSplitter`)
|
||||
2. `src/llm/embedding.rs`(`Embedding` trait + `MockEmbedding`)
|
||||
|
||||
**设计要点**:
|
||||
- `Document`:id / content / metadata(HashMap<String, String>)/ mime_type
|
||||
- `RecursiveCharacterSplitter`:chunk_size(默认 1000)/ chunk_overlap(默认 200)/ separators(`["\n\n", "\n", "。", "?", "!", ".", " ", ""]`,含 CJK 标点)
|
||||
- 两阶段算法:按 separator 优先级递归分割(Phase 1)+ 贪心合并 + overlap 滑动窗口(Phase 2)
|
||||
- 所有长度比较以 Unicode 字符数为单位(`chars_len()`),非字节数
|
||||
- `Embedding` trait:`async fn embed(&self, input: &[String]) -> Result<Vec<Vec<f32>>, LlmError>` + `fn dim()`
|
||||
- 复用 `LlmError` 而非新错误类型
|
||||
- `MockEmbedding`:sin-hash 零依赖伪随机向量 + L2 归一化
|
||||
- 不引入 `DocumentLoader` trait(应用层职责)
|
||||
|
||||
**实际新增**(2026-07-09 commit `d4c4d8f`,详见 `docs/20-phase14-document-and-embedding.md`):
|
||||
- 新增文件 3 个:
|
||||
- `src/document.rs`(580 行)— `Document` 类型(4 字段 + `new`/`from_raw` 构造器,2 个 `new` 接受 `impl Into<String>`) + `RecursiveCharacterSplitter`(两阶段算法:按 separator 优先级递归分割 + 贪心合并 overlap,所有长度比较 `chars_len()` 字符级,overlap 提取 `chars().rev().take().rev()` 字符级安全)+ 19 个内联测试
|
||||
- `src/llm/embedding.rs`(183 行)— `Embedding` trait(async + `LlmError`)+ `MockEmbedding`(sin-hash:字节和+长度做种子,`f32::sin(seed + i) * 10000`,L2 归一化到单位长度,零向量防除零)+ 6 个内联测试
|
||||
- `examples/document_demo.rs`(74 行)— 端到端演示 Document → RecursiveCharacterSplitter → MockEmbedding → InMemoryVectorRetriever → search
|
||||
- 修改文件 2 个:
|
||||
- `src/lib.rs`(+3 行:`pub mod document` + `pub use document::Document` + 空行)
|
||||
- `src/llm.rs`(+1 行:`pub mod embedding`)
|
||||
- 关键设计:
|
||||
- **早返回守卫**:`split_text` 在 `chars_len(text) <= self.chunk_size` 时直接返回 `[text]`,避免短文本在 Phase 2 `join("")` 中丢失 separator 边界
|
||||
- **`Document::new` 使用 `impl Into<String>`**:接受 `&str` 或 `String`,比规范示例的 `String` 更灵活
|
||||
- **`new()` panic + `try_new()` Result 双路径**:与 Rust 库惯例一致
|
||||
- **CJK 分隔符扩展**:`DEFAULT_SEPARATORS` 包含 `"。"`/`"?"`/`"!"`,避免中文文本跳过句子级退化为空格分割
|
||||
- **chunk_size = 0 校验**:构造器拒绝零值,避免字符级兜底死循环
|
||||
- **tracing 埋点**:`split()` 入口 `tracing::debug!` + 每文档/每 chunk `tracing::trace!`
|
||||
- **debug_assert 溢出保护**:单文档 chunk 数 < 10000 时 `debug_assert!`
|
||||
- **Metadata 键覆盖文档化**:`HashMap::insert()` 静默覆盖 source_id/chunk_index/chunk_count 在 `split()` doc comment 注明
|
||||
- 测试:19 个 Document 测试(含 1 个 split_multibyte_utf8_boundary CJK 边界测试)+ 6 个 Embedding 测试,全量 286 → 313(+27 新测试,但部分测试覆盖范围重叠计算约 25 个净增)
|
||||
- 方案文档:`docs/20-phase14-document-and-embedding.md`(1417 行,含背景/调研/方案对比/实施计划(详细版)/3 轮审查修复记录),经过 3 轮 PM/SA 审查 + 1 轮实施后修复
|
||||
- clippy 0 警告,doc 0 warning
|
||||
- 无新增外部依赖(`Cargo.toml` 未修改)
|
||||
|
||||
**实施后调整**:
|
||||
- 实施发现方案算法中 Phase 1 累加器设计与测试期望冲突("para1\n\npara2" 在 chunk_size=100 时 1 chunk 更合理),简化为"按 separator 切分 + Phase 2 合并"两阶段分工
|
||||
- 二次审查发现 `split_text` 缺少早返回守卫 + `current_sep_count` 虚增计数,全部已修复
|
||||
|
||||
**依赖**:无(纯数据结构 + 零新 crate 依赖)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 350 行
|
||||
**状态**:✅ Phase 14 全部交付物已完成(2026-07-09)
|
||||
|
||||
---
|
||||
|
||||
#### Phase 15: 向量存储持久化(SqliteStore 后端)
|
||||
|
||||
**目标**:实现 VectorStore 持久化,让语义检索支持进程重启后数据恢复。
|
||||
|
||||
**设计决策**:不用 pgvector。基于已有 SqliteStore(`rusqlite`)做持久化包装——运行时全量加载到 InMemory 索引做余弦搜索,写时同步到 SqliteStore。
|
||||
|
||||
**交付物**:
|
||||
1. 新增 `src/memory/vector_store.rs`(937 行)—— `VectorStore` trait + `InMemoryVectorStore` + `PersistentVectorStore` + `RagPipeline`
|
||||
2. `VectorStore` trait:`add(&[Document], &[Vec<f32>])` 批量 / `search(query, k)` 返回 `(Document, f32)` / `remove(ids)` 幂等
|
||||
3. `PersistentVectorStore`:构造时从 `MemoryStore` 全量加载已有索引;`add` 先写持久化后写内存(持久化失败时内存不污染,重启自动恢复);`search` 纯内存余弦搜索(快照 clone + 锁外计算)
|
||||
4. `RagPipeline`:组合器封装 `split → embed → store.add`(ingest)和 `embed → store.search`(retrieve)两条管线
|
||||
5. 存储格式:`vec:{namespace}:{doc_id}` → JSON `{doc_id, content, metadata, embedding, created_at}`,通过 `MemoryStore` 通用接口读写
|
||||
6. `src/memory/vector.rs` 旧 `VectorRetriever` trait + `InMemoryVectorRetriever` 标注 `#[deprecated(since = "0.3.0")]`,迁移路径指向 `VectorStore` / `InMemoryVectorStore`
|
||||
|
||||
**实际新增**(2026-07-09 commit `32d886f`):
|
||||
- 新增文件 1 个:`src/memory/vector_store.rs`(937 行,含 19 个内联测试)
|
||||
- 修改文件 3 个:`src/memory/vector.rs`(+4 行 deprecated 标注);`src/memory.rs`(+pub mod vector_store + 4 个 pub use re-export);`examples/document_demo.rs`(迁移到 RagPipeline ingest+retrieve)
|
||||
- 零新外部依赖(`Cargo.toml` 未修改)
|
||||
- 全量测试 313 → 335(+22,Phase 15 新增 19 测试 + 部分重叠计数 22 净增);clippy 0 警告,doc 0 warning
|
||||
- 设计文档:`docs/21-phase15-vector-store-persistence.md`(1570 行,经 3 轮审查 + 文档-代码不一致修复:`search_orthogonal_vectors` 返回 1 条 score≈0 而非空列表)
|
||||
|
||||
**依赖**:Phase 14(Document 类型 + Embedding trait)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 400 行(实际约 937 行纯实现 + 测试)
|
||||
**状态**:✅ Phase 15 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
#### Phase 16: 摘要自动生成
|
||||
|
||||
**目标**:闭环长对话能力。v0.2 的 `inject_summary` 消费端(`FocusedConfig.summary_override`)已就绪,缺的是生产端。
|
||||
|
||||
**交付物**:
|
||||
1. `SummaryConfig` 结构体:`trigger_token_ratio`(默认 0.75) / `max_context_tokens`(默认 32_000)/ `summary_prompt`(默认中文 `DEFAULT_SUMMARY_PROMPT` 含 `{messages}`) / `debounce_turns`(默认 3) / `summary_model`(默认 `None` 沿用主模型) / `max_tool_result_chars`(默认 500)
|
||||
2. 在 `submit_turn` / `finalize_turn` 中 OnTurnEnd 之后插入**内联检查点**(非 Hook 扩展):`should_summarize`(水位 + 防抖,首次不受防抖约束)→ `generate_summary` 关联函数(新 `LlmCycle` + `submit_messages` 无工具调用)→ 更新 `FocusedConfig.summary_override` + `slot.save()` 持久化 + `SessionMemory::set("conversation_summary", summary)` 全局快照
|
||||
3. `AgentBuilder` 扩展:`.summary_config(cfg)` 方法(不覆盖整个 `AgentConfig`)
|
||||
4. 公开 API:`get_conversation_summary() -> Result<Option<String>, AgentError>`
|
||||
5. `format_messages_as_text()` 简洁版消息格式化(System/User/Assistant + `[Tool: name]` + `Tool Result [id]:` 截断到 `max_tool_result_chars` 字符)
|
||||
|
||||
**设计决策**:内联于 `submit_turn` 流程而非 Hook 扩展(因为 HookContext 无法携带 `&mut self` 引用更新 slot config,且流式路径的 `finalize_turn` 中 `cycle` 已销毁)。`Option<SummaryConfig>` 的 opt-in 机制已足够提供可插拔性,不改变 Hook 系统签名。流式路径中 `submit_turn_stream` 已将 `turn_index` 提前 ++1,检查点使用 `saturating_sub(1)` 修正。
|
||||
|
||||
**依赖**:无(`submit_turn` 流程 + `CostTracker` + `SessionMemory` + `LlmProvider` 均已就绪)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 220 行(实际约 250 行,含 11 个内联测试)
|
||||
**方案文档**:`docs/22-phase16-summary-auto-generation.md`(471 行,经 PM/SA 审查 11 项修复 + 实施后第二轮审查 9 项修复全部完成)
|
||||
**状态**:✅ Phase 16 全部交付物已完成(含实施后 PM/SA/Code Reviewer 第二轮审查 PASS)
|
||||
|
||||
**实施后审查修复记录**(共 9 项):
|
||||
- 🔴 B1:`generate_summary` 调用 `submit_messages(Vec::new(), vec![])` 发送空消息列表 → 移除 `with_messages()`,直接 `submit_messages(vec![Message::user_text(prompt)], vec![])`
|
||||
- 🟡 W4:`should_summarize` 使用 `self.turn_index` 而非 `current_turn` 参数 → 改签名接收 `current_turn`,流式路径防抖准确
|
||||
- 🟡 W2:`summary_model` 硬编码 `unwrap_or("gpt-4o")` → 改为条件赋值,`None` 时沿用 `CycleConfig::default()`
|
||||
- 🟡 W5:Full 模式 `slot.save()` 无谓调用 → 移入 `SlotMode::Focused` 分支内
|
||||
- 🟡 W3:`format_messages_as_text` 缺 30K 整体截断 → 新增 `MAX_TOTAL_CHARS=30_000` + `truncate_total_chars`,优先保留最新
|
||||
- 🟡 W6:摘要成功无日志 → 添加 `tracing::info!(turn, summary_len, "摘要自动生成成功")`
|
||||
- 🟡 W1/W7:缺 3 个测试 → 新增 `format_total_charset_truncation_keeps_recent` / `summary_written_to_focused_slot_config` / `summary_skipped_for_empty_messages` / `summary_not_generated_if_max_context_unreachable`
|
||||
- 💭 `context.rs:78` 过时注释("v0.3 将支持 Hook 驱动")→ 更新为"v0.3 Phase 16 起 AgentBuilder 内联检查点自动生成摘要"
|
||||
|
||||
**第二轮审查门禁**:PASS(0 🔴 阻塞)。`cargo test --all-targets` **353 passed / 0 failed**,clippy 0 警告,doc 0 warning。
|
||||
|
||||
---
|
||||
|
||||
#### Phase 17: Agent 执行引擎(会话树 + Time-travel Checkpointer)
|
||||
|
||||
**目标**:建立 `engine/` 模块。解决 v0.2 中"session 在变量里、无法通过 ID 恢复、不支持父子关系"的空白。
|
||||
|
||||
**方案文档**:`docs/23-phase17-agent-execution-engine.md`
|
||||
|
||||
**交付物**:
|
||||
1. `src/engine/` 新模块(`session_manager.rs` + `checkpointer.rs` + `snapshot.rs` + `error.rs`)
|
||||
2. `SessionManager`:
|
||||
- `create(agent, bundle) -> Result<String, EngineError>` — 创建根 session(UUID v4 自动生成 ID)
|
||||
- `create_child(parent_id, agent) -> Result<String, EngineError>` — 创建子 session(继承父 `RuntimeBundle`,`Arc::clone` 共享引用)
|
||||
- `get(session_id) -> Result<Arc<Mutex<AgentSession>>, EngineError>` — 按 ID 查找(仅查内存,不自动从存储恢复)
|
||||
- `recover(session_id, agent, bundle) -> Result<Arc<Mutex<AgentSession>>, EngineError>` — 从存储恢复 session
|
||||
- `replace(session_id, session) -> Result<(), EngineError>` — 替换已有 session 实例(用于 rollback 后切换)
|
||||
- `children(parent_id)` / `parent(child_id)` — 树形查询
|
||||
- `destroy(id)` — 生命周期管理(允许孤儿 session 存在,不递归删除子 session)
|
||||
3. `Checkpointer`:
|
||||
- `checkpoint(session)` — 每个 `submit_turn` 末尾自动保存全量状态快照
|
||||
- `rollback_load(session_id, ckpt_id) -> SessionSnapshot` — 读取 checkpoint JSON 为 snapshot(不重建 AgentSession)
|
||||
- `list_checkpoints(session_id)` — 列出 checkpoint 列表
|
||||
- `delete_all(session_id)` — 清理某 session 所有 checkpoint
|
||||
- `fork()` 推迟(底层可拆解为 `rollback` + `create_child`,作为高层 API 等价于约 30 行组合代码,已具备原始能力)
|
||||
4. `SessionSnapshot` 独立 struct(位于 `engine/snapshot.rs`)—— 避开 `Arc<dyn Agent>` 不可序列化的限制,通过 `to_snapshot()` / `from_snapshot()` 双向转换实现 AgentSession 快照持久化
|
||||
- `to_snapshot()`(async,从 `SessionMemory` 读取完整数据)+ `from_snapshot()`(纯同步构造)+ `restore_memory()`(async 写回持久层)
|
||||
5. `EngineError` 枚举(含 `MemoryError` 透传变体 与项目既有 `AgentError` 风格一致)
|
||||
|
||||
**Checkpoint 存储格式**:`ckpt:{session_id}:{ckpt_id}` → `SessionSnapshot` JSON(全量 session 状态,含所有 slot 消息列表)。Ponytail:全量 JSON 够用,等遇到存储效率问题时再改增量模式。
|
||||
|
||||
**会话树持久化**:`session:{session_id}:meta` → `SessionMeta` JSON(`{agent_name, parent_id, created_at, turn_count}`)
|
||||
|
||||
**依赖**:Phase 10(ContextSlot 持久化 — 消息由 slot 自己管,Checkpointer 管执行状态)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 700 行(5 新增文件 + 5 修改文件)
|
||||
**状态**:✅ Phase 17 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
**实际新增**(2026-07-15,3 commits + 实施审查修复一轮):
|
||||
|
||||
- **新增 5 文件(`src/engine/`)**:`mod.rs`(19 行)+ `error.rs`(43 行)+ `snapshot.rs`(35 行)+ `checkpointer.rs`(373 行)+ `session_manager.rs`(910 行含测试)
|
||||
- **修改 5 文件**:
|
||||
- `src/llm/types/usage.rs` — `CostTracker` 加 `Clone, Serialize, Deserialize`(3 行)
|
||||
- `src/agent/context.rs` — `ContextSlot` + `MergeStrategy` 加 `Serialize, Deserialize`(4 行)
|
||||
- `src/agent/session_memory.rs` — 新增 `list_entries()` + `set_with_meta()` 方法
|
||||
- `src/agent/session.rs` — 新增 `to_snapshot()` (async) / `from_snapshot()` (sync) / `restore_memory()` (&mut self, async) / `has_pending_memory_restore()` + 公开 `bundle()` accessor
|
||||
- `src/lib.rs` — `pub mod engine`
|
||||
- **新增 1 示例**:`examples/engine_demo.rs`(~210 行,端到端演示 create → submit_turn → checkpoint → list → rollback_load → from_snapshot → restore_memory → replace → destroy 全链路,含 rollback 一致性 assert)
|
||||
- **依赖**:零新外部依赖(ponytail:ckpt_id 用纳秒+计数器生成,session_id 同理)
|
||||
- **测试**:353 → **374**(+21 引擎内联测试:Checkpointer 6 个 + SessionManager 15 个)
|
||||
- **质量基线**:`cargo test --all-targets` 374 passed / 0 failed;`cargo clippy --all-targets -- -D warnings` 0 警告;`cargo doc --no-deps` 0 warning;`cargo run --example engine_demo` exit 0
|
||||
- **关键设计决策落地**:
|
||||
- `SessionSnapshot` 独立 struct(避开 `Arc<dyn Agent>` 不可序列化)
|
||||
- `to_snapshot` async + `from_snapshot` 纯同步 + `restore_memory` async 三段式分离
|
||||
- `session_memory_data` 改用 `HashMap<String, SessionMemoryEntry>`(保留 metadata/created_at)
|
||||
- `EngineError::Memory(#[from] MemoryError)` 透传变体
|
||||
- `EngineManager` 锁契约:所有写操作先 HashMap 再 I/O(或反之,destroy 反向)
|
||||
- 自动 checkpoint 失败 `tracing::error!` 不阻断主流程(不提供强持久化保证)
|
||||
- ckpt_id 时间戳+纳秒+计数器无外部依赖(`created_at_nanos` 字段确保同秒内精确排序)
|
||||
- 孤儿策略:`destroy()` 不递归删除子 session;父被销毁后 `parent()` 返回 `Ok(None)`
|
||||
- **实施审查通过**:经过 PM + SA + Code Reviewer 三方联合审查 → 1 轮修复 → 全部 🟡 警告关闭
|
||||
- **M13 里程碑达成** — Phase 17 rc.1 标签可打(v0.3.0 第二个 Phase)
|
||||
|
||||
---
|
||||
|
||||
#### Phase 18: Agent Switch + SubAgent Dispatch + Agent 间交互
|
||||
|
||||
**目标**:在 SessionManager 基础上,提供 Agent 角色热切换和子代理调度能力。
|
||||
|
||||
**交付物**:
|
||||
1. `engine/switch.rs` — `switch_agent(session_id, new_agent)`:替换 `Arc<dyn Agent>`,slot 历史 / turn_index / session_memory 全保留
|
||||
2. `engine/sub_agent.rs` — SubAgent Dispatch 核心:
|
||||
- `DispatchConfig`:`max_concurrency`(默认 10)/ `inherit_session_memory`(默认 true)/ `bridge_keys`
|
||||
- `dispatch(parent_id, sub_agent, task, config) -> SubTaskResult`:创建子 session → 继承父 SessionMemory → `submit_turn` → 返回结构化结果
|
||||
- `dispatch_stream(parent_id, sub_agent, task, config) -> SubTaskStream`:流式版
|
||||
- `dispatch_all(parent_id, tasks, config) -> Vec<SubTaskResult>`:并行派发,`tokio::sync::Semaphore` 控制并发数
|
||||
3. `SubTaskResult`:`child_id` / `response` / `usage` / `summary` + `child_memory(sm)` 读取子 SessionMemory
|
||||
|
||||
**Agent 间交互三层级**:
|
||||
- 父→子:继承 SessionMemory 快照 + `bridge_keys` 指定 key 强制注入 system prompt
|
||||
- 子→父:`SubTaskResult` 结构化回传 + `SessionMemory["result_summary"]` 结论摘要
|
||||
- 子↔子(间接):通过公共 `MemoryStore` namespace(`shared:{parent_session_id}`)共享数据
|
||||
|
||||
**依赖**:Phase 17(SessionManager + 会话树)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 500 行
|
||||
|
||||
**实际新增**(2026-07-15 commit `46de111`,详见 `docs/24-phase18-agent-switch-and-dispatch.md`):
|
||||
- 方案文档:`docs/24-phase18-agent-switch-and-dispatch.md`(700 行,含 Agent Switch 与 SubAgent Dispatch 的设计推演)
|
||||
- 新增文件 2 个:
|
||||
- `src/engine/switch.rs`(222 行)— `SessionManager::switch_agent()` 热切换:替换 `Arc<dyn Agent>`,slot 历史 / turn_index / session_memory / cost_so_far 全部保留,同步更新 `SessionMeta.agent_name` 到持久层
|
||||
- `src/engine/sub_agent.rs`(1071 行)— SubAgent 调度完整实现:4 个公开方法 + 3 个公开类型
|
||||
- 修改文件 4 个:
|
||||
- `src/engine/mod.rs`(+5 行:`pub mod switch; pub mod sub_agent;` + `pub use sub_agent::{DispatchConfig, SubTaskResult, SubTaskStreamEvent};`)
|
||||
- `src/engine/error.rs`(+1 变体:`DispatchFailed(#[source] String)`)
|
||||
- `src/engine/session_manager.rs`(+2 处可见性:`save_session_meta` / `load_session_meta` 改 `pub(crate)` 供 `switch.rs` 使用)
|
||||
- `src/llm/types/usage.rs`(+`From<Usage>` 实现供 `SubTaskResult.usage` 字段构造)
|
||||
- 新增 4 个示例:
|
||||
- `examples/agent_switch_demo.rs`(115 行)— Agent 热切换演示
|
||||
- `examples/sub_agent_dispatch_demo.rs`(141 行)— dispatch / dispatch_all 并行派发演示
|
||||
- `examples/bridge_keys_demo.rs`(197 行)— bridge_keys 过滤的 SessionMemory 继承演示
|
||||
- `examples/dispatch_stream_demo.rs`(121 行)— dispatch_stream 流式派发演示
|
||||
- 关键设计:
|
||||
- **`switch_agent` 锁契约**:先 `get` session → 锁 `Mutex` 替换 agent 并读取 turn_index → 释放 Mutex → 读/写 `SessionMeta`(无锁 IO),最大限度减少锁竞争
|
||||
- **`SessionMeta` 保留原则**:切换 `agent_name` 字段,但 `created_at` / `parent_id` 保留原始(血缘不可变)
|
||||
- **`switch_agent` 不自动 checkpoint**:与 `auto_checkpoint` 语义一致(仅 `submit_turn` / `finalize_turn` 触发),避免每次角色切换产生冗余 checkpoint
|
||||
- **`inherit_session_memory` 快照语义**:捕获调用时刻的父 session_memory 快照,子 session 写回后即使父被并发写入也不传播(防止非确定性结果)
|
||||
- **`bridge_keys` 三态语义**:`None` = 不继承任何(安全默认)/ `Some(vec![])` = 继承全部 / `Some(keys)` = 仅继承指定 key
|
||||
- **`shared_namespace` 约定式共享**:纯约定字段,不触发自动注入逻辑,子 agent 显式 `session.set_session_data("shared:{prefix}:{key}", value)` 写入
|
||||
- **`dispatch_all` 部分成功语义**:`Vec<Result<SubTaskResult, EngineError>>` 按输入顺序 indexed 收集,task panic 通过 `DispatchFailed` 哨兵占位(不破坏顺序一致性)
|
||||
- **`dispatch_stream` 后台 finalize**:spawn task 内部调 `finalize_turn()` 落库,明确不参与 `auto_checkpoint`(避免与流式 checkpoint 重复)
|
||||
- **`SubTaskStreamEvent` 事件序列**:`ChildCreated { child_id }` → `Stream(StreamEvent) × N` → `Completed(SubTaskResult)` 或 `Error { child_id, error }`
|
||||
- **`SUBTASK_NAMESPACE` 防误注入**:子 session 注入到 SessionManager 时使用 `subtask:` prefix 避免与 SessionMeta 的 `session:{id}:meta` 冲突
|
||||
- 测试:+17 内联测试(4 switch + 5 dispatch + 4 dispatch_all + 4 dispatch_stream),全量 374 → **391 passed / 0 failed**(+17,0 失败)
|
||||
- 质量基线:`cargo test --all-targets` 391 passed / 0 failed;`cargo clippy --all-targets -- -D warnings` 0 警告;`cargo doc --no-deps` 0 warning;4 个示例全部 exit 0
|
||||
- 零新外部依赖(ponytail:与 Phase 17 一致)
|
||||
|
||||
**状态**:✅ Phase 18 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
#### Phase 19: 知识图谱 + 双通道检索
|
||||
|
||||
**目标**:落地 `docs/note-knowledge-graph-design.md` 中记录的知识图谱设计,提供实体-关系图检索能力。扩展 `MemoryRetriever` 为双通道。
|
||||
|
||||
**交付物**:
|
||||
1. `src/memory/graph.rs`(新文件):
|
||||
- `GraphEntity` / `GraphRelation` / `ScoredEntity` 核心类型
|
||||
- `RelationDirection` 枚举(Outgoing / Incoming / Both)
|
||||
- `KnowledgeGraph` trait:`add_entity` / `get_entity` / `remove_entity` / `add_relation` / `remove_relation` / `get_related` / `find_by_keywords` / `find_tags` / `set_entity_tags`
|
||||
- `InMemoryGraph` 实现:`HashMap<String, GraphEntity>` + `Vec<GraphRelation>` + BFS 图遍历
|
||||
- `TagConstraints`(`max_tags_per_entity` 默认 8)
|
||||
2. `src/memory/retriever.rs` 扩展:
|
||||
- `MemoryRetriever` 增加 `knowledge_graph` 可选字段
|
||||
- `RetrievalStrategy` 枚举:`Hybrid`(默认)/ `KnowledgeOnly` / `GraphOnly`
|
||||
|
||||
**与 Document 系统的关系**:知识图谱提供实体级检索("这个实体和什么相关"),VectorStore 提供语义相似度检索("哪些文档最相似"),两者互补。
|
||||
|
||||
**依赖**:MemoryStore 持久化(v0.1 Phase 3)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 400 行(实际约 720 行核心 + 200 行测试)
|
||||
**方案文档**:`docs/25-phase19-knowledge-graph-and-retrieval.md`(652 行,经 PM/SA 双轮审查 PASS)
|
||||
**状态**:✅ Phase 19 全部交付物已完成(2026-07-17)
|
||||
|
||||
**实际新增**(2026-07-17):
|
||||
- 新增文件 2 个:
|
||||
- `src/memory/graph.rs`(~580 行)- `GraphEntity`(id/name/entity_type/description/tags/properties)+ `GraphRelation`(无 id 字段,`composite_key()` 派生)+ `RelationDirection`(`#[derive(Default)]` + `#[default]` Outgoing)+ `ScoredEntity`(含 path 路径)+ `TagConstraints`(max_tags_per_entity 默认 8)+ `KnowledgeGraph` trait(10 个 async 方法)+ `InMemoryGraph`(`Mutex<GraphInner>` 单一锁结构,避免嵌套锁死锁)+ BFS 图遍历(visited 防环 + 权重乘积衰减 + 多路径先到先得 + depth=0 返回空)+ 标签管理(tag_index 反向索引)+ 23 个内联测试
|
||||
- `examples/knowledge_graph_demo.rs`(~140 行)- 端到端演示:构建图谱 -> BFS 遍历 -> 标签管理 -> Hybrid/GraphOnly 双通道检索
|
||||
- 修改文件 3 个:
|
||||
- `src/memory/retriever.rs` - `RetrievalStrategy` 枚举(Hybrid 默认 / KnowledgeOnly / GraphOnly)+ `RetrievalItem` enum(统一列表,`score()` 方法)+ `RetrievalResult` 新增 `strategy` 字段(反映实际执行策略)+ `MemoryRetriever` 双通道(`with_knowledge_graph` / `with_strategy` 链式构造)+ `search_knowledge_store` / `search_graph` 私有方法 + `tokio::join!` 并行 + 旧 `ScoredItem` 标注 `#[deprecated]` + `RetrieverConfig` 新增 `graph_depth`(默认 2)+ 13 个内联测试
|
||||
- `src/memory.rs` - `pub mod graph` + 重导出 7 个图类型 + 更新 retriever 重导出
|
||||
- `examples/knowledge_search_demo.rs` - 适配新 API(`RetrievalItem` enum match + `RetrieverConfig.graph_depth`)
|
||||
- 关键设计:
|
||||
- **`Mutex<GraphInner>` 单一锁结构** - 避免 `set_entity_tags` 嵌套锁死锁风险(审查修复)
|
||||
- **`RetrievalResult.strategy` 反映实际执行策略** - graph 未注入时退化为 `KnowledgeOnly`(审查修复)
|
||||
- **`GraphRelation` 无 id 字段** + `composite_key()` 派生方法
|
||||
- **BFS**:`visited` 防环 + 权重乘积衰减 + 多路径先到先得 + `depth=0` 返回空
|
||||
- **零新外部依赖**(ponytail 风格)
|
||||
- 测试:391 -> **427 passed / 0 failed**(+36 新测试:23 graph + 13 retriever)
|
||||
- 质量基线:`cargo test --all-targets` 427 passed / 0 failed;`cargo clippy --all-targets -- -D warnings` 0 警告;`cargo doc --no-deps` 0 warning;`cargo run --example knowledge_graph_demo` exit 0
|
||||
|
||||
---
|
||||
|
||||
### v0.3.0 Phase 依赖关系图
|
||||
|
||||
```mermaid
|
||||
graph BT
|
||||
P13["<b>Phase 13: 热身清理</b><br/>旧 types 文件删除<br/>ContextSlot fork/merge"]:::done
|
||||
P14["<b>Phase 14: Document + Embedding</b><br/>Document 类型<br/>RecursiveCharacterSplitter<br/>Embedding trait"]:::done
|
||||
P15["<b>Phase 15: 向量存储持久化</b><br/>VectorStore trait<br/>PersistentVectorStore<br/>RagPipeline<br/>19 新测试"]:::done
|
||||
P16["<b>Phase 16: 摘要自动生成</b><br/>SummaryConfig<br/>内联检查点<br/>首次防抖跳过<br/>18 新测试"]:::done
|
||||
P17["<b>Phase 17: 执行引擎</b><br/>SessionManager<br/>会话树<br/>Time-travel Checkpointer<br/>21 新测试"]:::done
|
||||
P18["<b>Phase 18: 切换与调度</b><br/>Agent Switch<br/>SubAgent Dispatch<br/>dispatch_all 并发控制<br/>17 新测试"]:::done
|
||||
P19["<b>Phase 19: 知识图谱</b><br/>KnowledgeGraph trait<br/>InMemoryGraph<br/>双通道检索"]:::done
|
||||
|
||||
P15 --> P14
|
||||
P18 --> P17
|
||||
|
||||
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
|
||||
classDef pending fill:#fbbf24,stroke:#d97706,color:#1a1a1a
|
||||
```
|
||||
|
||||
### 关键里程碑
|
||||
|
||||
| 里程碑 | Phase 完成条件 | 可验证指标 | 状态 |
|
||||
|--------|---------------|-----------|------|
|
||||
| **M9** | Phase 13 | 旧 types 文件删除、`cargo test --all-targets` 全绿、`fork`/`merge` 测试通过 | ✅ 2026-07-08 |
|
||||
| **M10** | Phase 14 | `Document` + `RecursiveCharacterSplitter` 分割结果验证、`MockEmbedding` 测试通过 | ✅ 2026-07-09 |
|
||||
| **M11** | Phase 15 | `PersistentVectorStore` 持久化 roundtrip、`RagPipeline::ingest → retrieve` 端到端验证 | ✅ 2026-07-09 |
|
||||
| **M12** | Phase 16 | 多轮对话后摘要自动写入 SessionMemory、派生 slot 时摘要正确注入 + 第二轮实施审查 PASS | ✅ 2026-07-10 |
|
||||
| **M13** | **Phase 17 (rc.1)** | `SessionManager` 创建/recover/replace/子树/销毁集成测试通过、`Checkpointer` checkpoint/rollback/list_checkpoints 验证(`fork` 推迟,按需时引入)| ✅ 2026-07-15 |
|
||||
| **M14** | Phase 18 | `switch_agent` 热切换验证(slot / turn_index / session_memory 保留)、`dispatch` / `dispatch_all` 并行派发 + Semaphore 顺序、`dispatch_stream` 流事件序列验证 | ✅ 2026-07-15 |
|
||||
| **M15** | Phase 19 | `KnowledgeGraph` 实体-关系 CRUD + `get_related` BFS 验证、双通道检索 Hybrid 策略验证 | ✅ 2026-07-17 |
|
||||
@@ -0,0 +1,476 @@
|
||||
# AG Core Roadmap — v0.3.2
|
||||
|
||||
**状态**:✅ Phase 20-27 全部交付(v0.3.2 交付完毕)
|
||||
|
||||
> 本文件聚焦 **v0.3.2 版本** 的规划与交付(Phase 20–27)。
|
||||
> 返回总入口:[`roadmap.md`](./roadmap.md)
|
||||
|
||||
> **进度更新(2026-07-19)**:v0.3.2 全部交付。Step 1 完成 Phase 20-25(Cargo features 定义 + 依赖 optional 化 + 全模块 cfg 门控);Step 3 完成 Phase 26-27(CI 测试矩阵固化 + LlmProvider trait 归属修正 + examples required-features + 文档更新)。验证矩阵 6 种组合全部通过(427/416/363/369/401/407 passed),clippy 0 警告,18 个 example 单独编译通过。
|
||||
>
|
||||
> **Step 3 实施中的关键调整**(超出原方案的发现):
|
||||
> - `LlmProvider` trait + `ProviderCapabilities` + `ProviderFeatures` 从 `provider.rs` 移至新建的 `provider_trait.rs`,归属 `#[cfg(feature = "llm")]`(ADR-1,纯 Mock 场景不再需要 provider feature)
|
||||
> - `llm` feature 补充 imply `futures-util`(修复 `cycle.rs` 隐式依赖)
|
||||
> - `bundle()` 方法加 `#[cfg(feature = "engine")]` 门控修复 dead_code 警告
|
||||
> - `prompt_composer` / `custom_tool` 的 required-features 需额外 `llm`(`response_v2.rs` 依赖 `LlmError`,预存耦合)
|
||||
> - cargo fmt 全量格式化(修复预存格式问题,CI format job 可通过)
|
||||
>
|
||||
> 实施方案见 [`docs/26-step1-phase20-cargo-features-implementation.md`](./26-step1-phase20-cargo-features-implementation.md) 和 [`docs/27-step3-phase26-ci-verification.md`](./27-step3-phase26-ci-verification.md)。
|
||||
|
||||
## v0.3.2 愿景
|
||||
|
||||
通过 Cargo features 拆分,让下游按需选择模块,跳过不需要的编译单元和重型依赖。
|
||||
|
||||
## v0.3.2 总体范围
|
||||
|
||||
**版本等级**:patch(v0.3.2),`default = ["full"]` 保持向后兼容,非破坏性变更。
|
||||
|
||||
**改造基线**:v0.3.0 已交付 23,718 行 Rust 代码,66 个源文件。当前所有依赖全量编译——引用 agcore 就意味着拉入 rusqlite bundled、reqwest、tokio full 等全部重型依赖。
|
||||
|
||||
**改造目标**:16 个 features(10 模块级 + 5 provider + 1 工具)+ 4 个快捷组合。下游可只选 `chat` 组合跳过 SQLite 和 MCP 的编译,或只选 `document` 实现纯文档分割零外部依赖。
|
||||
|
||||
**工作性质**:纯 cfg 门控 + Cargo.toml 配置变更,不新增功能代码。
|
||||
|
||||
**总体规模**:8 个增量 Phase(Phase 20–27),预计新增/修改约 330 行配置与条件编译代码。
|
||||
|
||||
---
|
||||
|
||||
## 功能清单
|
||||
|
||||
### 模块级 features(10 个)
|
||||
|
||||
| Feature | 覆盖内容 | imply | 外部依赖成本 |
|
||||
|---------|---------|-------|-------------|
|
||||
| `document` | Document + RecursiveCharacterSplitter | — | 无 |
|
||||
| `llm-types` | Message, ToolDef, Usage, ToolChoice 等 IR 类型 | — | 无(只 serde + thiserror) |
|
||||
| `prompt` | PromptTemplate + PromptComposer | `llm-types` | 无 |
|
||||
| `llm` | Provider trait + LlmCycle + hooks + compact + embedding + mock | `llm-types` | tokio, async-stream, futures-core, futures-util, tokio-stream |
|
||||
| `tools` | BaseTool + ToolRegistry | `llm-types` | futures, tokio-util, tokio |
|
||||
| `tools-mcp` | McpClient(Stdio/StreamableHttp) | `tools` | reqwest |
|
||||
| `memory` | MemoryStore(InMemory) + Conversation + VectorStore(InMemory) + KnowledgeGraph + Retriever | `document` + `llm` | tokio, time(继承 llm 的依赖) |
|
||||
| `memory-sqlite` | SqliteStore | `memory` | rusqlite (bundled), time |
|
||||
| `agent` | Agent + Builder + Session + ContextSlot + Summary | `llm` + `tools` + `memory` | 继承下层 |
|
||||
| `engine` | SessionManager + Checkpointer + SubAgent + Switch | `agent` | 继承下层 |
|
||||
|
||||
### Provider features(5 个,各自独立)
|
||||
|
||||
| Feature | imply | 外部依赖 |
|
||||
|---------|-------|---------|
|
||||
| `provider-openai` | `llm` | reqwest + bytes + futures-util |
|
||||
| `provider-anthropic` | `llm` | reqwest + bytes + futures-util |
|
||||
| `provider-deepseek` | `llm` | reqwest |
|
||||
| `provider-qwen` | `llm` | reqwest |
|
||||
| `provider-ollama` | `llm` | reqwest |
|
||||
|
||||
### 工具 features(1 个)
|
||||
|
||||
| Feature | 控制 | 依赖 |
|
||||
|---------|------|------|
|
||||
| `tracing-init` | `init_tracing()` 函数 | tracing-subscriber |
|
||||
|
||||
### 快捷组合(4 个)
|
||||
|
||||
| 组合 | 定义 | 场景 |
|
||||
|------|------|------|
|
||||
| `full`(default) | 全部 16 个 feature | 全栈(兼容 v0.3) |
|
||||
| `light` | llm + provider-openai + tools + tools-mcp + memory + agent + engine + prompt + document | 生产常用 |
|
||||
| `chat` | agent + provider-openai | 纯对话(context+session+轻量记忆,跳过 SQLite;MCP 按需加 `tools-mcp`) |
|
||||
| `multi` | engine + provider-openai | 多 Agent 复合(chat + subagent + switch + checkpointer;MCP 按需加 `tools-mcp`) |
|
||||
|
||||
---
|
||||
|
||||
## 实施计划 — 8 个增量 Phase
|
||||
|
||||
> **编号说明**:Phase 20-27 接续 v0.3.0 的 Phase 13-19。
|
||||
|
||||
### 实施节奏:4 个 Step
|
||||
|
||||
将 8 个 Phase 合并为 4 个实施步骤,平衡变更风险与执行效率。
|
||||
|
||||
| Step | Phase | 内容 | 验证方式 | 预估行数 |
|
||||
|------|-------|------|---------|---------|
|
||||
| **Step 1** ✅ | Phase 20-25 | Cargo.toml features 定义 + 依赖 optional 化 + 全模块 cfg 门控(合并实施) | 14 条编译验证全通过 + `cargo test -F full` 427 passed | ~100 |
|
||||
| **Step 2** | (已合并至 Step 1) | — | — | — |
|
||||
| **Step 3** ✅ | Phase 26 | 测试矩阵验证 + 修复 cfg 遗漏 + LlmProvider trait 归属修正 + examples required-features | 6 种组合全部测试通过 + clippy 0 警告 + 18 个 example 单独编译通过 | ~80 |
|
||||
| **Step 4** ✅ | Phase 27 | README + 示例标注 + 总入口同步 | review 通过 | ~100 |
|
||||
|
||||
**Step 1 单独成步**:Cargo.toml 是基础设施变更,编译通过后打 checkpoint,后续都是纯源文件变更。
|
||||
|
||||
**Step 2 合并 Phase 21–25**:全是 `#[cfg(feature = "...")]` 公式化插门控,按依赖顺序(底层模块 → LLM/Provider → Tools/MCP → Memory → Agent/Engine)实施,每插一个 feature 门控就验证。按子模块分批 commit 控制粒度。
|
||||
|
||||
---
|
||||
|
||||
### Phase 20: Cargo.toml 基础设施改造
|
||||
|
||||
**目标**:定义完整的 [features] 表,重型依赖改为 optional,建立 imply 链。
|
||||
|
||||
| Step | 内容 | 文件范围 | 验证标准 |
|
||||
|------|------|---------|---------|
|
||||
| **20.1** | 定义 16 个 features + 4 个快捷组合,`default = ["full"]` | `Cargo.toml` | `cargo build --features "full"` 编译通过,行为与原版一致 |
|
||||
| **20.2** | tokio / reqwest / rusqlite / tracing-subscriber 改为 optional | `Cargo.toml` | `cargo build --no-default-features` 成功(空 crate) |
|
||||
| **20.3** | tokio-stream / futures / futures-util / futures-core / bytes / async-stream / tokio-util / time 改为 optional | `Cargo.toml` | `cargo build --features "full"` 全量依赖正确拉取 |
|
||||
| **20.4** | tokio features 拆细:从 `["full"]` 改为 `["rt", "sync", "time", "macros", "process", "io-util"]`,仅保留实际使用的子模块 | `Cargo.toml` | `cargo build --features "llm,provider-openai"` 不拉入 tokio net/http 等无关子模块 |
|
||||
| **20.5** | feature imply 链配置:`prompt → llm-types`,`llm → llm-types`,`tools → llm-types`,`memory → document`,`agent → llm + tools + memory`(不含 tools-mcp),`engine → agent` | `Cargo.toml` | `cargo build --features "agent,provider-openai"` transitive 依赖自动拉取 |
|
||||
|
||||
**依赖**:无(Cargo.toml 独立改造)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 40 行
|
||||
**状态**:✅ 已交付(2026-07-19)— features 定义 + 依赖 optional 化 + tokio features 拆细(含 `rt-multi-thread` 修正)
|
||||
|
||||
---
|
||||
|
||||
### Phase 21: 底层模块 cfg 门控注入
|
||||
|
||||
**目标**:为 llm-types、document、prompt 三个零/低外部依赖模块添加条件编译门控。
|
||||
|
||||
| Step | 内容 | 文件范围 | 验证标准 |
|
||||
|------|------|---------|---------|
|
||||
| **21.1** | `src/lib.rs` 中所有 `pub mod` 声明加 `#[cfg(feature = "...")]` | `src/lib.rs` | `cargo build --no-default-features` 无模块引入 |
|
||||
| **21.2** | llm-types 模块条件编译 + 公共类型条件导出 | `src/llm/types/` | `cargo build --no-default-features --features "llm-types"` 编译通过 |
|
||||
| **21.3** | document 模块条件编译 + `pub use Document` 条件导出 | `src/document.rs` | `cargo build --no-default-features --features "document"` 编译通过 |
|
||||
| **21.4** | prompt 模块条件编译 | `src/prompt.rs` | `cargo build --no-default-features --features "prompt"` 编译通过 |
|
||||
|
||||
**依赖**:Phase 20(需 feature 定义就绪)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 30 行
|
||||
**状态**:✅ 已交付(2026-07-19,Step 1 合并)— `src/lib.rs` 全部 `pub mod` + `pub use Document` 门控完成
|
||||
|
||||
---
|
||||
|
||||
### Phase 22: LLM + Provider 门控注入
|
||||
|
||||
**目标**:llm 模块整体门控 + 5 个 Provider 独立条件编译 + cycle.rs 中 ToolRegistry 引用的 `#[cfg]` 隔离。
|
||||
|
||||
| Step | 内容 | 文件范围 | 验证标准 |
|
||||
|------|------|---------|---------|
|
||||
| **22.1** | llm 模块 cfg + embedding 子模块条件导出 + MockProvider 条件编译 | `src/llm.rs` | `cargo build --no-default-features --features "llm"` 编译通过 |
|
||||
| **22.2** | `create_provider()` + `build_client_*` 条件编译,按 feature 分别暴露 | `src/llm/provider.rs` | 各 provider feature 单独启用 |
|
||||
| **22.3** | OpenAI provider `#[cfg(feature = "provider-openai")]` | `src/llm/provider/openai.rs` | `--features "llm,provider-openai"` 编译通过;不含时不编译 |
|
||||
| **22.4** | Anthropic provider 条件编译 | `src/llm/provider/anthropic.rs` | `--features "llm,provider-anthropic"` 编译通过 |
|
||||
| **22.5** | DeepSeek + Qwen 共享 `openai_compat.rs` 用 `any(feature = "provider-deepseek", feature = "provider-qwen")` 条件 | `src/llm/provider/openai_compat.rs` | 各自单独编译通过 |
|
||||
| **22.6** | Ollama provider 条件编译 | `src/llm/provider/ollama.rs` | `--features "llm,provider-ollama"` 编译通过 |
|
||||
| **22.7** | `cycle.rs` 中 ToolRegistry 引用 + `submit_with_tools` 系列方法 `#[cfg(feature = "tools")]` | `src/llm/cycle.rs` | `--features "llm,provider-openai"` 不含 tools 编译通过 |
|
||||
|
||||
**依赖**:Phase 20 + Phase 21
|
||||
**优先级**:P0
|
||||
**预估规模**:约 80 行(中复杂度,cycle.rs 门控需精确隔离)
|
||||
**状态**:✅ 已交付(2026-07-19,Step 1 合并)— `src/llm.rs` 子模块按 llm-types/llm/provider 三类门控;`cycle.rs` 中 `ToolRegistry` import + `submit_with_tools` / `submit_with_tools_stream` / `run_tool_loop` 加 `#[cfg(feature = "tools")]`(Phase 22.7 提前)
|
||||
|
||||
---
|
||||
|
||||
### Phase 23: Tools + MCP 门控注入
|
||||
|
||||
**目标**:tools 模块整体门控 + mcp 子模块条件编译。
|
||||
|
||||
| Step | 内容 | 文件范围 | 验证标准 |
|
||||
|------|------|---------|---------|
|
||||
| **23.1** | tools 模块 cfg + pub use 条件导出 | `src/tools.rs` | `--features "tools"` 编译通过;不含时不编译 |
|
||||
| **23.2** | `mcp.rs` 整个文件 `#[cfg(feature = "tools-mcp")]` | `src/tools/mcp.rs` | `--features "tools"` 不含 mcp 时编译通过;加 `tools-mcp` 时引入 |
|
||||
| **23.3** | ToolRegistry 中 McpClient 引用的条件导出 | `src/tools/registry.rs` | `--features "tools"` 不含 mcp 编译通过 |
|
||||
|
||||
**依赖**:Phase 20 + Phase 21
|
||||
**优先级**:P0
|
||||
**预估规模**:约 20 行
|
||||
**状态**:✅ 已交付(2026-07-19,Step 1 合并)— `src/tools.rs` 中 `pub mod mcp` + `pub use mcp::*` 加 `#[cfg(feature = "tools-mcp")]`
|
||||
|
||||
---
|
||||
|
||||
### Phase 24: Memory 门控注入
|
||||
|
||||
**目标**:memory 模块门控 + vector_store 中 Embedding 引用隔离 + SqliteStore 可选化。
|
||||
|
||||
| Step | 内容 | 文件范围 | 验证标准 |
|
||||
|------|------|---------|---------|
|
||||
| **24.1** | memory 模块 cfg + pub use 条件导出 | `src/memory.rs` | `--features "memory"` imply document 编译通过 |
|
||||
| **24.2** | vector_store 中 Embedding trait 引用 `#[cfg(feature = "llm")]` | `src/memory/vector_store.rs` | `--features "memory"` 不含 `llm` 编译通过 |
|
||||
| **24.3** | `sqlite_store.rs` 整个文件 `#[cfg(feature = "memory-sqlite")]` | `src/memory/store/sqlite_store.rs` | `--features "memory"` 不含 sqlite 编译通过 |
|
||||
| **24.4** | `memory.rs` 中 `pub use SqliteStore` 条件导出 | `src/memory.rs` | `--features "memory-sqlite"` 正确导出 SqliteStore |
|
||||
|
||||
**依赖**:Phase 20 + Phase 21
|
||||
**优先级**:P0
|
||||
**预估规模**:约 30 行
|
||||
**状态**:✅ 已交付(2026-07-19,Step 1 合并)— Phase 24.3(`sqlite_store` 模块门控)+ Phase 24.4(`pub use SqliteStore` 门控)已完成;Phase 24.1(memory 模块 pub use)由 `src/lib.rs` 的 `#[cfg(feature = "memory")]` 覆盖;Phase 24.2(vector_store 中 Embedding 引用隔离)经 Phase 26 验证无需补充——`memory` feature imply `llm`,`Embedding` trait 在 `memory` 启用时一定可用
|
||||
|
||||
---
|
||||
|
||||
### Phase 25: Agent + Engine 门控注入
|
||||
|
||||
**目标**:agent 和 engine 两个高层模块的条件编译门控。注意 agent 不再 imply tools-mcp——MCP 作为可选工具层由用户显式启用。
|
||||
|
||||
| Step | 内容 | 文件范围 | 验证标准 |
|
||||
|------|------|---------|---------|
|
||||
| **25.1** | agent 模块 cfg + pub use 条件导出 | `src/agent.rs` | `--features "agent,provider-openai"` 编译通过 |
|
||||
| **25.2** | engine 模块 cfg + 子模块条件导出(switch / sub_agent / checkpointer) | `src/engine/` | `--features "engine,provider-openai"` 编译通过 |
|
||||
| **25.3** | `lib.rs` 中 agent / engine 模块声明 cfg + 条件重导出 | `src/lib.rs` | 验证 `engine` imply `agent` 链正确,transitive 依赖完整 |
|
||||
|
||||
**依赖**:Phase 20-24(全链路依赖就绪后操作)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 20 行
|
||||
**状态**:✅ 已交付(2026-07-19,Step 1 合并)— Phase 25.3(`src/lib.rs` 中 agent/engine 模块声明 cfg)已完成;Phase 25.1/25.2(agent/engine 内部子模块条件导出)由 `src/lib.rs` 顶层门控覆盖;`src/agent/session.rs` 中 engine 相关 import + `to_snapshot` / `from_snapshot` / `restore_memory` / `has_pending_memory_restore` + `pending_memory_restore` 字段加 `#[cfg(feature = "engine")]`;Phase 26 验证 `bundle()` 方法加 `#[cfg(feature = "engine")]` 门控修复 dead_code 警告
|
||||
|
||||
---
|
||||
|
||||
### Phase 26: 快捷组合验证 + 测试矩阵
|
||||
|
||||
**目标**:验证 4 个快捷组合 + clippy 完整性检查。
|
||||
|
||||
| Step | 内容 | 文件范围 | 验证标准 |
|
||||
|------|------|---------|---------|
|
||||
| **26.1** | `default = ["full"]` 回归验证 | CI | `cargo test --features "full"` 全绿(427 passed) |
|
||||
| **26.2** | light 组合编译 + 单元测试 | CI | `cargo test --no-default-features --features "light"` 通过 |
|
||||
| **26.3** | chat 组合(无 MCP)编译 + 单元测试 | CI | `cargo test --no-default-features --features "chat,provider-openai"` 通过 |
|
||||
| **26.4** | chat + MCP 组合编译 + 单元测试 | CI | `cargo test --no-default-features --features "chat,provider-openai,tools-mcp"` 通过 |
|
||||
| **26.5** | multi 组合(无 MCP)编译 + 单元测试 | CI | `cargo test --no-default-features --features "multi,provider-openai"` 通过 |
|
||||
| **26.6** | multi + MCP 组合编译 + 单元测试 | CI | `cargo test --no-default-features --features "multi,provider-openai,tools-mcp"` 通过 |
|
||||
| **26.7** | clippy `--all-features` 无警告 | CI | `cargo clippy --all-features -- -D warnings` 0 警告 |
|
||||
| **26.8** | 修复各组合编译中发现的 cfg 遗漏 | 全量 | 7 种组合全部编译 + 测试通过 |
|
||||
|
||||
**依赖**:Phase 20-25(所有门控就绪)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 10 行(CI 配置)
|
||||
**状态**:✅ 已交付(2026-07-19)— 6 种 feature 组合测试矩阵 + clippy + format + examples 验证 job 全部通过;`RUSTFLAGS=-D warnings` 强制零警告;LlmProvider trait 归属修正 + bundle() 门控 + llm feature 补充 imply futures-util
|
||||
|
||||
---
|
||||
|
||||
### Phase 27: 文档更新 + 示例标注 + README feature 表
|
||||
|
||||
**目标**:让下游使用者能快速理解 feature 体系并选择合适组合。
|
||||
|
||||
| Step | 内容 | 文件范围 | 验证标准 |
|
||||
|------|------|---------|---------|
|
||||
| **27.1** | README.md 添加 feature 表格 + `Cargo.toml` 使用示例 + 各组合推荐场景 | `README.md` | review 通过 |
|
||||
| **27.2** | 各示例文件顶部添加所需的 feature 组合标注注释 | `examples/*.rs` | review 通过 |
|
||||
| **27.3** | 更新 `docs/roadmap.md` 总入口添加 v0.3.2 链接和简要状态 | `docs/roadmap.md` | review 通过 |
|
||||
|
||||
**依赖**:Phase 20-26
|
||||
**优先级**:P0
|
||||
**预估规模**:约 100 行
|
||||
**状态**:✅ 已交付(2026-07-19)— README 添加 feature 表格 + 快捷组合 + 模块级 features 清单 + 升级指南(LlmProvider 路径迁移);18 个 example 顶部添加 Required features 注释;roadmap 总入口同步
|
||||
|
||||
---
|
||||
|
||||
## Feature 依赖关系图
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "快捷组合"
|
||||
FULL["full (default)"]
|
||||
LIGHT["light"]
|
||||
CHAT["chat"]
|
||||
MULTI["multi"]
|
||||
end
|
||||
|
||||
subgraph "模块级"
|
||||
ENGINE["engine"]
|
||||
AGENT["agent"]
|
||||
LLM["llm"]
|
||||
TOOLS["tools"]
|
||||
TOOLS_MCP["tools-mcp"]
|
||||
MEMORY["memory"]
|
||||
MEMORY_SQLITE["memory-sqlite"]
|
||||
PROMPT["prompt"]
|
||||
LLM_TYPES["llm-types"]
|
||||
DOCUMENT["document"]
|
||||
end
|
||||
|
||||
subgraph "Provider"
|
||||
P_OPENAI["provider-openai"]
|
||||
P_ANTHROPIC["provider-anthropic"]
|
||||
P_DEEPSEEK["provider-deepseek"]
|
||||
P_QWEN["provider-qwen"]
|
||||
P_OLLAMA["provider-ollama"]
|
||||
end
|
||||
|
||||
FULL --> LIGHT & CHAT & MULTI
|
||||
ENGINE --> AGENT
|
||||
AGENT --> LLM & TOOLS & MEMORY
|
||||
CHAT -.-> TOOLS_MCP
|
||||
MULTI -.-> TOOLS_MCP
|
||||
MEMORY_SQLITE --> MEMORY
|
||||
TOOLS_MCP --> TOOLS
|
||||
MEMORY --> DOCUMENT
|
||||
LLM --> LLM_TYPES
|
||||
TOOLS --> LLM_TYPES
|
||||
PROMPT --> LLM_TYPES
|
||||
P_OPENAI --> LLM
|
||||
P_ANTHROPIC --> LLM
|
||||
P_DEEPSEEK --> LLM
|
||||
P_QWEN --> LLM
|
||||
P_OLLAMA --> LLM
|
||||
|
||||
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
|
||||
classDef pending fill:#fbbf24,stroke:#d97706,color:#1a1a1a
|
||||
classDef provider fill:#93c5fd,stroke:#2563eb,color:#1a1a1a
|
||||
class P_OPENAI,P_ANTHROPIC,P_DEEPSEEK,P_QWEN,P_OLLAMA provider
|
||||
class FULL,ENGINE,AGENT,LLM,TOOLS,TOOLS_MCP,MEMORY,MEMORY_SQLITE,PROMPT,LLM_TYPES,DOCUMENT,LIGHT,CHAT,MULTI done
|
||||
```
|
||||
|
||||
## 关键里程碑
|
||||
|
||||
| 里程碑 | Phase 完成条件 | 可验证指标 | 状态 |
|
||||
|--------|---------------|-----------|------|
|
||||
| **M16** | Phase 20 | `cargo build --no-default-features` 成功;`cargo build --features "full"` 与原行为一致 | ✅ 2026-07-19 |
|
||||
| **M17** | Phase 21 | 三种零依赖模块各自独立编译通过 | ✅ 2026-07-19(Step 1 合并) |
|
||||
| **M18** | Phase 22 | 5 个 provider 各自单独编译;cycle.rs 无 tools 时编译通过 | ✅ 2026-07-19(Step 1 合并) |
|
||||
| **M19** | Phase 23 | tools 不含 mcp 编译通过;加 tools-mcp 引入 McpClient | ✅ 2026-07-19(Step 1 合并) |
|
||||
| **M20** | Phase 24 | memory imply document+llm 编译通过;不含 sqlite 编译通过;加 memory-sqlite 引入 SqliteStore | ✅ 2026-07-19(Step 1 合并) |
|
||||
| **M21** | Phase 25 | agent + engine 全链路门控编译通过 | ✅ 2026-07-19(Step 1 合并) |
|
||||
| **M22** | Phase 26 | 7 种 CI 组合全部编译 + 测试通过;clippy --all-features 0 警告 | ✅ 2026-07-19 |
|
||||
| **M23** | Phase 27 | 文档 review 通过 | ✅ 2026-07-19 |
|
||||
|
||||
## Cargo.toml [features] 草案
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
# 轻量核心依赖(始终编译)
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
async-trait = "0.1"
|
||||
tracing = "0.1"
|
||||
|
||||
# 按 feature 可选的重依赖
|
||||
tokio = { version = "1", features = ["rt", "sync", "time", "macros", "process", "io-util"], optional = true }
|
||||
reqwest = { version = "0.12", features = ["json", "stream"], optional = true }
|
||||
rusqlite = { version = "0.32", features = ["bundled"], optional = true }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||
tokio-stream = { version = "0.1", optional = true }
|
||||
futures = { version = "0.3", optional = true }
|
||||
futures-util = { version = "0.3", optional = true }
|
||||
futures-core = { version = "0.3", optional = true }
|
||||
bytes = { version = "1", optional = true }
|
||||
async-stream = { version = "0.3", optional = true }
|
||||
tokio-util = { version = "0.7", features = ["rt"], optional = true }
|
||||
time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"], optional = true }
|
||||
```
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = ["full"]
|
||||
|
||||
# === 模块级 features ===
|
||||
document = []
|
||||
llm-types = []
|
||||
prompt = ["llm-types"]
|
||||
llm = ["llm-types", "tokio", "async-stream", "futures-core", "futures-util", "tokio-stream"]
|
||||
tools = ["llm-types", "futures", "tokio-util", "tokio"]
|
||||
tools-mcp = ["tools", "reqwest"]
|
||||
memory = ["document", "llm", "tokio", "time"]
|
||||
memory-sqlite = ["memory", "rusqlite", "time"]
|
||||
agent = ["llm", "tools", "memory", "futures-util"]
|
||||
engine = ["agent"]
|
||||
|
||||
# === Provider features ===
|
||||
provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
|
||||
provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
|
||||
provider-deepseek = ["llm", "reqwest"]
|
||||
provider-qwen = ["llm", "reqwest"]
|
||||
provider-ollama = ["llm", "reqwest"]
|
||||
|
||||
# === 工具 features ===
|
||||
tracing-init = ["tracing-subscriber"]
|
||||
|
||||
# === 快捷组合 ===
|
||||
full = [
|
||||
"document", "llm-types", "prompt", "llm",
|
||||
"tools", "tools-mcp",
|
||||
"memory", "memory-sqlite",
|
||||
"agent", "engine",
|
||||
"provider-openai", "provider-anthropic", "provider-deepseek",
|
||||
"provider-qwen", "provider-ollama",
|
||||
"tracing-init",
|
||||
]
|
||||
light = [
|
||||
"llm", "provider-openai", "tools", "tools-mcp",
|
||||
"memory", "agent", "engine",
|
||||
"prompt", "document",
|
||||
]
|
||||
chat = ["agent", "provider-openai"]
|
||||
multi = ["engine", "provider-openai"]
|
||||
```
|
||||
|
||||
### 依赖 optional 化对照
|
||||
|
||||
| 依赖 | 启用者 | 当前声明 |
|
||||
|------|--------|---------|
|
||||
| `tokio`(features = `rt, rt-multi-thread, sync, time, macros, process, io-util`) | llm, tools, memory | `optional = true` |
|
||||
| `reqwest`(features = ["json", "stream"]) | provider-*, tools-mcp | `optional = true` |
|
||||
| `rusqlite`(features = ["bundled"]) | memory-sqlite | `optional = true` |
|
||||
| `tracing-subscriber`(features = ["env-filter"]) | tracing-init | `optional = true` |
|
||||
| `tokio-stream` | llm | `optional = true` |
|
||||
| `futures` | tools | `optional = true` |
|
||||
| `futures-util` | llm, provider-*, agent | `optional = true` |
|
||||
| `futures-core` | llm | `optional = true` |
|
||||
| `bytes` | provider-openai, provider-anthropic | `optional = true` |
|
||||
| `async-stream` | llm | `optional = true` |
|
||||
| `tokio-util`(features = ["rt"]) | tools | `optional = true` |
|
||||
| `time`(features = ["serde","parsing","formatting","macros"]) | memory, memory-sqlite | `optional = true` |
|
||||
|
||||
**始终编译**(轻量依赖,不参与 feature 门控):`serde`、`serde_json`、`thiserror`、`async-trait`、`tracing`
|
||||
|
||||
### CI 测试矩阵(已实施)
|
||||
|
||||
```yaml
|
||||
# .github/workflows/ci.yml
|
||||
name: CI
|
||||
on: [push, pull_request]
|
||||
env:
|
||||
RUSTFLAGS: "-D warnings"
|
||||
jobs:
|
||||
test-matrix:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
features:
|
||||
- "full"
|
||||
- "light"
|
||||
- "chat,provider-openai"
|
||||
- "chat,provider-openai,tools-mcp"
|
||||
- "multi,provider-openai"
|
||||
- "multi,provider-openai,tools-mcp"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
- run: cargo test --no-default-features --features "${{ matrix.features }}" --lib
|
||||
clippy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
- run: cargo clippy --all-features --lib -- -D warnings
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- run: cargo fmt --check
|
||||
examples:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
- run: cargo test --features "full"
|
||||
```
|
||||
|
||||
**关键设计决策**:
|
||||
- 矩阵使用 `--lib` 避免 examples 编译干扰模块测试验证
|
||||
- `RUSTFLAGS=-D warnings` 强制零警告
|
||||
- format job 使用 stable toolchain(`cargo fmt --check` 无需 nightly)
|
||||
- 独立 `examples` job 验证所有 example 在完整 features 下编译
|
||||
- 每个 job 设置 `timeout-minutes` 兜底
|
||||
|
||||
---
|
||||
|
||||
返回总入口:[`roadmap.md`](./roadmap.md)
|
||||
@@ -0,0 +1,233 @@
|
||||
# AG Core Roadmap — v0.4.0
|
||||
|
||||
> 本文件聚焦 **v0.4.0 版本** 的规划。Phase A-E 计划中,覆盖多 Agent 编排、Human-in-the-loop 与 Steering、语义压缩、自动校正。
|
||||
> 返回总入口:[`roadmap.md`](./roadmap.md)
|
||||
|
||||
## v0.4.0 愿景
|
||||
|
||||
从 v0.3 的"多 Agent 基础系统"升级为"多 Agent 多职责编排系统"。补齐高层编排抽象(Swarm/Supervisor/Subgraph)、生产级人工干预能力(HITL + Steering)、工具与消息的语义压缩(TokenJuice),以及自动质量校正(Reflection)。为即将开发的多 Agent 协作产品提供完整的编排、干预与质量保证层。
|
||||
|
||||
## v0.4.0 总体范围
|
||||
|
||||
**总体规模**:5 个增量 Phase(Phase A-E),总新增代码约 1,950 行,零强制新外部依赖,零破坏性变更。
|
||||
|
||||
### 架构决策
|
||||
|
||||
**路线选择**:采用轻量编排模式(路线 A),不引入通用有向图引擎。通过 `Swarm::star()` / `Swarm::sequential()` / `Swarm::hierarchical()` 等具名模式提供编排能力,底层复用现有 `dispatch` / `create_child` / `SessionManager` 基础设施。预留路线 B(StateGraph 抽象)作为未来版本的升级路径。
|
||||
|
||||
**模块位置**:
|
||||
- 编排逻辑 → `src/engine/supervisor.rs`(新增)
|
||||
- 内建工具 → `src/tools/builtin.rs`(新增)
|
||||
- TokenJuice 压缩 → `src/llm/compress.rs`(新增)
|
||||
- Steering 机制 → `src/engine/steer.rs`(新增,或并入 supervisor.rs)
|
||||
|
||||
### 功能清单
|
||||
|
||||
#### P0 — 必须交付
|
||||
|
||||
| # | 功能 | 模块 | 方案要点 |
|
||||
|---|------|------|---------|
|
||||
| 1 | Swarm 编排(Star/Sequential/Hierarchical + Subgraph) | `engine/supervisor` | `Swarm::star().supervisor(A).worker(B)` 声明式 API;`Swarm::sequential().link(A).link(B)` 串联;`Swarm::hierarchical().supervisor(root).group("sub", ...)` 层次嵌套 |
|
||||
| 2 | 结果聚合 | `engine/supervisor` | `aggregation_prompt` 模板将子 Agent 结果合并到 Supervisor 上下文;`DispatchConfig` 扩展 `result_key` 字段 |
|
||||
| 3 | Human-in-the-loop 审批 | `engine/steer` | `interrupt()` 暂停执行 + `Command(resume=bool)` 恢复;`HookEvent::OnInterrupt` 新变体 |
|
||||
| 4 | 用户 Steering(运行中校正) | `engine/steer` | `Command(resume=Correction{...})` 结构化校正;Steer 消息在工具批处理边界注入 |
|
||||
| 5 | TokenJuice 语义压缩 | `llm/compress` | `Compressor` trait 统一抽象;覆盖工具结果、对话历史、跨 Agent 消息三层;LLM 摘要压缩 + 确定性兜底 |
|
||||
|
||||
#### P1 — 推荐交付
|
||||
|
||||
| # | 功能 | 模块 | 方案要点 |
|
||||
|---|------|------|---------|
|
||||
| 6 | 自动校正 / Reflection | `engine/reflect` | Evaluator-Optimizer 循环;Producer-Critic 角色分离;上限 2-3 轮迭代 |
|
||||
|
||||
### 实施计划 — 5 个增量 Phase
|
||||
|
||||
> **编号说明**:Phase A-E 为 v0.4.0 专属编号,接续已完成的 Phase 30。
|
||||
|
||||
---
|
||||
|
||||
#### Phase A: Swarm 编排抽象(Star / Sequential / Hierarchical + Subgraph)
|
||||
|
||||
**目标**:在现有 `dispatch` 原语基础上,提供声明式多 Agent 编排 API。Supervisor 作为 `Arc<dyn Agent>`,通过内建工具 `dispatch_sub_agent` 驱动子 Agent 执行。
|
||||
|
||||
**交付物**:
|
||||
1. `src/engine/supervisor.rs` 新文件:
|
||||
- `Swarm` 枚举/结构体:`Swarm::star()`(星型,一个 Supervisor + N 个 Worker)、`Swarm::sequential()`(顺序链 A→B→C)、`Swarm::hierarchical()`(层次嵌套,Supervisor 下的 Sub-Supervisor)
|
||||
- 各模式的 `build()` 和 `run(input)` 方法
|
||||
- 底层通过 `SessionManager::dispatch()` / `dispatch_all()` 实现
|
||||
2. `src/tools/builtin.rs` 新文件:
|
||||
- `dispatch_sub_agent(name, task, config)` 内建工具 — 从 Agent 注册表查找 Agent 工厂 → `SessionManager::dispatch()`
|
||||
3. `AgentRegistry`:`HashMap<String, Box<dyn Fn() -> Arc<dyn Agent>>>` 轻量工厂注册表(约 50 行)
|
||||
4. Subgraph 嵌套:`Swarm::hierarchical()` 支持 `group(name, inner_swarm)`,内层 Swarm 作为子节点编译后嵌入
|
||||
|
||||
**设计要点**:
|
||||
- Supervisor 就是 `Arc<dyn Agent>`,不新增 `SupervisorAgent` trait
|
||||
- 路由逻辑写在 Supervisor 的 system prompt 中(LLM 决定的动态路由)
|
||||
- 三种模式覆盖常见编排拓扑,不引入通用图引擎(路线 B 留作未来)
|
||||
- Subgraph 编译为独立的 `SessionManager` 子树(复用 `create_child` 的父子关系)
|
||||
|
||||
**依赖**:Phase 18(SubAgent dispatch / SessionManager)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 500 行
|
||||
**状态**:📋 待实施
|
||||
|
||||
---
|
||||
|
||||
#### Phase B: 结果聚合 + 编排模式完善
|
||||
|
||||
**目标**:让 Supervisor 能智能地合并 Worker 结果。完善三种编排模式的容错性和易用性。
|
||||
|
||||
**交付物**:
|
||||
1. `aggregation_prompt` 模板系统 — 内建 `DEFAULT_AGGREGATION_PROMPT`,用户可自定义聚合逻辑
|
||||
2. `DispatchConfig` 扩展:
|
||||
- `result_key: Option<String>` — 将子结果存入 `session_memory` 的指定 key,供后续阶段使用
|
||||
- `aggregate_strategy: AggregateStrategy` — `Concatenate` / `Summarize` / `Custom(Value)`
|
||||
3. 编排模式增强:
|
||||
- `Swarm::sequential()` 支持失败时停止 / 跳过 / 重试策略
|
||||
- `Swarm::star()` 支持 Worker 超时
|
||||
4. 端到端示例 3 个:
|
||||
- `swarm_star_demo.rs` — 星型编排 + 并发派发 + 结果聚合
|
||||
- `swarm_sequential_demo.rs` — 串联流水线
|
||||
- `swarm_hierarchical_demo.rs` — 层次嵌套(Supervisor → Sub-Supervisor → Worker)
|
||||
|
||||
**依赖**:Phase A
|
||||
**优先级**:P0
|
||||
**预估规模**:约 200 行
|
||||
**状态**:📋 待实施
|
||||
|
||||
---
|
||||
|
||||
#### Phase C: Human-in-the-loop + 用户 Steering
|
||||
|
||||
**目标**:生产级多 Agent 系统的关键门禁。提供执行中暂停-审批-恢复机制,以及用户运行中校正方向的能力。
|
||||
|
||||
**交付物**:
|
||||
1. `src/engine/steer.rs` 新文件:
|
||||
- `interrupt(value)` 函数 — 在工具循环中插入暂停点,持久化当前状态后返回控制权
|
||||
- `Command` 枚举:
|
||||
- `Command::Resume(bool)` — 二元审批(批准/拒绝)
|
||||
- `Command::ResumeWith(Correction)` — 结构化校正(修改工具参数 / 调整方向)
|
||||
2. `LlmCycle` 扩展:可中断工具循环模式
|
||||
- `submit_with_tools_interruptible()` — 支持在工具批处理边界检查中断信号
|
||||
- 中断时保存当前 `LlmCycle` 状态到 checkpoint
|
||||
3. `HookEvent::OnInterrupt` / `OnSteer` 新变体 — 监听中断和校正事件
|
||||
4. `SessionManager::resume_turn(session_id, resume_data)` — 从 checkpoint 恢复并注入审批结果
|
||||
5. `tools/builtin.rs` 扩展:
|
||||
- `request_approval(question, context)` — 请求用户审批
|
||||
- `emit_steer(correction)` — 用户校正
|
||||
6. Steering 生命周期:
|
||||
- `interrupt` → 用户收到提示 → 用户决定方向 → `Command::ResumeWith(correction)` → Agent 在新方向上继续
|
||||
|
||||
**设计要点**:
|
||||
- User Steering 不是简单的"批准/拒绝",而是 `Correction { action, reason, amended_params }` 结构化指令
|
||||
- Steering 消息在工具批处理边界(Worker 返回后、Supervisor 决策前)注入,不中断正在执行的工具
|
||||
- 继承 `ContextSlot::fork/merge` 模式,steer 前 fork 快照,允许用户回退到 steer 前状态
|
||||
|
||||
**依赖**:Phase A(Swarm 编排)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 500 行
|
||||
**状态**:📋 待实施
|
||||
|
||||
---
|
||||
|
||||
#### Phase D: TokenJuice 语义压缩
|
||||
|
||||
**目标**:替代当前字节级截断(`microcompact` 的 `[pruned]`),提供语义级别的压缩。在三层管道中接入:工具结果压缩、对话历史压缩、跨 Agent 消息压缩。
|
||||
|
||||
**交付物**:
|
||||
1. `src/llm/compress.rs` 新文件:
|
||||
- `Compressor` trait(`async fn compress(&self, input: &str, ctx: &CompressionContext) -> Result<String>`)
|
||||
- `CompressionContext`:`target_tokens` / `preserve_keys` / `strategy`
|
||||
- `CompressionStrategy` 枚举:`Semantic { model }`(LLM 摘要)、`Extractive { ratio }`(抽取式)、`Hybrid { semantic_first }`(混合)
|
||||
- `SemanticCompressor` 实现(复用已有 provider 做 LLM 摘要压缩)
|
||||
- `ExtractiveCompressor` 实现(确定性关键句提取,零 LLM 调用)
|
||||
2. 三层接入点:
|
||||
- **工具结果压缩**:在 `run_tool_loop` 中,`tool.execute()` 后插入 `compress_result()`,压缩结果再 `push ToolResult`
|
||||
- **对话历史压缩**:在 `load_messages()` 后插入 `compress_history()`,替代/补充 `microcompact`
|
||||
- **跨 Agent 消息压缩**:在 `inherit_session_memory` 的子 memory 写入前压缩(减少子 Agent 的 context 水位)
|
||||
3. `CycleConfig` / `CompactConfig` 扩展:
|
||||
- `token_compression: Option<CompressionConfig>` — 可选语义压缩配置
|
||||
- `fallback_to_microcompact: bool`(默认 `true`)— LLM 压缩失败时退化为字节截断
|
||||
4. TokenJuice 与现有 `microcompact` 的关系:
|
||||
- `microcompact` 保留为最轻量级兜底(零 LLM 调用)
|
||||
- TokenJuice 是可选增强层(默认关闭,用户 opt-in)
|
||||
|
||||
**设计要点**:
|
||||
- 零新外部依赖:LLM 摘要压缩复用已有 provider,抽取式压缩纯 Rust 实现
|
||||
- 与现有 `CompactState` 断路器模式兼容(LLM 压缩失败 3 次后自动降级到 `microcompact`)
|
||||
- `preserve_keys` 确保关键数据(数字、ID、SQL、代码片段)不被压缩掉
|
||||
|
||||
**依赖**:Phase 14(Embedding trait 可选参考)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 400 行
|
||||
**状态**:📋 待实施
|
||||
|
||||
---
|
||||
|
||||
#### Phase E: 自动校正 / Reflection
|
||||
|
||||
**目标**:实现 Agent 输出后的自我质量评估与自动修正循环。基于 `interrupt/resume` 基础设施,构建 Producer-Critic 闭环。
|
||||
|
||||
**交付物**:
|
||||
1. `src/engine/reflect.rs` 新文件:
|
||||
- `ReflectionConfig`:`max_cycles`(默认 2)/ `critic_agent`(可选不同模型)/ `criteria: Vec<String>`(评估标准)
|
||||
- `Reflectable` trait:`fn reflection_criteria(&self) -> Vec<String>` + `fn needs_refinement(&self, critique: &Critique) -> bool`
|
||||
- `ReflectionLoop`:`evaluate(output) → Critique` → `should_refine? → yes: refine(output, critique) → 循环 / no: 返回`
|
||||
2. Swarm 内建 Reflection 模式:
|
||||
- `Swarm::reflect(producer_agent, critic_agent)` — 专用 Reflection Swarm
|
||||
- 可在 Supervisor 流程中嵌入 `reflect_on(worker_result)` — 对 Worker 结果自动过一遍质量检查
|
||||
3. `Critique` 结构体:`issues: Vec<Issue>` / `score: f32` / `should_refine: bool` / `suggestions: Vec<String>`
|
||||
4. `tools/builtin.rs` 扩展:`verify_output(claim, evidence)` 工具 — 让 Agent 自行验证输出真实性
|
||||
|
||||
**设计要点**:
|
||||
- Producer 和 Critic 使用**不同模型**(避免同一模型的自我审查盲区 bias)
|
||||
- 上限 2-3 轮(第一轮修正捕获 70–80% 改善空间,第 4+ 轮收益递减)
|
||||
- 基于已有 `HookEvent::OnTurnEnd` 或扩展 `HookEvent::OnOutputGenerated` 触发反思
|
||||
- 失败静默:Reflection 失败不阻断主流程(`tracing::warn!` 后继续交付原始输出)
|
||||
|
||||
**依赖**:Phase C(interrupt/resume 基础设施)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 350 行
|
||||
**状态**:📋 待实施
|
||||
|
||||
---
|
||||
|
||||
### v0.4.0 Phase 依赖关系图
|
||||
|
||||
```mermaid
|
||||
graph BT
|
||||
PA["<b>Phase A: Swarm 编排</b><br/>Swarm::star/sequential/hierarchical<br/>Subgraph 嵌套<br/>内建 dispatch_sub_agent 工具<br/>~500 行"]:::pending
|
||||
PB["<b>Phase B: 结果聚合</b><br/>aggregation_prompt 模板<br/>DispatchConfig result_key<br/>编排模式完善<br/>3 个端到端示例<br/>~200 行"]:::pending
|
||||
PC["<b>Phase C: HITL + Steering</b><br/>interrupt/resume<br/>Command(ResumeWith Correction)<br/>HookEvent::OnInterrupt<br/>~500 行"]:::pending
|
||||
PD["<b>Phase D: TokenJuice</b><br/>Compressor trait<br/>工具结果/历史/跨 Agent 压缩<br/>Semantic + Extractive 策略<br/>~400 行"]:::pending
|
||||
PE["<b>Phase E: 自动校正</b><br/>ReflectionLoop<br/>Producer-Critic<br/>上限 2-3 轮<br/>~350 行"]:::pending
|
||||
|
||||
PB --> PA
|
||||
PC --> PA
|
||||
PE --> PC
|
||||
|
||||
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
|
||||
classDef pending fill:#fbbf24,stroke:#d97706,color:#1a1a1a
|
||||
classDef future fill:#94a3b8,stroke:#64748b,color:#1a1a1a
|
||||
```
|
||||
|
||||
### 关键里程碑
|
||||
|
||||
| 里程碑 | Phase 完成条件 | 可验证指标 | 状态 |
|
||||
|--------|---------------|-----------|------|
|
||||
| **M16** | Phase A | `Swarm::star().supervisor(A).worker(B).run(input)` 端到端验证;`dispatch_sub_agent` 内建工具注册并可用;2 个示例 exit 0 | 📋 待启动 |
|
||||
| **M17** | Phase B | `Swarm::sequential()` 串联执行验证;`Swarm::hierarchical()` 层次嵌套验证;结果聚合正确合并;3 个新示例 exit 0 | 📋 待启动 |
|
||||
| **M18** | Phase C | `interrupt()` 暂停 + `Command::Resume(bool)` 恢复全链路验证;`Command::ResumeWith(Correction)` 结构化校正验证;HookEvent 触发验证 | 📋 待启动 |
|
||||
| **M19** | Phase D | 工具结果经语义压缩后保留关键信息(验证压缩比 ≥ 3:1);`microcompact` 降级路径验证;对话历史压缩验证 | 📋 待启动 |
|
||||
| **M20** | Phase E | ReflectionLoop 正确性验证:已知缺陷的输出被修复、无缺陷的输出不被修改(不变性保证);2 轮迭代上限验证;Critic 不同模型配置验证 | 📋 待启动 |
|
||||
|
||||
### 不做(v0.5+)
|
||||
|
||||
| 功能 | 原因 |
|
||||
|------|------|
|
||||
| Agent 自动创生(LLM 驱动动态分派) | 设计复杂且不确定性高,v0.4 专注显式声明式编排 |
|
||||
| 分布式 Session 共享(Redis 后端) | 与编排正交,大多数用户单进程即可 |
|
||||
| 精确 tokenizer 计数(tiktoken-rs) | 依赖引入,v0.4 专注编排与压缩能力本身 |
|
||||
| 增量 Checkpoint | 存储优化,当前全量 JSON 够用 |
|
||||
| 路线 B(StateGraph 通用图引擎) | 当前编排需求在路线 A 范围内,图引擎留给未来版本 |
|
||||
| RL 轨迹导出 | 专项需求,非通用 |
|
||||
| Markdown 技能按需加载 | 独立功能 |
|
||||
@@ -0,0 +1,24 @@
|
||||
# AG Core Roadmap
|
||||
|
||||
> 拆分式 roadmap:按版本归档 + 未归类内容
|
||||
> 最后更新:2026-07-21(v0.4.0 规划完成 — Phase A-E 多 Agent 编排路线图制定)
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 范围 | 状态 |
|
||||
|------|------|------|
|
||||
| [`roadmap-v0.1.0.md`](./roadmap-v0.1.0.md) | v0.1.0 计划与交付 — Phase 0–4c + v0.1.0 Release | ✅ 已发布 2026-07-04 |
|
||||
| [`roadmap-v0.2.0.md`](./roadmap-v0.2.0.md) | v0.2.0 计划与交付 — Phase 5–12 + v0.2.0-rc.1 | 🟡 Phase 5-11 已完成;Phase 12 P2 锦上添花可选 |
|
||||
| [`roadmap-v0.3.0.md`](./roadmap-v0.3.0.md) | v0.3.0 计划与交付 - Phase 13–19 | ✅ Phase 13-19 全部完成,v0.3.0 交付完毕 |
|
||||
| [`roadmap-v0.3.2.md`](./roadmap-v0.3.2.md) | v0.3.2 计划与交付 — Phase 20–27(Cargo features 拆分) | ✅ Phase 20-27 全部完成,v0.3.2 交付完毕 |
|
||||
| [`28-phase28-openai-response-api-provider.md`](./28-phase28-openai-response-api-provider.md) | Phase 28-30 OpenAI Response API Provider 实施方案(独立 feature `provider-openai-response`) | ✅ Phase 28-30 已交付 |
|
||||
| [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md) | v0.4.0 计划 — Phase A-E(Swarm 编排、HITL + Steering、TokenJuice 语义压缩、自动校正) | 📋 计划中 |
|
||||
| [`roadmap-unsorted.md`](./roadmap-unsorted.md) | 未归到任何版本的内容 — 全局愿景、当前状态、模块完整性、v0.4+ 展望、风险与建议、下一步行动、阶段总回顾 | — |
|
||||
|
||||
## 阅读建议
|
||||
|
||||
- **按版本顺序追溯历史**:v0.1.0 → v0.2.0 → v0.3.0 → v0.4.0
|
||||
- **了解产品演进全貌**:从 `roadmap-unsorted.md` 顶部开始读
|
||||
- **查找特定 Phase**:每个版本文件内按 Phase 编号顺序排列
|
||||
- **了解项目当前关注点**:从 `roadmap-unsorted.md` 的「下一步行动」开始
|
||||
- **未来规划视野**:从 `roadmap-unsorted.md` 的「v0.4+ 展望」开始
|
||||
-945
@@ -1,945 +0,0 @@
|
||||
# AG Core Roadmap
|
||||
|
||||
> 定稿日期:2026-05-11
|
||||
> 最后更新:2026-07-09(Phase 14 完成 + M10 里程碑达成)
|
||||
|
||||
## 愿景
|
||||
|
||||
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
|
||||
|
||||
**当前状态**:v0.2.0-rc.1 已打标签。Phase 0-14 全部完成。v0.3.0 实施中,Phase 15-19 共 5 个增量 Phase 待交付。目标是从"LLM 调用工具箱"升级为"能构建多 Agent 协作、RAG、长记忆 Agent 产品的基础系统"。
|
||||
|
||||
---
|
||||
|
||||
## 模块完整性评估
|
||||
|
||||
| 功能领域 | 方案状态 | 文档位置 | 实现优先级 |
|
||||
|---------|---------|---------|-----------|
|
||||
| LLM 调用周期 | ✅ 完整 | `specs/llm-call-lifecycle.md` | P0 |
|
||||
| 提示词工程 | ✅ 完整 | `docs/4-prompt-engineering.md` | P1 |
|
||||
| 工具系统 + 权限 | ✅ 完整 | `docs/5-tool-system.md` | P1 |
|
||||
| 记忆检索 | ✅ 完整 | `docs/6-memory-system.md` | P2 |
|
||||
| Agent 运行时(4a 胶水层) | ✅ 已实现 | `docs/7-agent-runtime.md` | P2 |
|
||||
| 生命周期钩子 | ✅ 完整 | `docs/3-phase0-remaining.md` | P0(LLM Cycle 扩展) |
|
||||
| Provider 注册发现 | ✅ 完整 | `docs/3-phase0-remaining.md` | P0(Provider 接口扩展) |
|
||||
| 流式事件系统 | ✅ 完整 | `docs/3-phase0-remaining.md` | P0(流式接口前置) |
|
||||
|
||||
---
|
||||
|
||||
## 分阶段 Roadmap
|
||||
|
||||
### Phase 0 — Foundation(基础设施)
|
||||
|
||||
**目标**:实现 LLM 调用周期的核心功能,作为所有上层模块的基础。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `llm/types.rs` — 核心数据类型(Message, ContentBlock, ChatRequest/Response, ToolDefinition, StopReason)
|
||||
2. ✅ `llm/error.rs` — 错误体系(LlmError 枚举,可重试/不可重试判断)
|
||||
3. ✅ `llm/provider.rs` + `llm/provider/openai.rs` — Provider 接口 + OpenAI 兼容实现
|
||||
4. ✅ `llm/provider/registry.rs` — ProviderRegistry(多 Provider 注册发现)
|
||||
5. ✅ `llm/cycle.rs` + `llm/cycle/{retry,usage}.rs` — 生命周期引擎(重试策略 + 用量追踪)
|
||||
6. ✅ `llm/hooks.rs` — HookExecutor 接口(生命周期钩子)
|
||||
7. ✅ `llm/stream.rs` — StreamEvents 流式事件系统(AssistantTextDelta, ToolExecutionStarted 等)
|
||||
8. ✅ `llm/compact.rs` — Auto-compaction(上下文自动压缩)
|
||||
9. ✅ `Cargo.toml` — 添加依赖(tokio, reqwest, serde, thiserror, async-trait, tracing)
|
||||
|
||||
**依赖**:无
|
||||
|
||||
**优先级**:Must Have
|
||||
|
||||
**预估规模**:约 1000 行核心代码
|
||||
|
||||
**状态**:✅ Phase 0 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 — Prompt Engineering(提示词工程)
|
||||
|
||||
**目标**:提供提示词的组合、模板化与优化能力。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `prompt.rs` + `prompt/` 模块
|
||||
2. ✅ `PromptTemplate` — 模板引擎(支持变量插值、条件渲染)
|
||||
3. ✅ `PromptComposer` — 提示词组合器(拼接 system/user/assistant 消息)
|
||||
4. ✅ `docs/4-prompt-engineering.md` — 方案文档
|
||||
|
||||
**依赖**:无(可与 Phase 0 并行)
|
||||
|
||||
**优先级**:Should Have
|
||||
|
||||
**预估规模**:约 400 行代码
|
||||
|
||||
**状态**:✅ Phase 1 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Tool System(工具系统)
|
||||
|
||||
**目标**:实现 MCP 协议集成与自定义工具注册、调用、权限控制。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `tools.rs` + `tools/` 模块(base/registry/permission/mcp/error)
|
||||
2. ✅ `ToolRegistry` — 工具注册表(注册、发现、调用、并行执行、超时控制)
|
||||
3. ✅ `BaseTool` trait — 工具抽象接口(含 ToolContext 执行上下文)
|
||||
4. ✅ `McpClient` — MCP 协议客户端(stdio transport,StreamableHttp 预留)
|
||||
5. ✅ `PermissionChecker` — 工具执行权限检查(白名单/黑名单/自定义权限)
|
||||
6. ✅ `docs/5-tool-system.md` — 方案设计文档
|
||||
7. ✅ 扩展 `llm/cycle.rs` 支持自动 tool 循环(`submit_with_tools()` + `submit_request()` + `maybe_compact()`)
|
||||
8. ✅ `ToolError` — 结构化错误体系(含 `is_recoverable()` 分类)
|
||||
|
||||
**依赖**:Phase 0(LlmProvider 接口传递 tool definitions)、Phase 1(提示词可能需要注入工具描述)
|
||||
|
||||
**优先级**:Should Have
|
||||
|
||||
**预估规模**:约 900 行代码(实际约 1500 行)
|
||||
|
||||
**状态**:✅ Phase 2 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Memory System(记忆系统)
|
||||
|
||||
**目标**:提供对话记忆的存储、检索与管理能力。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `memory.rs` + `memory/` 模块(store / conversation / knowledge / retriever / error / types)
|
||||
2. ✅ `MemoryStore` trait + `InMemoryStore` — 记忆存储抽象(可插拔后端)+ 默认实现
|
||||
3. ✅ `ConversationMemory` — 对话记忆管理(sliding window / 全量),复用 `llm::compact`
|
||||
4. ✅ `KnowledgeStore` — 知识页面存储(具体 struct,非 trait,基于 MemoryStore)
|
||||
5. ✅ `MemoryRetriever` — 记忆检索器(TextOverlap Dice 系数评分,单通道)
|
||||
6. ✅ `docs/6-memory-system.md` — 方案设计文档
|
||||
7. ✅ `docs/note-knowledge-graph-design.md` — KnowledgeGraph 等 Phase 4 备用设计
|
||||
8. ✅ `EvictionPolicy` — 支持 None / Ttl / Capacity 三种淘汰策略
|
||||
|
||||
**依赖**:Phase 0(llm::compact 复用)、Cargo.toml 新增 `time` 依赖
|
||||
|
||||
**优先级**:Could Have
|
||||
|
||||
**预估规模**:约 700 行代码(实际约 1242 行,含测试)
|
||||
|
||||
**状态**:✅ Phase 3 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
### Phase 4a — Agent Core Glue(核心胶水层)
|
||||
|
||||
**目标**:提供最小可用的 Agent Runtime——把 Phase 0-3 的能力"装配"成 `AgentSession::submit_turn`。上层可基于 4a 构建多轮对话应用。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `agent.rs` + `agent/` 模块(7 个文件:agent/error/runtime/builder/session/task + 模块根)
|
||||
2. ✅ `Agent` trait — 智能体角色定义(name / system_prompt / tool_definitions)
|
||||
3. ✅ `AgentSession` — 会话实例(绑定 `Arc<dyn Agent>` + `RuntimeBundle` + 内联 HashMap session_data)
|
||||
4. ✅ `RuntimeBundle` — 显式依赖注入容器(不含 session_memory_backend)
|
||||
5. ✅ `AgentBuilder` — 链式构造入口(不含 session_memory_backend)
|
||||
6. ✅ `AgentError` — 统一错误类型(7 个变体:Llm / Tool / Memory / HookBlocked / LimitExceeded / Config / Other;不含 PlanParse)
|
||||
7. ✅ `Plan` / `Step` / `StepStatus` — 纯数据结构(不含任何解析逻辑)
|
||||
8. ✅ Hook 事件扩展:OnTurnStart / OnTurnEnd + turn_index 字段
|
||||
9. ✅ `docs/7-agent-runtime.md` — 方案设计文档(含 4a/4b/4c 分阶段计划)
|
||||
|
||||
**实际新增**:
|
||||
- 新增文件 7 个(agent.rs + agent/{agent, error, runtime, builder, session, task}.rs)
|
||||
- 修改文件 3 个(lib.rs +1 行;llm/hooks.rs +13 行追加变体/字段;llm/cycle.rs 内部字段 Box→Arc + 新增 `new_with_arc` 公共方法)
|
||||
- 实际代码量约 800 行(含测试;纯实现约 470 行——略高于方案预估 440 行,因 AgentSession 的 tests 模块内联 MockProvider/StubAgent 等辅助结构)
|
||||
- 新增内联测试 22 个;全量测试 84 → 109(0 失败)
|
||||
- clippy 0 警告(agent 模块)
|
||||
- 无新增外部依赖
|
||||
|
||||
**依赖**:Phase 0, 1, 2, 3
|
||||
|
||||
**优先级**:Could Have
|
||||
|
||||
**预估规模**:约 440 行代码
|
||||
|
||||
**状态**:✅ Phase 4a 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
### Phase 4b — Task Execution(任务执行)
|
||||
|
||||
**目标**:在 Phase 4a 基础上,赋予智能体"拆解目标 → 逐步执行"的能力。
|
||||
|
||||
**前置条件**:Phase 4a 已完成。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `TaskAgent` trait — `run(goal)` 自主式 + `execute_plan(plan)` 外部驱动式
|
||||
2. ✅ `PlanParser` trait + `JsonPlanParser` 参考实现
|
||||
3. ✅ `AgentError` 追加 PlanParse 变体(共 7 个变体)
|
||||
4. ✅ Hook 事件扩展:OnPlanStepComplete + plan_step_index 字段
|
||||
|
||||
**依赖**:Phase 4a
|
||||
|
||||
**优先级**:Could Have
|
||||
|
||||
**预估规模**:约 200 行代码(增量)
|
||||
|
||||
**实际新增**:
|
||||
- 修改文件 2 个(llm/hooks.rs +5 行;agent/error.rs +10 行)
|
||||
- 新增代码约 150 行(含测试;纯实现约 90 行)
|
||||
- 新增内联测试 4 个;全量测试 109 → 113(0 失败)
|
||||
- clippy 0 警告
|
||||
- 无新增外部依赖
|
||||
|
||||
**状态**:✅ Phase 4b 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
### Phase 4c — Session Memory(会话级记忆)
|
||||
|
||||
**目标**:提供会话级 key-value 记忆,作为 session 内各 context 之间的信息桥接通道。
|
||||
|
||||
**前置条件**:Phase 4a 已完成(可与 Phase 4b 并行)。
|
||||
|
||||
**交付物**:
|
||||
1. ✅ `SessionMemory` struct — 基于 `MemoryStore`,按 session_id namespace 隔离
|
||||
2. ✅ `RuntimeBundle` + `AgentBuilder` 扩展 `session_memory_backend` 字段
|
||||
3. ✅ `AgentSession` 替换内联 HashMap 为完整 `SessionMemory`
|
||||
|
||||
**依赖**:Phase 4a(Phase 3 MemoryStore)
|
||||
|
||||
**优先级**:Could Have
|
||||
|
||||
**预估规模**:约 115 行代码(增量)
|
||||
|
||||
**实际新增**:
|
||||
- 新增文件 1 个(agent/session_memory.rs)
|
||||
- 修改文件 4 个(agent/runtime.rs +5 行;agent/builder.rs +10 行;agent/session.rs +30 行;agent.rs +2 行)
|
||||
- 新增代码约 180 行(含测试;纯实现约 100 行)
|
||||
- 新增内联测试 3 个;全量测试 113 → 116(0 失败)
|
||||
- clippy 0 警告
|
||||
- 无新增外部依赖
|
||||
|
||||
**状态**:✅ Phase 4c 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系图
|
||||
|
||||
```mermaid
|
||||
graph BT
|
||||
P0["<b>Phase 0: Foundation</b><br/>LLM Cycle<br/>ProviderRegistry<br/>HookExecutor<br/>StreamEvents<br/>Auto-compaction"]:::done
|
||||
P1["<b>Phase 1: Prompt Engineering</b><br/>PromptTemplate<br/>PromptComposer"]:::done
|
||||
P2["<b>Phase 2: Tool System</b><br/>Tool Registry<br/>PermissionChecker<br/>MCP Client"]:::done
|
||||
P3["<b>Phase 3: Memory System</b><br/>MemoryStore<br/>ConversationMemory<br/>KnowledgeStore"]:::done
|
||||
P4a["<b>Phase 4a: Core Glue</b><br/>AgentSession<br/>RuntimeBundle<br/>Plan/Step 纯数据"]:::done
|
||||
P4b["<b>Phase 4b: Task Execution</b><br/>TaskAgent<br/>PlanParser<br/>JsonPlanParser"]:::done
|
||||
P4c["<b>Phase 4c: Session Memory</b><br/>SessionMemory"]:::done
|
||||
|
||||
P1 --> P0
|
||||
P2 --> P0
|
||||
P3 --> P0
|
||||
P2 --> P1
|
||||
P4a --> P1
|
||||
P4a --> P2
|
||||
P4a --> P3
|
||||
P4b --> P4a
|
||||
P4c --> P4a
|
||||
|
||||
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
|
||||
classDef pending fill:#fbbf24,stroke:#d97706,color:#1a1a1a
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v0.2.0 — 生产就绪(Production-Ready Core)
|
||||
|
||||
**目标**:解决 Rust Agent 工具箱从"能跑"到"能被人依赖"的鸿沟。持久化、配置层、上下文管理三大块补齐后,开发者可在 30 分钟内写出生产可用的 Agent 服务。
|
||||
|
||||
**总体规模**:8 个增量 Phase(Phase 5-12),17 个可验证 Step。
|
||||
|
||||
### 功能清单
|
||||
|
||||
#### P0 — 必须交付
|
||||
|
||||
| # | 功能 | 模块 | 方案要点 |
|
||||
|---|------|------|---------|
|
||||
| 1 | SqliteStore | `memory` | `rusqlite` + `bundled` feature,`MemoryStore` 的 SQLite 实现,进程重启数据不丢 |
|
||||
| 2 | ProviderConfig 扩展 + `from_env()` | `llm` | 补全 `timeout_secs` / `max_retries` 字段;`AG_LLM_*` 环境变量辅助函数 |
|
||||
| 3 | ToolDefinition IR 正式化 | `tools` | 移除 deprecated OpenAI wire 格式,替换为自定义 `ToolDef` 结构体 |
|
||||
| 4 | API 稳定性管理 | `*` | 公开枚举加 `#[non_exhaustive]`;CHANGELOG 记录 Breaking Changes;废弃 API 用 `#[deprecated]` 标记 |
|
||||
| 5 | Quick Start + 端到端示例 | `examples/` | 30 行 `main.rs` 快速开始;一个"SQLite 持久化 + Provider + 工具调用 + 多轮对话"的可运行示例(`cargo run --example`) |
|
||||
|
||||
#### P1 — 重要但不阻塞
|
||||
|
||||
| # | 功能 | 模块 | 方案要点 |
|
||||
|---|------|------|---------|
|
||||
| 6 | Ollama Provider | `llm/provider` | OpenAI Compat,本地 LLM 支持,实现量极小 |
|
||||
| 7 | VectorRetriever trait | `memory` | 语义检索 trait 抽象(`index` / `search`),不绑定后端实现 |
|
||||
| 8 | 流式 `submit_turn_stream` | `agent` | `AgentSession` 新增 `submit_turn_stream()`,返回 `Stream<Item = StreamEvent>` |
|
||||
| 9 | 测试补强 | `*` | wiremock Provider roundtrip 测试;多线程并发写入 MemoryStore 测试 |
|
||||
|
||||
#### P2 — 有时间再做
|
||||
|
||||
| # | 功能 | 模块 | 备注 |
|
||||
|---|------|------|------|
|
||||
| 10 | MCP StreamableHttp | `tools` | 当前仅预留枚举变体 |
|
||||
| 11 | Gemini Provider | `llm/provider` | 协议差异大,实现成本较高 |
|
||||
| 12 | 文件系统 MemoryStore 后端 | `memory` | JSON/JSONL 轻量持久化 |
|
||||
|
||||
### ContextSlot 上下文管理
|
||||
|
||||
**模块归属**:`src/llm/context.rs`(与 `compact.rs` 同级)
|
||||
|
||||
**核心概念**:`ContextSlot` 是一段带策略配置的消息列表,以 `slot_id` 为 namespace 独立持久化到 `MemoryStore`。支持三种模式、三种来源和派生关联(记录 `parent_id`)。
|
||||
|
||||
**核心类型**:
|
||||
|
||||
```rust
|
||||
pub struct ContextSlot { id, session_id, config, messages, store }
|
||||
pub struct SlotConfig { mode: SlotMode, source: SlotSource, budget, compact }
|
||||
pub enum SlotMode {
|
||||
Full, // 完整对话历史
|
||||
Focused(FocusedConfig), // 聚焦:保持 LLM 注意力
|
||||
Readonly, // 只读参考上下文
|
||||
}
|
||||
pub struct FocusedConfig { keep_system, recent_turns, inject_summary }
|
||||
pub enum SlotSource {
|
||||
New, // 全新空槽,独立持久化
|
||||
Derived { parent_id, strategy: DeriveStrategy }, // 从父 slot 派生
|
||||
Static(Vec<Message>), // 预置消息,不持久化
|
||||
}
|
||||
pub enum DeriveStrategy { Full, Focused(FocusedConfig) }
|
||||
pub struct ContextBudget { system, history, tools, tool_results, reserve }
|
||||
```
|
||||
|
||||
**持久化 Key 命名**:
|
||||
- `slot_msg:{session_id}:{slot_id}:{index}` → 消息内容
|
||||
- `slot_meta:{session_id}:{slot_id}` → `SlotMeta`(含 `parent_id`)
|
||||
- `slot_rel:{session_id}:{child_id}:parent` → `"{parent_id}"`
|
||||
|
||||
**`AgentSession` 扩展**:
|
||||
- `create_slot(id, config)` — 创建新 slot
|
||||
- `switch_slot(id)` — 切换当前 slot
|
||||
- `list_slots()` — 列出所有 slot
|
||||
- `derive_slot(id, parent_id, strategy)` — 从父 slot 派生
|
||||
|
||||
**与 `ConversationMemory` 的关系**:保留不废除。`ConversationMemory` 继续服务传统对话场景。
|
||||
|
||||
**v0.2 不做**:
|
||||
- ❌ `slot.fork()` / `merge()` — 分支方法推迟到 v0.3+
|
||||
- ❌ `inject_summary` 自动生成 — v0.2 仅消费端(从 `SessionMemory` 读取),生成在 v0.3+
|
||||
- ❌ 血缘关系图遍历 — 只存 `parent_id`,不做查询
|
||||
|
||||
**依赖**:Phase 0(MemoryStore trait)、Phase 3(MemoryStore 持久化)
|
||||
**优先级**:P1
|
||||
|
||||
---
|
||||
|
||||
### v0.2.0 实施计划 — 8 个增量 Phase
|
||||
|
||||
> **编号说明**:Phase 5-12 接续 v0.1 的 Phase 0-4c,按开发顺序排列。
|
||||
|
||||
#### Phase 5: 热身准备(Warmup)
|
||||
|
||||
**目标**:快速交付三个互不依赖的独立改动,建立交付节奏。
|
||||
|
||||
| Step | 内容 | 文件范围 | 验证标准 |
|
||||
|------|------|---------|---------|
|
||||
| **5.1** ✅ | `ProviderConfig` 扩展:补 `timeout_secs`(def=30) + `max_retries`(def=3);新增 `ProviderConfig::from_env(prefix)` | `llm/provider.rs` + 各 Provider `new()` 构造函数 | `cargo test` + `from_env()` 单元测试 |
|
||||
| **5.2** ✅ | `OllamaProvider`:基于 `GenericOpenaiProvider` 包装,改 base_url 为 `http://localhost:11434`;`ProviderType` 新增 `Ollama` | `llm/provider/provider.rs` + `llm/provider/ollama.rs`(新增) | `cargo build` — 纯类型级验证 |
|
||||
| **5.3** ✅ | 公开枚举 `#[non_exhaustive]` 前置标记:`ProviderType` / `StopReason` / `FinishReason` / `EvictionPolicy` / `SlotMode`(预置) | 各枚举定义处 | 编译通过 + `cargo clippy` 0 警告 |
|
||||
|
||||
**实际新增**(2026-07-05 commit `98dfe6c`):
|
||||
- 新增文件 1 个(`llm/provider/ollama.rs`,72 行)
|
||||
- 修改文件 2 个(`llm/provider.rs` 加 `from_env` + `Default` + 4 个字段;`memory/store.rs` EvictionPolicy 加 `#[non_exhaustive]`)
|
||||
- `ProviderType::Ollama` 变体 + `FromStr` 解析("ollama" → Ollama)
|
||||
- `OllamaProvider::new(base_url, api_key, model, timeout_secs)` + `with_client()` 构造函数
|
||||
- `ProviderConfig::from_env(prefix)` 解析 `{prefix}_API_KEY` / `{prefix}_BASE_URL` / `{prefix}_MODEL` 环境变量
|
||||
- 全量测试 182 → 190(+8,phase 5 新增 from_env 与 Ollama 相关单测)
|
||||
- clippy 0 警告
|
||||
|
||||
**依赖**:无(三个 Step 互不冲突)
|
||||
**优先级**:P0(5.1)+ P1(5.2)+ P0 前置(5.3)
|
||||
**为何独立成 Phase**:三个改动零文件重叠,可以并行推进。它们是后续所有 Phase 的"门把手"——先做完热身再进入核心工作。
|
||||
**状态**:✅ Phase 5 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
#### Phase 6: ToolDefinition IR 正式化
|
||||
|
||||
**目标**:引入 `ToolDef` 新类型,替换已标记 `#[deprecated]` 的 `ToolDefinition`(`OpenaiToolDefinition` 别名)。
|
||||
|
||||
**这是 v0.2 技术风险最高的 Phase**,影响 4 个模块约 8 个文件。通过 5 个 Step 逐文件切割确保每步可编译。
|
||||
|
||||
| Step | 内容 | 验证标准 |
|
||||
|------|------|---------|
|
||||
| **6.1** ✅ | `types/tool.rs` 新增 `ToolDef` 结构体 + `From<ToolDef> for OpenaiToolDefinition` + 反向 `From` | 单元测试 roundtrip |
|
||||
| **6.2** ✅ | `types/mod.rs` 切别名 `pub type ToolDefinition = ToolDef`;`MessageRequest.tools` 改 `Vec<ToolDef>` | `cargo build` 编译断点 |
|
||||
| **6.3** ✅ | `cycle.rs` 4 个方法签名 + `registry.rs` `definitions()` 签名更新 | `cargo build` |
|
||||
| **6.4** ✅ | Provider 适配层(openai.rs / anthropic.rs / openai_compat.rs):`build_request()` 内做 `ToolDef → wire-format` 转换 | `cargo test` 每个 provider 测试 |
|
||||
| **6.5** ✅ | 所有测试/示例中 `ToolDefinition` → `ToolDef` 修复;移除旧 `#[deprecated]` alias | `cargo test --all-targets` 全绿 |
|
||||
|
||||
**边界切割技巧**:
|
||||
- Step 6.1 → 6.2 之间是安全 checkpoint:新类型存在但旧代码照常编译
|
||||
- Provider 层不改序列化逻辑,只加一层 `From` 转换
|
||||
- 当前代码中 `ToolDefinition` 已是 `#[deprecated(since = "0.1.0")]`,用户已有迁移预期
|
||||
|
||||
**依赖**:无(仅与 Phase 5.3 有枚举兼容关系)
|
||||
**优先级**:P0
|
||||
|
||||
**实际新增**(2026-07-05 commit `4cf5918` / `9da9b83` / `b187519`,详见 `docs/13-phase6-tooldef-ir.md`):
|
||||
- 修改文件 8 个:`llm/types/tool.rs`、`llm/types/mod.rs`、`llm/types/request_v2.rs`、`llm/cycle.rs`、`llm/provider/openai.rs`、`tools/registry.rs`、`tools/mcp.rs`、`agent/agent.rs`
|
||||
- `ToolDef` IR(name / description / parameters,无 `strict`)新增于 `types/tool.rs`,配套双向 `From` 转换
|
||||
- `OpenaiToolDefinition` 降级为 `#[doc(hidden)]`,仅供 OpenAI 适配层内部消费
|
||||
- `MessageRequest.tools` 切换为 `Vec<ToolDef>`
|
||||
- `ToolDefinition` 别名最终完全移除(直接使用 `ToolDef`)
|
||||
- 4 处 `#[allow(deprecated)]` 抑制点全部清理(cycle/registry/mcp/agent);残留 `#[allow(deprecated)]` 均与 `ChatResponse` / `with_system_prompt` 等其他弃用项无关
|
||||
- 新增 roundtrip 测试 `message_request_with_tools_roundtrip`(断言 `strict` 字段不泄漏到序列化输出)
|
||||
- Anthropic 适配层字段名一致零改动;openai_compat/ollama 委托 `GenericOpenaiProvider` 零改动
|
||||
- 全量测试 190 → 191(+1,Phase 6 新增 roundtrip);clippy 0 警告
|
||||
|
||||
**状态**:✅ Phase 6 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
#### Phase 7: SqliteStore 持久化
|
||||
|
||||
**目标**:实现 `MemoryStore` 的 SQLite 后端,进程重启数据不丢。
|
||||
|
||||
**与 Phase 6 无耦合,可重叠开发。**
|
||||
|
||||
| Step | 内容 | 文件 | 验证标准 |
|
||||
|------|------|-----|---------|
|
||||
| **7.1** ✅ | 新增 `memory/store/sqlite.rs`:`Mutex<Connection>` + `spawn_blocking`,实现 `save/get/delete/list` + prefix 过滤 | `memory/store/sqlite.rs` + `Cargo.toml`(add `rusqlite`) | 单元测试 CRUD + prefix 查询 |
|
||||
| **7.2** ✅ | WAL 模式 + 并发安全 + 集成测试(`tokio::spawn` 10 个并发 task) | `sqlite.rs` 扩展 | 并发写入 100 轮无 race |
|
||||
|
||||
**设计决策**:
|
||||
- 用 `Mutex<Connection>` 而非连接池(ponytail:一个连接够用就不加 r2d2)
|
||||
- WAL 模式:`PRAGMA journal_mode=WAL` 解决读写锁
|
||||
|
||||
**依赖**:`MemoryStore` trait(v0.1 Phase 3 已就绪)
|
||||
**优先级**:P0
|
||||
|
||||
**实际新增**(2026-07-05 commit `13edacd` / `c8a91f6` / `c82af60`,详见 `docs/14-phase7-sqlite-store.md`):
|
||||
- 方案文档:`docs/14-phase7-sqlite-store.md`(526 行,Phase 7 设计推演与权衡记录)
|
||||
- 结构重组:`src/memory/store.rs` 单体文件 → `src/memory/store/{mod.rs(in_memory.rs, sqlite_store.rs)}` 模块目录;外部导入路径 `crate::memory::store::MemoryStore` 不变
|
||||
- 新增文件 2 个:`src/memory/store/sqlite_store.rs`(545 行 SqliteStore 实现 + 9 个内联测试)、`src/memory/store/in_memory.rs`(266 行,结构搬移)
|
||||
- 核心实现要点:
|
||||
- `Arc<Mutex<Connection>>` 串行化所有 IO;`spawn_blocking` 卸载到阻塞线程池
|
||||
- WAL 模式 + `synchronous=NORMAL` + `busy_timeout=5s` + `wal_autocheckpoint=1000`
|
||||
- `PRAGMA user_version` schema 版本管理(`INITIAL_USER_VERSION = 1`)
|
||||
- `created_at` 归一化为 UTC 的 RFC 3339 TEXT,字典序等价时间序
|
||||
- 错误精细映射:`SqliteFailure` / `InvalidQuery` → `InvalidInput`;`FromSqlConversionFailure` → `Serialization`;其他 → `Storage`
|
||||
- 9 个内联测试覆盖:CRUD、upsert、prefix / since / offset+limit 过滤、10 写者 × 10 次并发写入、持久化 round-trip(重启连接不丢数据)、`InMemoryStore ↔ SqliteStore` trait-box 互换兼容性
|
||||
- 依赖:`rusqlite = { version = "0.32", features = ["bundled"] }`;`time` 增补 `parsing` / `formatting` / `macros` features;`dev-dependencies` 新增 `tempfile = "3"`
|
||||
- 全量测试 191 → 200(+9,Phase 7 新增 SqliteStore 单测);clippy 0 警告
|
||||
|
||||
**状态**:✅ Phase 7 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
#### Phase 8: MVP 集成出口(v0.2.0-rc.1 候选)
|
||||
|
||||
**目标**:P0 五项全部交付。开发者 clone 仓库后 10 分钟跑起持久化 Agent。
|
||||
|
||||
| Step | 内容 | 验证标准 |
|
||||
|------|------|---------|
|
||||
| **8.1** ✅ | API 稳定性扫尾:`#[non_exhaustive]` × 14 公开枚举 + `StepStatus::Completed` 切 `MessageResponse` + CHANGELOG v0.2.0-rc.1 + Cargo.toml 0.2.0-rc.1 | `cargo doc --no-deps` 0 warning + 零 deprecated warning |
|
||||
| **8.2** ✅ | Quick Start 示例(57 行 `main.rs`):MockProvider + EchoTool + submit_turn 真实工具调用 | `cargo run --example quick_start` exit 0 |
|
||||
| **8.3** ✅ | 端到端示例:SqliteStore + AG_LLM_* from_env 自动检测 + 3 工具 + 3 轮对话 + 持久化跨连接验证 | `cargo run --example end_to_end`(Mock fallback,无需 API key)|
|
||||
|
||||
**Phase 8 全部完成**。**已打 `v0.2.0-rc.1` 标签**。
|
||||
|
||||
**实际新增**(2026-07-05,7 commits):
|
||||
- `feat(core)` —— 14 个公开枚举追加 `#[non_exhaustive]`(P0 核心 IR + P0 Error + P1 其他)
|
||||
- `refactor(agent)` —— `StepStatus::Completed(ChatResponse)` → `Completed(MessageResponse)` + `task_agent_demo.rs` 清理 3 处废弃类型
|
||||
- `docs` —— CHANGELOG v0.2.0-rc.1 条目 + Cargo.toml version 0.1.0 → 0.2.0-rc.1 + README 示例列表 7 → 10
|
||||
- `test(core)` —— 验证 commit 1-3 零回归(test 200 passed + clippy 0 警告 + doc 0 warning)
|
||||
- `feat(examples)` —— `quick_start.rs`(60 行)+ `end_to_end.rs`(246 行)
|
||||
- `docs(roadmap)` —— 标记 Phase 8 全部完成 + M4 里程碑 ✅
|
||||
- `fix(examples)` —— 实施后 PM/SA/Code Reviewer 三方审查发现 6 项问题(🔴 CalcTool 除零 panic + 🟡 drop 注释准确性 + 🟡 EchoTool 错误处理 + 💭 断言一致性 + 💭 工具两端语义统一 + 💭 trailing newline),全部修复
|
||||
|
||||
**依赖**:Phase 5(ProviderConfig from_env)+ Phase 6(ToolDef)+ Phase 7(SqliteStore)
|
||||
**优先级**:P0
|
||||
**状态**:✅ Phase 8 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
#### Phase 9: 流式体验增强
|
||||
|
||||
**目标**:Agent 会话支持流式输出,开发者看到实时 token。
|
||||
|
||||
| Step | 内容 | 文件 | 验证标准 |
|
||||
|------|------|-----|---------|
|
||||
| **9.1** ✅ | `AgentSession::submit_turn_stream(user_input) -> impl Stream<Item=StreamEvent>` | `agent/session.rs` | 单元测试验证流事件序列:`TextDelta → ... → MessageComplete` |
|
||||
|
||||
**注意**:tool 自动循环时流中插入 `ToolExecutionStarted` 事件,用户端 UI 显示"正在调用工具..."。
|
||||
|
||||
**依赖**:Phase 6(ToolDef)+ `LlmProvider.chat_stream`(v0.1 已有)
|
||||
**优先级**:P1
|
||||
|
||||
**实际新增**(2026-07-06 commit `212cfcc`,详见 `docs/16-phase9-streaming-experience.md`):
|
||||
- 方案文档:`docs/16-phase9-streaming-experience.md`(821 行,含状态机设计推演与边界情况)
|
||||
- 修改文件 3 个:`src/agent/session.rs`(+208,含 `submit_turn_stream` / `finalize_turn`)、`src/llm/cycle.rs`(+784,含 `submit_with_tools_stream` / `run_tool_loop` spawn + mpsc 状态机)、`src/llm/types/response_v2.rs`(+21,含 `StreamEvent::ToolExecutionStarted`/`Completed` 变体 + `apply_to` 元事件)
|
||||
- 关键设计:`CycleConfig` 加 `Clone` derive 以支持 spawn 跨 task;`finalize_turn` 手动同步状态(`submit_turn_stream` 返回流前不落库,避免半成品被 hook 误读)
|
||||
- 测试:新增 9 个单元测试 + 2 个集成测试(含 `submit_turn_stream_end_to_end` 端到端 mock provider 流消费 + `submit_turn_stream_triggers_turn_hooks` Hook 触发验证),全量 200 → 211(+11,0 失败)
|
||||
- clippy 0 警告
|
||||
- 无新增外部依赖
|
||||
|
||||
**状态**:✅ Phase 9 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
#### Phase 10: ContextSlot 上下文管理
|
||||
|
||||
**目标**:支持多上下文分区管理,Agent 可在不同 slot 之间切换。
|
||||
|
||||
| Step | 内容 | 验证标准 |
|
||||
|------|------|---------|
|
||||
| **10.1** ✅ | `src/agent/context.rs`:`ContextSlot` + `SlotConfig` / `SlotMode` / `FocusedConfig` / `SlotSource` / `DeriveStrategy` / `ContextBudget` / `SlotMeta` 核心类型 | `cargo build --all-targets` |
|
||||
| **10.2** ✅ | ContextSlot 持久化:基于 `MemoryStore` trait(不绑定 SqliteStore)实现 save/load/list/delete + slot 命名空间 key 策略 + `load_messages()` Focused 读时过滤 + `append_messages()` Readonly 阻断 + colon 注入防护 | 单元测试:持久化 roundtrip / session 隔离 / Focused 边界 / delete 保护 / 派生 / load_messages() |
|
||||
| **10.3** ✅ | `AgentSession` 扩展:`create_slot` / `switch_slot` / `list_slots` / `derive_slot` / `delete_slot` + `new()` 自动创建 `"default"` slot + `submit_turn`/`finalize_turn` 改造为基于当前 slot 的增量追加写回 + 新示例 `context_slot_demo` | 集成测试 + `cargo run --example context_slot_demo` exit 0 |
|
||||
|
||||
**如何保证简单场景无感**:`AgentSession::new()` 内部检查,自动创建 `"default"` slot → `submit_turn` 默认写到 default slot。
|
||||
|
||||
**实际新增**(2026-07-07 commit `6359422`,详见 `docs/17-phase10-contextslot.md`):
|
||||
- 方案文档:`docs/17-phase10-contextslot.md`(1227 行,含 §5 推荐方案、§6 实施建议、§9 实施计划,经过 4 轮方案/计划/实施审查 + 1 轮非阻塞建议修复)
|
||||
- 新增文件 3 个:`src/agent/context.rs`(~430 行 ContextSlot 核心类型 + 持久化方法 + 22 个测试)、`src/agent/context.rs` 中的 `ContextSlot::filter_focused` 静态方法(被 `load_messages` 和 `derive_slot` 复用,消除代码重复)、`examples/context_slot_demo.rs`(~160 行分支对话示例:法律咨询 → 派生两个方向 → 切换 → 隔离验证 → 删除保护)
|
||||
- 修改文件 3 个:`src/agent.rs`(+5 行 module 声明 + re-export)、`src/agent/error.rs`(+56 行:3 个新变体 `SlotReadonly`/`SlotNotFound`/`SlotAlreadyExists` + 4 个测试)、`src/agent/session.rs`(+825/-197 行:slots 字段 + 6 个管理方法 + submit_turn/finalize_turn 改造 + 17 个测试)
|
||||
- 关键设计:
|
||||
- **模块归属**:`agent/context.rs`(零新依赖方向,遵循 `agent → memory` 已有依赖)
|
||||
- **持久化**:JSON blob 批次存储,每 slot 3-4 条 `MemoryItem`(`slot_data` / `slot_meta` / `slot_config` / `slot_rel`)
|
||||
- **submit_turn 签名不变**:方案 A(内部 `current_slot_id` 状态),向后兼容
|
||||
- **Focused 模式读时过滤**:`load_messages() -> Vec<Message>`,避免 Rust 借用检查问题
|
||||
- **增量追加写回**:`cycle.messages()[input_len..]` 提取本轮新增消息,确保 Focused 模式数据不丢失
|
||||
- **delete_slot 双重保护**:禁止删 `"default"` + 至少保留一个 slot
|
||||
- **colon 注入防护**:`assert_no_colon` 在 key 构造时 panic
|
||||
- **错误传播**:`serde_json` / `MemoryStore` 所有错误用 `?` 传播,无静默吞掉
|
||||
- 验证:211 → 254 测试(+43 新测试),clippy 0 警告,doc 0 warning,10 + 1 示例全部 exit 0
|
||||
- finalize_turn 签名变更(破坏性):新增 `new_messages_from_cycle: Vec<Message>` 参数,返回从 `()` 改为 `Result<(), AgentError>`——影响 Phase 9 的 `submit_turn_stream_triggers_turn_hooks` 和 `submit_turn_stream_end_to_end` 2 个测试,已适配
|
||||
|
||||
**依赖**:Phase 5(`#[non_exhaustive]` 预置 SlotMode 等枚举)、Phase 7(SqliteStore 推荐持久化后端;`MemoryStore` trait 即可)
|
||||
**优先级**:P1
|
||||
**状态**:✅ Phase 10 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
#### Phase 11: 测试与检索补强
|
||||
|
||||
**目标**:补全测试覆盖 + 语义检索抽象。
|
||||
|
||||
| Step | 内容 | 验证标准 |
|
||||
|------|------|---------|
|
||||
| **11.1** ✅ | `VectorRetriever` trait:`index(id, embeddings)` + `search(query, k)` | 编译 + mock 测试 |
|
||||
| **11.2** ✅ | wiremock Provider roundtrip 测试:模拟 OpenAI/Anthropic HTTP 端点 | `cargo test` 新增 10+ roundtrip 测试 |
|
||||
| **11.3** ✅ | 并发测试补强:InMemoryStore + SqliteStore 多线程写入验证 | 跑 100 轮无 race |
|
||||
|
||||
**实际新增**(2026-07-06 commit `71abe88` / `b4e5c7d`,详见 `docs/18-phase11-testing-and-retrieval.md`):
|
||||
- 方案文档:`docs/18-phase11-testing-and-retrieval.md`(647 行,含 11.1/11.2/11.3 设计 + 10 项架构决策 + 实施后补充 2 条偏差记录 #6 mid-stream mock 模式 + #7 429 retry-after 修复)
|
||||
- 新增文件 1 个:`src/memory/vector.rs`(237 行 — `VectorRetriever` trait + `InMemoryVectorRetriever` 引用实现 + `dot()` 零依赖 + 6 个内联测试)
|
||||
- 修改文件 5 个:
|
||||
- `src/memory.rs`(+2 行:module 声明 + re-export)
|
||||
- `src/llm/provider/openai.rs`(+8 wiremock 测试 + `handle_error_response` 429 retry-after 解析修复 5 行)
|
||||
- `src/llm/provider/anthropic.rs`(+4 wiremock 测试)
|
||||
- `src/memory/store/in_memory.rs`(+3 并发测试:100 并发写、5 写+5 读混合、15 写者容量淘汰)
|
||||
- `src/memory/store/sqlite_store.rs`(+2 并发测试:100 并发写、5 写+5 读混合)
|
||||
- 关键设计:
|
||||
- **零依赖 dot()**:手写点积/范数,零新增 crate 依赖
|
||||
- **Wiremock 测试自包含**:每个测试独立 `MockServer::start()`,沿用现有模式
|
||||
- **429 retry-after 修复**:`openai.rs` 与 `anthropic.rs` 行为对齐(5 行代码)
|
||||
- **偏差记录**:方案文档「已否决的方案 #6/#7」记录两处实施偏差,便于后续审计追溯
|
||||
- 验证:254 → 277 测试(+23 个新测试),clippy 0 警告,doc 0 warning;并发测试连续 3 次运行稳定无 flaky
|
||||
- **依赖**:无(与方案一致)
|
||||
- **状态**:✅ Phase 11 全部交付物已完成
|
||||
|
||||
---
|
||||
|
||||
#### Phase 12: P2 锦上添花(可选)
|
||||
|
||||
**目标**:时间允许时按优先级交付。
|
||||
|
||||
| 优先级 | 功能 | 实现量估计 | 备注 |
|
||||
|--------|------|-----------|------|
|
||||
| **12.1** | 文件系统 MemoryStore(JSON/JSONL) | ~80 行 | 最简单,适合练手 |
|
||||
| **12.2** | MCP StreamableHttp 传输 | ~150 行 | 协议还在演进 |
|
||||
| **12.3** | Gemini Provider | ~300 行 | 协议差异大,建议推迟到 v0.3 |
|
||||
|
||||
**依赖**:无(独立交付)
|
||||
|
||||
---
|
||||
|
||||
### v0.2.0 Phase 依赖关系图
|
||||
|
||||
```mermaid
|
||||
graph BT
|
||||
P5["<b>Phase 5: 热身准备</b><br/>ProviderConfig::from_env<br/>Ollama Provider<br/>#[non_exhaustive] 标记"]:::done
|
||||
P6["<b>Phase 6: ToolDef IR</b><br/>Provider 无关工具定义"]:::done
|
||||
P7["<b>Phase 7: SqliteStore</b><br/>rusqlite + WAL<br/>9 个内联测试<br/>持久化 round-trip"]:::done
|
||||
P8["<b>Phase 8: MVP 出口</b><br/>rc.1 标签<br/>14 枚举 #[non_exhaustive]<br/>StepStatus IR 迁移<br/>quick_start + end_to_end"]:::done
|
||||
P9["<b>Phase 9: 流式体验增强</b><br/>submit_turn_stream<br/>submit_with_tools_stream<br/>9 单元测试 + 2 集成测试"]:::done
|
||||
P10["<b>Phase 10: ContextSlot</b><br/>ContextSlot 类型<br/>JSON blob 持久化<br/>AgentSession 集成<br/>43 个新测试"]:::done
|
||||
P11["<b>Phase 11: 测试与检索补强</b><br/>VectorRetriever trait<br/>12 wiremock tests<br/>5 并发测试"]:::done
|
||||
P12["Phase 12<br/>P2 锦上添花"]:::p2
|
||||
|
||||
P8 --> P5
|
||||
P8 --> P6
|
||||
P8 --> P7
|
||||
|
||||
P9 --> P6
|
||||
|
||||
P10 --> P7
|
||||
P10 --> P8
|
||||
|
||||
P11 -.-> P7
|
||||
|
||||
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
|
||||
classDef warmup fill:#e2e8f0,stroke:#94a3b8
|
||||
classDef core fill:#fbbf24,stroke:#d97706
|
||||
classDef mvp fill:#4ade80,stroke:#16a34a
|
||||
classDef p1 fill:#93c5fd,stroke:#2563eb
|
||||
classDef p2 fill:#c4b5fd,stroke:#7c3aed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 关键里程碑
|
||||
|
||||
| 里程碑 | Phase 完成条件 | 可验证指标 | 状态 |
|
||||
|--------|---------------|-----------|------|
|
||||
| **M1** | Phase 5 | 热身三项完成:`from_env()` 可用 / Ollama 类型存在 / `#[non_exhaustive]` 就位 | ✅ 2026-07-05 |
|
||||
| **M2** | Phase 6 | `ToolDef` 全量切换,`cargo test --all-targets` 全绿 | ✅ 2026-07-05 |
|
||||
| **M3** | Phase 7 | SqliteStore CRUD + 并发测试通过,进程重启数据不丢 | ✅ 2026-07-05 |
|
||||
| **M4** | **Phase 8 (rc.1)** | P0 五项全部交付,`cargo run --example quick_start` 跑通 | ✅ 2026-07-05 |
|
||||
| **M5** | Phase 9 | `submit_turn_stream` 流式事件序列验证通过 | ✅ 2026-07-06 |
|
||||
| **M6** | Phase 10 | ContextSlot 创建/切换/派生集成测试通过 | ✅ 2026-07-07 |
|
||||
| **M7** | Phase 11 | wiremock + 并发测试补强,测试总量 200+ | ✅ 2026-07-06 |
|
||||
| **M8** | Phase 12(可选) | P2 功能按需交付 | ⏳ |
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0 — 多 Agent 基础系统(Multi-Agent Foundation)
|
||||
|
||||
**目标**:从"LLM 调用工具箱"升级为"能构建多 Agent 协作、RAG、长记忆 Agent 产品的基础系统"。补齐 LangChain 7 大组件中缺失的 Document 和 VectorStore 能力,落地笔记设计中的 ContextSlot fork/merge、摘要自动生成、知识图谱,建立 engine 引擎层(会话树 + time-travel Checkpointer + SubAgent Dispatch + Agent Switch),为即将开发的多 Agent 产品提供完整基础。
|
||||
|
||||
**总体规模**:7 个增量 Phase(Phase 13-19),总新增代码约 2600 行,测试从 277 → 380+。
|
||||
|
||||
### 功能清单
|
||||
|
||||
#### P0 — 必须交付
|
||||
|
||||
| # | 功能 | 模块 | 方案要点 |
|
||||
|---|------|------|---------|
|
||||
| 1 | 技术债清理(旧 types 文件) | `llm/types` | `request.rs` / `response.rs` / `old_stream.rs` 三个 Phase 0 旧文件删除;内部类型移入 `provider/openai.rs` |
|
||||
| 2 | ContextSlot fork/merge | `agent/context` | `fork(child_id, strategy)` 别名 + `merge(child, MergeStrategy)` 三种策略(Append/Replace/Summarize) |
|
||||
| 3 | Document 系统 | `document/`(新模块) | `Document` 核心类型 + `RecursiveCharacterSplitter`(递归字符分割,支持 chunk_size/chunk_overlap/separators) |
|
||||
| 4 | Embedding 抽象 | `llm/embedding` | `Embedding` trait(`embed` / `dim`)+ `MockEmbedding` 测试实现 |
|
||||
| 5 | 向量存储持久化 | `vector/`(新模块) | `VectorStore` trait + `InMemoryVectorStore`(读写)+ `PersistentVectorStore`(SqliteStore 后端)+ `RagPipeline` 组合器 |
|
||||
| 6 | 摘要自动生成 | `agent` / `llm/hooks` | `SummaryConfig` 配置 + `OnTurnEnd` Hook 自动检测 token 水位 → 调 LLM 生成摘要 → `SessionMemory::set("conversation_summary", ...)` |
|
||||
| 7 | SessionManager + 会话树 | `engine/`(新模块) | Session 工厂(`create`/`create_child`)+ 按 ID 恢复(`get`)+ 子树管理(`children`/`parent`/`destroy_subtree`)+ 元数据持久化(MemoryStore) |
|
||||
| 8 | Time-travel Checkpointer | `engine/checkpointer` | `checkpoint(session)` 全量序列化 + `rollback(session_id, ckpt_id)` 回滚 + `fork(session_id, ckpt_id, new_id)` 分支 + `list_checkpoints` |
|
||||
| 9 | Agent Switch | `engine/switch` | 热切换 `session.agent`(替换 `Arc<dyn Agent>`),slot 历史 / turn_index / session_memory 全保留 |
|
||||
| 10 | SubAgent Dispatch | `engine/sub_agent` | `dispatch(parent, sub_agent, task, config)` 单任务 + `dispatch_all(parent, tasks, config)` 并行派发(Semaphore 并发控制)+ 子 SessionMemory 继承 + `SubTaskResult` 结构化回传 |
|
||||
| 11 | 知识图谱 | `memory/graph` | `KnowledgeGraph` trait(`add_entity` / `add_relation` / `get_related` / `find_by_keywords`)+ `InMemoryGraph` 实现 + `tag_index` 标签管理 |
|
||||
| 12 | 双通道检索 | `memory/retriever` | `MemoryRetriever` 扩展为双通道(`KnowledgeStore` + `KnowledgeGraph`)+ `RetrievalStrategy::Hybrid` |
|
||||
|
||||
### 实施计划 — 7 个增量 Phase
|
||||
|
||||
> **编号说明**:Phase 13-19 接续 v0.2 的 Phase 5-12,按开发顺序排列。
|
||||
|
||||
#### Phase 13: 热身清理 + ContextSlot fork/merge
|
||||
|
||||
**目标**:清除 Phase 0 遗留的旧 types 文件,交付超低价功能建立节奏。
|
||||
|
||||
| Step | 内容 | 文件范围 | 验证标准 |
|
||||
|------|------|---------|---------|
|
||||
| **13.1** | `OpenaiChatRequest` 移入 `provider/openai.rs`,`types/request.rs` 删除 | `llm/types/request.rs` + `llm/provider/openai.rs` | `cargo build --all-targets` |
|
||||
| **13.2** | `OpenaiChatResponse/Chunk` 移入 `provider/openai.rs`,`types/response.rs` 删除 | `llm/types/response.rs` + `llm/provider/openai.rs` | `cargo build --all-targets` |
|
||||
| **13.3** | `old_stream.rs` 删除 + `types/mod.rs` 中 `ChatResponse` 删除 | `llm/types/old_stream.rs` + `llm/types/mod.rs` | `cargo build` + 确认 3 个旧文件不存在 |
|
||||
| **13.4** | `ToolChoice` 从 `request.rs` 搬到 `tool.rs` | `llm/types/tool.rs` + `llm/types/request_v2.rs` | `cargo test --all-targets` 全绿 |
|
||||
| **13.5** | `ContextSlot::fork(child_id, strategy)` 别名 + `merge(child, MergeStrategy)` | `agent/context.rs` | 单元测试:fork → 子 slot 消息 = 父 slot 副本;merge(Append) → 消息按序追加 |
|
||||
|
||||
**依赖**:无
|
||||
**优先级**:P0
|
||||
**预估规模**:约 200 行
|
||||
**状态**:✅ Phase 13 全部交付物已完成(2026-07-08)
|
||||
|
||||
---
|
||||
|
||||
#### Phase 14: Document 系统 + Embedding 抽象
|
||||
|
||||
**目标**:补齐 LangChain 7 大组件中最明显的缺口——Document 类型和分割器。不搞 Loader 框架,用户用 `fs::read_to_string` 自行加载。
|
||||
|
||||
**交付物**:
|
||||
1. `src/document.rs` 新模块(`Document` 类型 + `RecursiveCharacterSplitter`)
|
||||
2. `src/llm/embedding.rs`(`Embedding` trait + `MockEmbedding`)
|
||||
|
||||
**设计要点**:
|
||||
- `Document`:id / content / metadata(HashMap<String, String>)/ mime_type
|
||||
- `RecursiveCharacterSplitter`:chunk_size(默认 1000)/ chunk_overlap(默认 200)/ separators(`["\n\n", "\n", "。", "?", "!", ".", " ", ""]`,含 CJK 标点)
|
||||
- 两阶段算法:按 separator 优先级递归分割(Phase 1)+ 贪心合并 + overlap 滑动窗口(Phase 2)
|
||||
- 所有长度比较以 Unicode 字符数为单位(`chars_len()`),非字节数
|
||||
- `Embedding` trait:`async fn embed(&self, input: &[String]) -> Result<Vec<Vec<f32>>, LlmError>` + `fn dim()`
|
||||
- 复用 `LlmError` 而非新错误类型
|
||||
- `MockEmbedding`:sin-hash 零依赖伪随机向量 + L2 归一化
|
||||
- 不引入 `DocumentLoader` trait(应用层职责)
|
||||
|
||||
**实际新增**(2026-07-09 commit `d4c4d8f`,详见 `docs/20-phase14-document-and-embedding.md`):
|
||||
- 新增文件 3 个:
|
||||
- `src/document.rs`(580 行)— `Document` 类型(4 字段 + `new`/`from_raw` 构造器,2 个 `new` 接受 `impl Into<String>`) + `RecursiveCharacterSplitter`(两阶段算法:按 separator 优先级递归分割 + 贪心合并 overlap,所有长度比较 `chars_len()` 字符级,overlap 提取 `chars().rev().take().rev()` 字符级安全)+ 19 个内联测试
|
||||
- `src/llm/embedding.rs`(183 行)— `Embedding` trait(async + `LlmError`)+ `MockEmbedding`(sin-hash:字节和+长度做种子,`f32::sin(seed + i) * 10000`,L2 归一化到单位长度,零向量防除零)+ 6 个内联测试
|
||||
- `examples/document_demo.rs`(74 行)— 端到端演示 Document → RecursiveCharacterSplitter → MockEmbedding → InMemoryVectorRetriever → search
|
||||
- 修改文件 2 个:
|
||||
- `src/lib.rs`(+3 行:`pub mod document` + `pub use document::Document` + 空行)
|
||||
- `src/llm.rs`(+1 行:`pub mod embedding`)
|
||||
- 关键设计:
|
||||
- **早返回守卫**:`split_text` 在 `chars_len(text) <= self.chunk_size` 时直接返回 `[text]`,避免短文本在 Phase 2 `join("")` 中丢失 separator 边界
|
||||
- **`Document::new` 使用 `impl Into<String>`**:接受 `&str` 或 `String`,比规范示例的 `String` 更灵活
|
||||
- **`new()` panic + `try_new()` Result 双路径**:与 Rust 库惯例一致
|
||||
- **CJK 分隔符扩展**:`DEFAULT_SEPARATORS` 包含 `"。"`/`"?"`/`"!"`,避免中文文本跳过句子级退化为空格分割
|
||||
- **chunk_size = 0 校验**:构造器拒绝零值,避免字符级兜底死循环
|
||||
- **tracing 埋点**:`split()` 入口 `tracing::debug!` + 每文档/每 chunk `tracing::trace!`
|
||||
- **debug_assert 溢出保护**:单文档 chunk 数 < 10000 时 `debug_assert!`
|
||||
- **Metadata 键覆盖文档化**:`HashMap::insert()` 静默覆盖 source_id/chunk_index/chunk_count 在 `split()` doc comment 注明
|
||||
- 测试:19 个 Document 测试(含 1 个 split_multibyte_utf8_boundary CJK 边界测试)+ 6 个 Embedding 测试,全量 286 → 313(+27 新测试,但部分测试覆盖范围重叠计算约 25 个净增)
|
||||
- 方案文档:`docs/20-phase14-document-and-embedding.md`(1417 行,含背景/调研/方案对比/实施计划(详细版)/3 轮审查修复记录),经过 3 轮 PM/SA 审查 + 1 轮实施后修复
|
||||
- clippy 0 警告,doc 0 warning
|
||||
- 无新增外部依赖(`Cargo.toml` 未修改)
|
||||
|
||||
**实施后调整**:
|
||||
- 实施发现方案算法中 Phase 1 累加器设计与测试期望冲突("para1\n\npara2" 在 chunk_size=100 时 1 chunk 更合理),简化为"按 separator 切分 + Phase 2 合并"两阶段分工
|
||||
- 二次审查发现 `split_text` 缺少早返回守卫 + `current_sep_count` 虚增计数,全部已修复
|
||||
|
||||
**依赖**:无(纯数据结构 + 零新 crate 依赖)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 350 行
|
||||
**状态**:✅ Phase 14 全部交付物已完成(2026-07-09)
|
||||
|
||||
---
|
||||
|
||||
#### Phase 15: 向量存储持久化(SqliteStore 后端)
|
||||
|
||||
**目标**:实现 VectorStore 持久化,让语义检索支持进程重启后数据恢复。
|
||||
|
||||
**设计决策**:不用 pgvector。基于已有 SqliteStore(`rusqlite`)做持久化包装——运行时全量加载到 InMemory 索引做余弦搜索,写时同步到 SqliteStore。
|
||||
|
||||
**交付物**:
|
||||
1. `src/vector/` 新模块:`VectorStore` trait + `InMemoryVectorStore` + `PersistentVectorStore` + `RagPipeline`
|
||||
2. `VectorStore` trait:`add(docs, embeddings)` / `search(query, k)` / `remove(ids)`
|
||||
3. `PersistentVectorStore`:构造时从 SqliteStore 加载已有索引;`add` 双向写入;`search` 纯内存搜索
|
||||
4. `RagPipeline`:组合器封装 `split` → `embed` → `store.add` 的 ingest 流程,以及 `embed` → `store.search` 的 retrieve 流程
|
||||
5. SqliteStore 存储格式:`vec:{namespace}:{doc_id}` → JSON `{doc_id, content, metadata, embedding}`
|
||||
|
||||
**依赖**:Phase 14(Document 类型)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 400 行
|
||||
**状态**:⏳ 待实施
|
||||
|
||||
---
|
||||
|
||||
#### Phase 16: 摘要自动生成
|
||||
|
||||
**目标**:闭环长对话能力。v0.2 的 `inject_summary` 消费端(`FocusedConfig.summary_override`)已就绪,缺的是生产端。
|
||||
|
||||
**交付物**:
|
||||
1. `SummaryConfig` 结构体:`enabled` / `trigger_token_ratio`(默认 0.75)/ `summary_prompt`(可自定义)
|
||||
2. 在 `OnTurnEnd` Hook 中插检查点:检测 token 水位超过 `trigger_token_ratio` → 调 LLM 生成摘要 → `SessionMemory::set("conversation_summary", summary)`
|
||||
3. `AgentBuilder` 扩展:`.summary_config(cfg)` 方法
|
||||
|
||||
**为什么放 Hook 而非内置**:可插拔,默认不启用,用户 opt-in。不改变现有 `submit_turn` 行为。
|
||||
|
||||
**依赖**:无(Hook 系统 + SessionMemory 已就绪)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 150 行
|
||||
**状态**:⏳ 待实施
|
||||
|
||||
---
|
||||
|
||||
#### Phase 17: Agent 执行引擎(会话树 + Time-travel Checkpointer)
|
||||
|
||||
**目标**:建立 `engine/` 模块。解决 v0.2 中"session 在变量里、无法通过 ID 恢复、不支持父子关系"的空白。
|
||||
|
||||
**交付物**:
|
||||
1. `src/engine/` 新模块(`session_manager.rs` + `checkpointer.rs` + `error.rs`)
|
||||
2. `SessionManager`:
|
||||
- `create(agent, bundle) -> session_id` — 创建根 session
|
||||
- `create_child(parent_id, child_id, agent)` — 创建子 session(继承父 `RuntimeBundle`)
|
||||
- `get(session_id) -> Arc<Mutex<AgentSession>>` — 按 ID 查找(支持从持久化恢复)
|
||||
- `children(parent_id)` / `parent(child_id)` — 树形查询
|
||||
- `destroy(id)` / `destroy_subtree(id)` — 生命周期管理
|
||||
- `tree() -> SessionTreeSnapshot` — 树结构快照
|
||||
3. `Checkpointer`:
|
||||
- `checkpoint(session)` — 每个 `submit_turn` 末尾自动保存全量状态快照
|
||||
- `rollback(session_id, ckpt_id)` — 回滚到任意历史 checkpoint
|
||||
- `fork(session_id, ckpt_id, new_id)` — 从历史 checkpoint 分支出新 session
|
||||
- `list_checkpoints(session_id)` — 列出 checkpoint 列表
|
||||
4. `AgentSession` 新增 `Serialize + Deserialize` 以支持 checkpoint 序列化
|
||||
|
||||
**Checkpoint 存储格式**:`checkpoint:{session_id}:{ckpt_id}` → JSON(完整 AgentSession,含所有 slot 消息列表)。Ponytail:全量 JSON 够用,等遇到存储效率问题时再改增量模式。
|
||||
|
||||
**会话树持久化**:`session_meta:{session_id}` → `{agent_name, parent_id, created_at, turn_count}`;`session_rel:{child_id}` → `"parent_id"`
|
||||
|
||||
**依赖**:Phase 10(ContextSlot 持久化 — 消息由 slot 自己管,Checkpointer 管执行状态)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 600 行
|
||||
**状态**:⏳ 待实施
|
||||
|
||||
---
|
||||
|
||||
#### Phase 18: Agent Switch + SubAgent Dispatch + Agent 间交互
|
||||
|
||||
**目标**:在 SessionManager 基础上,提供 Agent 角色热切换和子代理调度能力。
|
||||
|
||||
**交付物**:
|
||||
1. `engine/switch.rs` — `switch_agent(session_id, new_agent)`:替换 `Arc<dyn Agent>`,slot 历史 / turn_index / session_memory 全保留
|
||||
2. `engine/sub_agent.rs` — SubAgent Dispatch 核心:
|
||||
- `DispatchConfig`:`max_concurrency`(默认 10)/ `inherit_session_memory`(默认 true)/ `bridge_keys`
|
||||
- `dispatch(parent_id, sub_agent, task, config) -> SubTaskResult`:创建子 session → 继承父 SessionMemory → `submit_turn` → 返回结构化结果
|
||||
- `dispatch_stream(parent_id, sub_agent, task, config) -> SubTaskStream`:流式版
|
||||
- `dispatch_all(parent_id, tasks, config) -> Vec<SubTaskResult>`:并行派发,`tokio::sync::Semaphore` 控制并发数
|
||||
3. `SubTaskResult`:`child_id` / `response` / `usage` / `summary` + `child_memory(sm)` 读取子 SessionMemory
|
||||
|
||||
**Agent 间交互三层级**:
|
||||
- 父→子:继承 SessionMemory 快照 + `bridge_keys` 指定 key 强制注入 system prompt
|
||||
- 子→父:`SubTaskResult` 结构化回传 + `SessionMemory["result_summary"]` 结论摘要
|
||||
- 子↔子(间接):通过公共 `MemoryStore` namespace(`shared:{parent_session_id}`)共享数据
|
||||
|
||||
**依赖**:Phase 17(SessionManager + 会话树)
|
||||
**优先级**:P0
|
||||
**预估规模**:约 500 行
|
||||
**状态**:⏳ 待实施
|
||||
|
||||
---
|
||||
|
||||
#### 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 行
|
||||
**状态**:⏳ 待实施
|
||||
|
||||
---
|
||||
|
||||
### 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"]:::pending
|
||||
P16["<b>Phase 16: 摘要自动生成</b><br/>SummaryConfig<br/>OnTurnEnd Hook"]:::pending
|
||||
P17["<b>Phase 17: 执行引擎</b><br/>SessionManager<br/>会话树<br/>Time-travel Checkpointer"]:::pending
|
||||
P18["<b>Phase 18: 切换与调度</b><br/>Agent Switch<br/>SubAgent Dispatch<br/>dispatch_all 并发控制"]:::pending
|
||||
P19["<b>Phase 19: 知识图谱</b><br/>KnowledgeGraph trait<br/>InMemoryGraph<br/>双通道检索"]:::pending
|
||||
|
||||
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` 端到端验证 | ⏳ |
|
||||
| **M12** | Phase 16 | 多轮对话后摘要自动写入 SessionMemory、派生 slot 时摘要正确注入 | ⏳ |
|
||||
| **M13** | **Phase 17 (rc.1)** | `SessionManager` 创建/子树/恢复集成测试通过、`Checkpointer` checkpoint/rollback/fork 验证 | ⏳ |
|
||||
| **M14** | Phase 18 | `switch_agent` 热切换验证、`dispatch`/`dispatch_all` 多轮对话 + 结果回传验证 | ⏳ |
|
||||
| **M15** | Phase 19 | `KnowledgeGraph` 实体-关系 CRUD + `get_related` BFS 验证、双通道检索 Hybrid 策略验证 | ⏳ |
|
||||
|
||||
---
|
||||
|
||||
## v0.4+ 展望
|
||||
|
||||
### 已规划的功能
|
||||
|
||||
| 功能 | 说明 | 预计版本 |
|
||||
|------|------|---------|
|
||||
| Multi-Agent Swarm 编排 | Supervisor/Subgraph 模式,基于 v0.3 dispatch 构建 | v0.4 |
|
||||
| Human-in-the-loop 审批 | `interrupt()` + `Command(resume=...)` 异步审批回调 | v0.4 |
|
||||
| Agent 自动创生 | LLM 自主决定何时派发子 agent、派发什么角色 | v0.4 |
|
||||
| 分布式 session 共享 | SessionManager Redis 后端支持跨进程 | v0.4 |
|
||||
| 精确 tokenizer 计数 | 引入 `tiktoken-rs`,绑定模型具体 tokenizer,替换字符估算 | v0.4+ |
|
||||
| TokenJuice 语义压缩 | 对工具结果做语义压缩而非字节截断 | v0.4+ |
|
||||
| Markdown 技能按需加载 | 技能注册表 + 按 prompt 上下文动态加载 | v0.4+ |
|
||||
| 增量 checkpoint | 仅存储变化部分,替换当前全量 JSON 模式 | v0.4+ |
|
||||
| RL 轨迹导出 | ShareGPT 格式轨迹、Atropos 集成 | v0.4+ |
|
||||
|
||||
### 明确不做(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 新增 `engine/` `vector/` `document/` 三个模块,功能覆盖扩展到多 Agent 基础系统。始终保持 trait + reference impl 的边界,业务循环留给上层
|
||||
5. **API 稳定性**:v0.3 引入 `Checkpointer`、`SessionManager`、`VectorStore` 等新公开 API,v0.2 已有的 `#[non_exhaustive]` 和 `#[deprecated]` 机制继续沿用
|
||||
6. **Checkpointer 存储效率**:v0.3 使用全量 JSON 序列化存储 checkpoint,每轮对话约几百 KB。`fork` 从历史 checkpoint 创建新 session 时也会复制全量。等实际使用中发现存储瓶颈时再改为增量模式
|
||||
|
||||
---
|
||||
|
||||
## 下一步行动
|
||||
|
||||
1. **v0.3.0 Phase 15 启动**:向量存储持久化(`VectorStore` trait + `InMemoryVectorStore` + `PersistentVectorStore` + `RagPipeline` 组合器),基于 Phase 14 的 `Document` 类型构建
|
||||
2. **Phase 15-19 顺次交付**:按依赖关系推进向量存储 → 摘要 → 引擎 → 调度 → 知识图谱
|
||||
3. **示例先行**:每完成一个 Phase 立即创建/更新对应示例,确保 `cargo run --example` 可验证
|
||||
4. **里程碑追踪**:以 M10(Phase 14)为已达成里程碑,逐 Phase 推进 M11-M15
|
||||
|
||||
**已完成 / 进行中阶段**:
|
||||
- ✅ Phase 0 Foundation — 全部交付物已完成
|
||||
- ✅ Phase 1 Prompt Engineering — 全部交付物已完成
|
||||
- ✅ Phase 2 Tool System — 全部交付物已完成
|
||||
- ✅ Phase 3 Memory System — 全部交付物已完成
|
||||
- ✅ Phase 4a Core Glue — 全部交付物已完成
|
||||
- ✅ Phase 4b Task Execution — 全部交付物已完成
|
||||
- ✅ Phase 4c Session Memory — 全部交付物已完成
|
||||
- ✅ Phase 5 Warmup — ProviderConfig::from_env + OllamaProvider + `#[non_exhaustive]` 前置标记(ProviderType / StopReason / FinishReason / EvictionPolicy)
|
||||
- ✅ Phase 6 ToolDefinition IR — `ToolDef` 新类型 + 双向 `From` 转换 + 别名彻底移除 + `#[allow(deprecated)]` 清理(cycle/registry/mcp/agent);Anthropic 零改动;roundtrip 测试覆盖
|
||||
- ✅ Phase 7 SqliteStore — `rusqlite 0.32` + WAL 模式 + `Arc<Mutex<Connection>>` + `spawn_blocking`;`memory/store.rs` → `store/{in_memory,sqlite_store}.rs` 模块化;9 个内联测试覆盖 CRUD/upsert/过滤/10×10 并发/持久化 round-trip;`InMemoryStore ↔ SqliteStore` trait-box 互换兼容
|
||||
- ✅ **Phase 8 MVP 集成出口** — 14 个公开枚举追加 `#[non_exhaustive]`(P0 核心 IR + P0 Error + P1 其他) + `StepStatus::Completed(ChatResponse)` → `Completed(MessageResponse)` 迁移 + CHANGELOG v0.2.0-rc.1 + 2 个新示例(`quick_start` 60 行 + `end_to_end` 246 行),10 个离线示例全部 exit 0;**v0.2.0-rc.1 标签已打**;实施后三方审查发现 6 项问题(1 🔴 + 2 🟡 + 3 💭)已全部修复
|
||||
- ✅ **Phase 9 流式体验增强** — `AgentSession::submit_turn_stream` 流式事件序列 + `LlmCycle::submit_with_tools_stream` spawn + mpsc 状态机 + `StreamEvent::ToolExecutionStarted`/`Completed` 新变体 + 9 单元测试 + 2 集成测试(含 `submit_turn_stream_end_to_end` 端到端 mock 验证 + `submit_turn_stream_triggers_turn_hooks` Hook 触发验证),全量 200 → 211;`CycleConfig` 加 `Clone` derive;方案文档 `docs/16-phase9-streaming-experience.md`(821 行)
|
||||
- ✅ **Phase 10 ContextSlot 上下文管理** — `src/agent/context.rs` 新增 `ContextSlot` 核心类型(Full / Focused / Readonly 三种模式,New / Derived / Static 三种来源)+ JSON blob 批次持久化(每 slot 3-4 条 MemoryItem,`slot_config` key 自恢复支持旧版本兼容);`AgentSession` 扩展 slots 字段 + 5 个管理方法(`create_slot` / `switch_slot` / `list_slots` / `derive_slot` / `delete_slot`,自动创建 `"default"` slot,`delete_slot` 双重保护禁止删 default/最后一个);`submit_turn`/`finalize_turn` 改造为基于当前 slot 的增量追加写回(`cycle.messages()[input_len..]` 提取本轮新增消息,确保 Focused 模式"读时过滤"语义不丢失数据);`finalize_turn` 签名变更(新增 `new_messages_from_cycle: Vec<Message>` 参数,返回 `Result<(), AgentError>`);`agent/error.rs` 新增 3 个 Slot 错误变体(`SlotReadonly` / `SlotNotFound` / `SlotAlreadyExists`);`examples/context_slot_demo.rs` 新增分支对话示例(法律咨询入口 → 两个派生方向 → 切换 → 隔离验证 → 删除保护);方案文档 `docs/17-phase10-contextslot.md`(1227 行,含 §5 推荐方案、§6 实施建议、§9 实施计划,经过 4 轮方案/计划/实施审查 + 1 轮非阻塞建议修复);全量 211 → 254(+43 新测试),clippy 0 警告,doc 0 warning,11 个离线示例全部 exit 0
|
||||
- ✅ **Phase 11 测试与检索补强** — `src/memory/vector.rs` 新增 `VectorRetriever` trait(index + search 抽象)+ `InMemoryVectorRetriever` 引用实现(HashMap + 全量余弦相似度扫描 + 零依赖 `dot()`),6 个内联测试覆盖 basic/empty/zero-vector/k=0/2 个并发;wiremock Provider roundtrip 测试 12 个(OpenAI 8 + Anthropic 4)覆盖请求体/header/401/429/500/529/流式 usage-only/流式错误/ToolUse/结构化错误体;`MemoryStore` 并发测试 5 个(InMemoryStore 3 + SqliteStore 2)覆盖 100 并发写、5 写+5 读混合 2 秒、15 写者容量淘汰;`openai.rs` `handle_error_response` 修复 429 retry-after 解析(5 行,与 anthropic 对齐);方案文档 `docs/18-phase11-testing-and-retrieval.md`(647 行,含 10 项架构决策 + 2 条实施偏差记录 #6 mid-stream mock 模式 + #7 retry-after 修复);全量 254 → 277(+23 新测试),clippy 0 警告,doc 0 warning,并发测试 3 次稳定无 flaky
|
||||
- ✅ **Phase 13 热身清理 + ContextSlot fork/merge** — 3 个旧 types 文件删除(`request.rs` 187 行 + `response.rs` 177 行 + `old_stream.rs` 45 行),所有 OpenAI wire-format 类型迁入 `provider/openai.rs` 可见性 `pub(crate)`(Breaking Change:原 `agcore::llm::types::OpenaiChatRequest/Response/Chunk` 公共 re-export 路径已删除);`ChatResponse` 自 v0.1.0 标记 `#[deprecated]` 后在 Phase 13 整体删除;`ToolChoice` 从 `request.rs` 迁入 `tool.rs`(公共 `agcore::llm::types::ToolChoice` 路径不变);`ContextSlot::fork()` 派生独立子 slot(`SlotSource::Derived { parent_id, strategy }` 血缘可追溯)+ `ContextSlot::merge(child, MergeStrategy)` 合入父 slot(`Append` / `Replace` 两种策略,`#[non_exhaustive]` 为 Phase 16 `Summarize` 预留);`MergeStrategy` 防御性检查(self-merge / 跨 session / Readonly 目标全部阻断);`AgentSession::derive_slot` 重构复用 `fork()` 消除重复;`agent.rs` 追加 `MergeStrategy` re-export;9 个 fork/merge 内联测试覆盖 happy path 与 error path;`stream.rs` 简化为 module doc + `pub use` 重导出(保持 `use crate::llm::stream::StreamEvent` 路径兼容);方案文档 `docs/19-phase13-cleanup-and-fork-merge.md`(640 行);全量 277 → 286(+9 新测试),clippy 0 警告,doc 0 warning
|
||||
- ✅ Provider IR 重构 — 统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen/Ollama 适配
|
||||
- ✅ LlmCycle 简化 — IR 消息类型切换 + Phase 0 桥接层移除
|
||||
- ✅ v0.1 Release — 技术债扫清、MockProvider 公开化、8 个离线示例(含 `simple_visit`)、README + 错误消息友好化、CHANGELOG 初始化
|
||||
- ✅ **v0.2 规划细化完成** — 8 个增量 Phase(Phase 5-12),17 个可验证 Step,覆盖 P0-P2 全部 12 项功能 + ContextSlot
|
||||
- ✅ **v0.3.0 Phase 13 完成** — 技术债清理(3 旧 types 文件 + ChatResponse 删除)+ ContextSlot fork/merge(9 新测试),M9 里程碑达成
|
||||
- ✅ **v0.3.0 Phase 14 完成** — Document 类型(id/content/metadata/mime_type)+ `RecursiveCharacterSplitter` 两阶段算法(按 separator 优先级递归分割 + 贪心合并 overlap,全部 `chars_len()` 字符级比较)+ `Embedding` trait(async + `LlmError` 复用)+ `MockEmbedding`(sin-hash 零依赖伪随机 + L2 归一化)+ 19 Document 测试 + 6 Embedding 测试(含 1 个 split_multibyte_utf8_boundary CJK 边界测试);`src/document.rs`(580 行)+ `src/llm/embedding.rs`(183 行)+ `examples/document_demo.rs`(74 行);`pub use document::Document` 在 lib.rs 重导出;CJK 分隔符(`。`/`?`/`!`)加入 `DEFAULT_SEPARATORS`;方案文档 `docs/20-phase14-document-and-embedding.md`(1417 行);全量 286 → 313(+27 新测试,0 失败),clippy 0 警告,doc 0 warning,零新外部依赖;M10 里程碑达成;Phase 15-19 共 5 个增量 Phase 待实施(向量存储 → 摘要 → 引擎 → 调度 → 知识图谱)
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -1,4 +1,5 @@
|
||||
//! agent_session_demo —— Agent 装配 + 会话链路 + SessionMemory 桥接。
|
||||
//! Required features: cargo run --example agent_session_demo --features "agent"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. 实现 `Agent` trait(定义角色 + system prompt)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! agent_switch_demo —— Agent 角色热切换示例。
|
||||
//! Required features: cargo run --example agent_switch_demo --features "engine"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. 创建 session(绑定 Analyst agent)
|
||||
//! 2. 提交一轮对话(角色 A 输出"分析数据")
|
||||
//! 3. switch_agent 切换为 Reporter agent
|
||||
//! 4. 提交第二轮对话(角色 B 基于已有上下文输出"报告")
|
||||
//! 5. 验证:turn_index 连续、session_memory 保留、slot 历史保留
|
||||
//!
|
||||
//! 运行:`cargo run --example agent_switch_demo`
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agcore::agent::{Agent, AgentBuilder};
|
||||
use agcore::engine::SessionManager;
|
||||
use agcore::llm::hooks::HookExecutor;
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::types::Usage;
|
||||
use agcore::llm::types::message::{ContentBlock, Message};
|
||||
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
|
||||
use agcore::memory::store::InMemoryStore;
|
||||
use agcore::tools::ToolRegistry;
|
||||
|
||||
struct AnalystAgent;
|
||||
struct ReporterAgent;
|
||||
|
||||
impl Agent for AnalystAgent {
|
||||
fn name(&self) -> &str {
|
||||
"analyst"
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
Some("You are a data analyst. Analyze the input concisely.")
|
||||
}
|
||||
}
|
||||
|
||||
impl Agent for ReporterAgent {
|
||||
fn name(&self) -> &str {
|
||||
"reporter"
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
Some("You are a report writer. Write concise reports based on context.")
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant_text(text: &str) -> MessageResponse {
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: String::new(),
|
||||
message: Message::Assistant {
|
||||
content: vec![ContentBlock::Text { text: text.into() }],
|
||||
},
|
||||
usage: Usage::from_input_output(8, 4),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("=== Agent Switch Demo ===\n");
|
||||
|
||||
// 1. 准备组件
|
||||
let store: Arc<dyn agcore::memory::store::MemoryStore> = Arc::new(InMemoryStore::new());
|
||||
let provider = Arc::new(MockProvider::new(vec![
|
||||
assistant_text("Analyst: data analyzed (Q3 sales up 15%)"),
|
||||
assistant_text("Reporter: report drafted (3 paragraphs)"),
|
||||
]));
|
||||
let bundle = Arc::new(
|
||||
AgentBuilder::new()
|
||||
.provider(provider)
|
||||
.tool_registry(Arc::new(ToolRegistry::new()))
|
||||
.hook_executor(Arc::new(HookExecutor::new()))
|
||||
.session_memory_backend(store.clone())
|
||||
.build()
|
||||
.expect("RuntimeBundle 装配失败"),
|
||||
);
|
||||
|
||||
let analyst: Arc<dyn Agent> = Arc::new(AnalystAgent);
|
||||
let reporter: Arc<dyn Agent> = Arc::new(ReporterAgent);
|
||||
|
||||
let sm = Arc::new(SessionManager::new(store));
|
||||
let session_id = sm.create(analyst, bundle.clone()).await.expect("create");
|
||||
println!("[1] session created: {session_id}");
|
||||
|
||||
// 2. Analyst 跑一轮
|
||||
let resp1 = sm
|
||||
.submit_turn(&session_id, "Analyze Q3 sales data")
|
||||
.await
|
||||
.expect("submit_turn 1");
|
||||
println!("[2] analyst turn 1: {:?}", resp1.text());
|
||||
|
||||
// 3. 切换到 Reporter
|
||||
sm.switch_agent(&session_id, reporter)
|
||||
.await
|
||||
.expect("switch_agent");
|
||||
println!("[3] agent switched to 'reporter'");
|
||||
|
||||
// 4. Reporter 跑一轮(基于已有上下文)
|
||||
let resp2 = sm
|
||||
.submit_turn(&session_id, "Write a report based on the analysis")
|
||||
.await
|
||||
.expect("submit_turn 2");
|
||||
println!("[4] reporter turn 2: {:?}", resp2.text());
|
||||
|
||||
// 5. 验证 turn_index 连续
|
||||
let (turn_index, agent_name_owned) = {
|
||||
let session = sm.get(&session_id).await.unwrap();
|
||||
let guard = session.lock().await;
|
||||
(guard.turn_index(), guard.agent.name().to_string())
|
||||
};
|
||||
println!("\n[verify] turn_index = {turn_index}, agent = {agent_name_owned}");
|
||||
assert_eq!(turn_index, 2, "turn_index should be 2 after 2 turns");
|
||||
assert_eq!(
|
||||
agent_name_owned, "reporter",
|
||||
"current agent should be reporter"
|
||||
);
|
||||
println!("✓ context preserved across agent switch");
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! bridge_keys_demo —— bridge_keys 过滤 + 子↔子共享 namespace 示例。
|
||||
//! Required features: cargo run --example bridge_keys_demo --features "engine"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. 父 session 设置 SessionMemory(key: "project_goal", "constraints", "noise")
|
||||
//! 2. dispatch + bridge_keys = ["project_goal", "constraints"] → 只继承这两个
|
||||
//! 3. 验证子 session 读到的 session_memory 与过滤一致
|
||||
//! 4. 演示子↔子共享 namespace:dispatch 时设 `shared_namespace`,
|
||||
//! 子 A 写入 `shared:{parent_id}:fact_x`,子 B 通过约定 key 读取
|
||||
//!
|
||||
//! 运行:`cargo run --example bridge_keys_demo`
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agcore::agent::{Agent, AgentBuilder};
|
||||
use agcore::engine::{DispatchConfig, SessionManager};
|
||||
use agcore::llm::hooks::HookExecutor;
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::types::Usage;
|
||||
use agcore::llm::types::message::{ContentBlock, Message};
|
||||
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
|
||||
use agcore::memory::store::InMemoryStore;
|
||||
use agcore::tools::ToolRegistry;
|
||||
|
||||
struct WorkerAgent;
|
||||
|
||||
impl Agent for WorkerAgent {
|
||||
fn name(&self) -> &str {
|
||||
"worker"
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
Some("You are a worker.")
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant_text(text: &str) -> MessageResponse {
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: String::new(),
|
||||
message: Message::Assistant {
|
||||
content: vec![ContentBlock::Text { text: text.into() }],
|
||||
},
|
||||
usage: Usage::from_input_output(8, 4),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("=== Bridge Keys Demo ===\n");
|
||||
|
||||
let store: Arc<dyn agcore::memory::store::MemoryStore> = Arc::new(InMemoryStore::new());
|
||||
// 3 个 dispatch 调用需要 3 个 mock response
|
||||
let provider = Arc::new(MockProvider::new(vec![
|
||||
assistant_text("Worker 1: done"),
|
||||
assistant_text("Worker 2: done"),
|
||||
assistant_text("Worker 3: done"),
|
||||
]));
|
||||
let bundle = Arc::new(
|
||||
AgentBuilder::new()
|
||||
.provider(provider)
|
||||
.tool_registry(Arc::new(ToolRegistry::new()))
|
||||
.hook_executor(Arc::new(HookExecutor::new()))
|
||||
.session_memory_backend(store.clone())
|
||||
.build()
|
||||
.expect("RuntimeBundle"),
|
||||
);
|
||||
|
||||
let worker: Arc<dyn Agent> = Arc::new(WorkerAgent);
|
||||
|
||||
let sm = Arc::new(SessionManager::new(store));
|
||||
let parent_id = sm
|
||||
.create(worker.clone(), bundle.clone())
|
||||
.await
|
||||
.expect("create");
|
||||
println!("[1] parent session created: {parent_id}");
|
||||
|
||||
// 父 session_memory 写入 3 个 key
|
||||
{
|
||||
let session = sm.get(&parent_id).await.unwrap();
|
||||
let mut guard = session.lock().await;
|
||||
guard
|
||||
.set_session_data("project_goal", "Build a fast compiler")
|
||||
.await
|
||||
.unwrap();
|
||||
guard
|
||||
.set_session_data("constraints", "Rust, no unsafe")
|
||||
.await
|
||||
.unwrap();
|
||||
guard
|
||||
.set_session_data("noise", "should NOT be inherited")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
println!("[2] parent set 3 keys: project_goal, constraints, noise");
|
||||
|
||||
// dispatch + bridge_keys 过滤
|
||||
let config = DispatchConfig {
|
||||
bridge_keys: Some(vec!["project_goal".to_string(), "constraints".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
let result = sm
|
||||
.dispatch(&parent_id, worker.clone(), "do work", config)
|
||||
.await
|
||||
.expect("dispatch");
|
||||
println!(
|
||||
"[3] dispatched sub-agent (child_id={})\n",
|
||||
&result.child_id[..20]
|
||||
);
|
||||
|
||||
// 验证过滤效果
|
||||
let child_session = sm.get(&result.child_id).await.unwrap();
|
||||
let child_guard = child_session.lock().await;
|
||||
let inherited_goal = child_guard
|
||||
.session_memory()
|
||||
.get("project_goal")
|
||||
.await
|
||||
.unwrap();
|
||||
let inherited_constraint = child_guard
|
||||
.session_memory()
|
||||
.get("constraints")
|
||||
.await
|
||||
.unwrap();
|
||||
let filtered_noise = child_guard.session_memory().get("noise").await.unwrap();
|
||||
drop(child_guard);
|
||||
|
||||
println!("[verify] inherited keys in child session:");
|
||||
println!(" - project_goal: {:?}", inherited_goal);
|
||||
println!(" - constraints: {:?}", inherited_constraint);
|
||||
println!(" - noise: {:?} (should be None)", filtered_noise);
|
||||
|
||||
assert_eq!(inherited_goal, Some("Build a fast compiler".to_string()));
|
||||
assert_eq!(inherited_constraint, Some("Rust, no unsafe".to_string()));
|
||||
assert_eq!(filtered_noise, None, "noise should be filtered out");
|
||||
|
||||
println!("\n✓ bridge_keys filtering works correctly");
|
||||
|
||||
// =============== 第二部分:子↔子共享 namespace(convention)===============
|
||||
println!("\n=== Part 2: Child↔Child Shared Namespace (convention) ===\n");
|
||||
|
||||
// 关键点:`SessionMemory::get`/`set` 通过 session 自身 namespace 隔离
|
||||
// (每个 session 一个独立 namespace),所以"子↔子共享"不能直接通过 SessionMemory。
|
||||
// 真正的子↔子共享需要直接操作底层 MemoryStore,或由上层应用维护一个
|
||||
// 跨 session 的"共享通道"(例如独立的 namespace + 所有子 session 知道 key 前缀)。
|
||||
//
|
||||
// 本 demo 演示通过 DispatchConfig.shared_namespace(convention-based):
|
||||
// - `shared_namespace: Some(prefix)` 作为约定标记,告知子 agent
|
||||
// "你的数据共享 namespace 是 shared:{prefix}:*"
|
||||
// - 子 agent 自行通过 `sm.store()` 直接操作 MemoryStore(绕过 SessionMemory 的 namespace 隔离)
|
||||
//
|
||||
// 演示 2 个子 agent 通过约定 namespace prefix 共享数据。
|
||||
|
||||
let shared_ns_config = DispatchConfig {
|
||||
bridge_keys: Some(vec![]),
|
||||
shared_namespace: Some("parent-123".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// dispatch 第一个子 agent
|
||||
let _researcher_result = sm
|
||||
.dispatch(
|
||||
&parent_id,
|
||||
worker.clone(),
|
||||
"research task",
|
||||
shared_ns_config.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("dispatch researcher");
|
||||
|
||||
// 子 A 通过 `sm.store()` 直接写入共享 namespace key
|
||||
// (约定 prefix: "shared:parent-123:")
|
||||
let shared_key = "shared:parent-123:fact_architecture";
|
||||
sm.store()
|
||||
.save(agcore::memory::types::MemoryItem {
|
||||
id: shared_key.to_string(),
|
||||
content: "Microservices with event sourcing".to_string(),
|
||||
metadata: serde_json::json!({}),
|
||||
created_at: time::OffsetDateTime::now_utc(),
|
||||
})
|
||||
.await
|
||||
.expect("save shared fact");
|
||||
println!("[4] researcher wrote {shared_key}");
|
||||
|
||||
// dispatch 第二个子 agent
|
||||
let _writer_result = sm
|
||||
.dispatch(&parent_id, worker.clone(), "writing task", shared_ns_config)
|
||||
.await
|
||||
.expect("dispatch writer");
|
||||
|
||||
// 子 B 通过 `sm.store()` 直接读取共享 namespace key
|
||||
let read_item = sm.store().get(shared_key).await.expect("get");
|
||||
let read_fact = read_item.map(|i| i.content);
|
||||
println!("[5] writer reads {shared_key} = {read_fact:?}");
|
||||
|
||||
assert_eq!(
|
||||
read_fact,
|
||||
Some("Microservices with event sourcing".to_string()),
|
||||
"writer should read researcher's shared fact"
|
||||
);
|
||||
|
||||
println!("\n✓ child↔child shared namespace works correctly (via MemoryStore convention)");
|
||||
println!("\n=== All Bridge Keys Demo checks passed ===");
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! context_slot_demo —— 多上下文槽位管理示例。
|
||||
//! Required features: cargo run --example context_slot_demo --features "agent"
|
||||
//!
|
||||
//! 场景:法律咨询入口 → 派生两个独立探索方向 → 切换 → 隔离验证 → 删除。
|
||||
//!
|
||||
@@ -13,12 +14,12 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use agcore::agent::{Agent, AgentBuilder, AgentSession};
|
||||
use agcore::llm::LlmProvider;
|
||||
use agcore::llm::hooks::HookExecutor;
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::provider::LlmProvider;
|
||||
use agcore::llm::types::Usage;
|
||||
use agcore::llm::types::message::{ContentBlock, Message};
|
||||
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
|
||||
use agcore::llm::types::Usage;
|
||||
use agcore::tools::ToolRegistry;
|
||||
|
||||
struct LegalAdvisor;
|
||||
@@ -78,11 +79,19 @@ async fn main() {
|
||||
|
||||
println!("\n=== 3. 派生两个独立探索方向的 slot ===");
|
||||
session
|
||||
.derive_slot("option_jurisdiction", "default", agcore::agent::DeriveStrategy::Full)
|
||||
.derive_slot(
|
||||
"option_jurisdiction",
|
||||
"default",
|
||||
agcore::agent::DeriveStrategy::Full,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
session
|
||||
.derive_slot("option_amendment", "default", agcore::agent::DeriveStrategy::Full)
|
||||
.derive_slot(
|
||||
"option_amendment",
|
||||
"default",
|
||||
agcore::agent::DeriveStrategy::Full,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let slots: Vec<_> = session.list_slots().cloned().collect();
|
||||
@@ -158,4 +167,4 @@ fn message_contains(msg: &Message, needle: &str) -> bool {
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! conversation_memory_demo —— 对话记忆滑动窗口与隔离。
|
||||
//! Required features: cargo run --example conversation_memory_demo --features "memory"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. `ConversationMemoryConfig` 构造(SlidingWindow / Full 策略)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! custom_tool —— 自定义工具注册、单次 / 并行调用、权限检查。
|
||||
//! Required features: cargo run --example custom_tool --features "tools,llm"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. 实现 `BaseTool` trait(WeatherTool + DeleteFileTool)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! dispatch_stream_demo —— 流式子代理调度示例。
|
||||
//! Required features: cargo run --example dispatch_stream_demo --features "engine"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. 创建父 session
|
||||
//! 2. dispatch_stream 单个子 agent
|
||||
//! 3. 消费 SubTaskStreamEvent 序列
|
||||
//! 4. 验证事件序列:ChildCreated → Stream(...) × N → Completed
|
||||
//! 5. 验证:完成时 turn_index 已递增(finalize 副作用)
|
||||
//!
|
||||
//! 运行:`cargo run --example dispatch_stream_demo`
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agcore::agent::{Agent, AgentBuilder};
|
||||
use agcore::engine::{SessionManager, SubTaskStreamEvent};
|
||||
use agcore::llm::hooks::HookExecutor;
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::types::Usage;
|
||||
use agcore::llm::types::message::{ContentBlock, Message};
|
||||
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
|
||||
use agcore::memory::store::InMemoryStore;
|
||||
use agcore::tools::ToolRegistry;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
struct StreamWorkerAgent;
|
||||
|
||||
impl Agent for StreamWorkerAgent {
|
||||
fn name(&self) -> &str {
|
||||
"stream_worker"
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
Some("You are a streaming worker.")
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant_text(text: &str) -> MessageResponse {
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: String::new(),
|
||||
message: Message::Assistant {
|
||||
content: vec![ContentBlock::Text { text: text.into() }],
|
||||
},
|
||||
usage: Usage::from_input_output(8, 4),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("=== Dispatch Stream Demo ===\n");
|
||||
|
||||
let store: Arc<dyn agcore::memory::store::MemoryStore> = Arc::new(InMemoryStore::new());
|
||||
let provider = Arc::new(MockProvider::new(vec![assistant_text("streamed response")]));
|
||||
let bundle = Arc::new(
|
||||
AgentBuilder::new()
|
||||
.provider(provider)
|
||||
.tool_registry(Arc::new(ToolRegistry::new()))
|
||||
.hook_executor(Arc::new(HookExecutor::new()))
|
||||
.session_memory_backend(store.clone())
|
||||
.build()
|
||||
.expect("RuntimeBundle"),
|
||||
);
|
||||
|
||||
let worker: Arc<dyn Agent> = Arc::new(StreamWorkerAgent);
|
||||
|
||||
let sm = Arc::new(SessionManager::new(store));
|
||||
let parent_id = sm
|
||||
.create(worker.clone(), bundle.clone())
|
||||
.await
|
||||
.expect("create");
|
||||
println!("[1] parent session: {parent_id}");
|
||||
|
||||
// dispatch_stream
|
||||
let mut stream = sm
|
||||
.dispatch_stream(&parent_id, worker, "do streaming work", Default::default())
|
||||
.await
|
||||
.expect("dispatch_stream");
|
||||
|
||||
println!("[2] consuming SubTaskStreamEvent sequence...\n");
|
||||
let mut saw_child_created = false;
|
||||
let mut saw_stream_count = 0;
|
||||
let mut completed = None;
|
||||
|
||||
while let Some(event) = stream.next().await {
|
||||
match event {
|
||||
SubTaskStreamEvent::ChildCreated { child_id } => {
|
||||
println!(" → ChildCreated({})", &child_id[..20]);
|
||||
saw_child_created = true;
|
||||
}
|
||||
SubTaskStreamEvent::Stream(_) => {
|
||||
saw_stream_count += 1;
|
||||
}
|
||||
SubTaskStreamEvent::Completed(r) => {
|
||||
println!(
|
||||
" → Completed(child_id={}, {} tokens)",
|
||||
&r.child_id[..20],
|
||||
r.usage.total().total_tokens
|
||||
);
|
||||
completed = Some(r);
|
||||
break;
|
||||
}
|
||||
SubTaskStreamEvent::Error { child_id, error } => {
|
||||
panic!("unexpected error: child_id={child_id}, error={error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = completed.expect("Completed should arrive");
|
||||
|
||||
// 验证事件序列
|
||||
assert!(saw_child_created, "ChildCreated should be received");
|
||||
assert!(saw_stream_count > 0, "at least one Stream event");
|
||||
println!("\n[3] received {} stream events", saw_stream_count);
|
||||
|
||||
// 验证 finalize 已发生(turn_index 递增)
|
||||
let child_session = sm.get(&result.child_id).await.unwrap();
|
||||
let child_guard = child_session.lock().await;
|
||||
let child_turn_index = child_guard.turn_index();
|
||||
drop(child_guard);
|
||||
assert_eq!(
|
||||
child_turn_index, 1,
|
||||
"turn_index should increment after finalize"
|
||||
);
|
||||
println!("[4] child session turn_index = {child_turn_index} (finalize works)");
|
||||
|
||||
println!("\n✓ dispatch_stream completed successfully");
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! document_demo —— Document + RecursiveCharacterSplitter + MockEmbedding + RagPipeline 完整衔接示例。
|
||||
//! Required features: cargo run --example document_demo --features "memory,tracing-init"
|
||||
//!
|
||||
//! 演示 RAG 管线:
|
||||
//! 1. 创建多段落 Document
|
||||
@@ -37,11 +38,7 @@ async fn main() {
|
||||
let embedder: Arc<dyn Embedding> = Arc::new(MockEmbedding::new(4));
|
||||
let store: Arc<dyn VectorStore> = Arc::new(InMemoryVectorStore::new());
|
||||
let splitter = RecursiveCharacterSplitter::new(200, 30);
|
||||
let pipeline = RagPipeline::new(
|
||||
Arc::clone(&embedder),
|
||||
Arc::clone(&store),
|
||||
Some(splitter),
|
||||
);
|
||||
let pipeline = RagPipeline::new(Arc::clone(&embedder), Arc::clone(&store), Some(splitter));
|
||||
|
||||
// 3. 一次性 ingest:自动 split → embed → add
|
||||
pipeline.ingest(std::slice::from_ref(&doc)).await.unwrap();
|
||||
@@ -67,4 +64,4 @@ async fn main() {
|
||||
);
|
||||
|
||||
println!("\n✓ document_demo 完成");
|
||||
}
|
||||
}
|
||||
|
||||
+203
-64
@@ -1,4 +1,5 @@
|
||||
//! end_to_end —— 3 工具 + 3 轮对话 + SqliteStore 持久化跨连接验证。
|
||||
//! Required features: cargo run --example end_to_end --features "agent,memory-sqlite,provider-openai"
|
||||
//!
|
||||
//! 运行:`cargo run --example end_to_end`(离线,零配置)
|
||||
//!
|
||||
@@ -16,10 +17,15 @@ use std::env;
|
||||
use std::sync::Arc;
|
||||
|
||||
use agcore::agent::{Agent, AgentBuilder, AgentSession};
|
||||
use agcore::llm::LlmProvider;
|
||||
use agcore::llm::hooks::HookExecutor;
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::provider::{create_provider, LlmProvider, ProviderConfig, ProviderType};
|
||||
use agcore::llm::types::{Usage, message::{ContentBlock, Message}, response_v2::{MessageResponse, StopReason}};
|
||||
use agcore::llm::provider::{ProviderConfig, ProviderType, create_provider};
|
||||
use agcore::llm::types::{
|
||||
Usage,
|
||||
message::{ContentBlock, Message},
|
||||
response_v2::{MessageResponse, StopReason},
|
||||
};
|
||||
use agcore::memory::store::{MemoryStore, SqliteStore};
|
||||
use agcore::memory::types::{MemoryFilter, MemoryItem};
|
||||
use agcore::tools::{BaseTool, ToolContext, ToolError, ToolRegistry};
|
||||
@@ -32,8 +38,12 @@ use time::OffsetDateTime;
|
||||
|
||||
struct AssistantAgent;
|
||||
impl Agent for AssistantAgent {
|
||||
fn name(&self) -> &str { "end-to-end assistant" }
|
||||
fn system_prompt(&self) -> Option<&str> { Some("简洁助手,必要时调用工具完成任务。") }
|
||||
fn name(&self) -> &str {
|
||||
"end-to-end assistant"
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
Some("简洁助手,必要时调用工具完成任务。")
|
||||
}
|
||||
}
|
||||
|
||||
// === Tools ===
|
||||
@@ -41,14 +51,19 @@ impl Agent for AssistantAgent {
|
||||
struct EchoTool;
|
||||
#[async_trait]
|
||||
impl BaseTool for EchoTool {
|
||||
fn name(&self) -> &str { "echo" }
|
||||
fn description(&self) -> &str { "回显输入文本" }
|
||||
fn name(&self) -> &str {
|
||||
"echo"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"回显输入文本"
|
||||
}
|
||||
fn parameters(&self) -> Value {
|
||||
json!({"type":"object","properties":{"text":{"type":"string"}},"required":["text"]})
|
||||
}
|
||||
async fn execute(&self, args: Value, _: &ToolContext<'_>) -> Result<Value, ToolError> {
|
||||
let text = args.get("text").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidArguments("text".into(), "需要 string 类型的 text 参数".into()))?;
|
||||
let text = args.get("text").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
ToolError::InvalidArguments("text".into(), "需要 string 类型的 text 参数".into())
|
||||
})?;
|
||||
Ok(json!({"echoed": format!("收到: {text}")}))
|
||||
}
|
||||
}
|
||||
@@ -57,8 +72,12 @@ impl BaseTool for EchoTool {
|
||||
struct CalcTool;
|
||||
#[async_trait]
|
||||
impl BaseTool for CalcTool {
|
||||
fn name(&self) -> &str { "calc" }
|
||||
fn description(&self) -> &str { "四则运算:'a op b' 格式,op ∈ {+, -, *, /}" }
|
||||
fn name(&self) -> &str {
|
||||
"calc"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"四则运算:'a op b' 格式,op ∈ {+, -, *, /}"
|
||||
}
|
||||
fn parameters(&self) -> Value {
|
||||
json!({"type":"object","properties":{"expr":{"type":"string"}},"required":["expr"]})
|
||||
}
|
||||
@@ -66,18 +85,30 @@ impl BaseTool for CalcTool {
|
||||
let expr = args["expr"].as_str().unwrap_or("");
|
||||
let parts: Vec<&str> = expr.split_whitespace().collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(ToolError::InvalidArguments("expr".into(), "需要 'a op b' 三段式".into()));
|
||||
return Err(ToolError::InvalidArguments(
|
||||
"expr".into(),
|
||||
"需要 'a op b' 三段式".into(),
|
||||
));
|
||||
}
|
||||
let a: i64 = parts[0].parse().map_err(|_| ToolError::InvalidArguments("expr".into(), format!("无法解析 '{}'", parts[0])))?;
|
||||
let b: i64 = parts[2].parse().map_err(|_| ToolError::InvalidArguments("expr".into(), format!("无法解析 '{}'", parts[2])))?;
|
||||
let a: i64 = parts[0].parse().map_err(|_| {
|
||||
ToolError::InvalidArguments("expr".into(), format!("无法解析 '{}'", parts[0]))
|
||||
})?;
|
||||
let b: i64 = parts[2].parse().map_err(|_| {
|
||||
ToolError::InvalidArguments("expr".into(), format!("无法解析 '{}'", parts[2]))
|
||||
})?;
|
||||
let result = match parts[1] {
|
||||
"+" => a + b,
|
||||
"-" => a - b,
|
||||
"*" => a * b,
|
||||
"/" => a.checked_div(b).ok_or_else(|| {
|
||||
ToolError::InvalidArguments("expr".into(), "除数不能为 0".into())
|
||||
})?,
|
||||
op => return Err(ToolError::InvalidArguments("expr".into(), format!("不支持的运算符: {op}"))),
|
||||
"/" => a
|
||||
.checked_div(b)
|
||||
.ok_or_else(|| ToolError::InvalidArguments("expr".into(), "除数不能为 0".into()))?,
|
||||
op => {
|
||||
return Err(ToolError::InvalidArguments(
|
||||
"expr".into(),
|
||||
format!("不支持的运算符: {op}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(json!({"result": result}))
|
||||
}
|
||||
@@ -86,13 +117,21 @@ impl BaseTool for CalcTool {
|
||||
/// 通过 MemoryStore trait 读写笔记:直接持有 Arc<dyn MemoryStore>,
|
||||
/// 绕开 AgentSession 封装(NoteTool 在 tool.execute 中直接操作 store)。
|
||||
/// 关键前缀 "note:" 用于 list 过滤。
|
||||
struct NoteTool { store: Arc<dyn MemoryStore> }
|
||||
impl NoteTool { const PREFIX: &'static str = "note:"; }
|
||||
struct NoteTool {
|
||||
store: Arc<dyn MemoryStore>,
|
||||
}
|
||||
impl NoteTool {
|
||||
const PREFIX: &'static str = "note:";
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BaseTool for NoteTool {
|
||||
fn name(&self) -> &str { "note" }
|
||||
fn description(&self) -> &str { "笔记 save/query: save(key, content) / query()" }
|
||||
fn name(&self) -> &str {
|
||||
"note"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"笔记 save/query: save(key, content) / query()"
|
||||
}
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type":"object",
|
||||
@@ -116,18 +155,29 @@ impl BaseTool for NoteTool {
|
||||
metadata: json!({}),
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
};
|
||||
self.store.save(item).await
|
||||
self.store
|
||||
.save(item)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed("note".into(), e.to_string()))?;
|
||||
Ok(json!({"saved": key}))
|
||||
}
|
||||
"query" => {
|
||||
let filter = MemoryFilter { prefix: Some(Self::PREFIX.into()), ..Default::default() };
|
||||
let items = self.store.list(&filter).await
|
||||
let filter = MemoryFilter {
|
||||
prefix: Some(Self::PREFIX.into()),
|
||||
..Default::default()
|
||||
};
|
||||
let items = self
|
||||
.store
|
||||
.list(&filter)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed("note".into(), e.to_string()))?;
|
||||
let notes: Vec<String> = items.into_iter().map(|i| i.content).collect();
|
||||
Ok(json!({"notes": notes}))
|
||||
}
|
||||
_ => Err(ToolError::InvalidArguments("action".into(), format!("未知 action: {action}"))),
|
||||
_ => Err(ToolError::InvalidArguments(
|
||||
"action".into(),
|
||||
format!("未知 action: {action}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,31 +185,91 @@ impl BaseTool for NoteTool {
|
||||
// === Mock response helper ===
|
||||
|
||||
fn resp(content: Vec<ContentBlock>, stop: StopReason, u: (u32, u32)) -> MessageResponse {
|
||||
MessageResponse { id: String::new(), model: "mock".into(),
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: "mock".into(),
|
||||
message: Message::Assistant { content },
|
||||
usage: Usage::from_input_output(u.0, u.1),
|
||||
stop_reason: stop, extra: Default::default() }
|
||||
stop_reason: stop,
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_responses() -> Vec<MessageResponse> {
|
||||
vec![
|
||||
// 第 1 轮:calc(25 * 4) → tool_result(100) → 文本回答
|
||||
resp(vec![ContentBlock::ToolUse { id: "t1".into(), name: "calc".into(),
|
||||
input: json!({"expr": "25 * 4"}) }], StopReason::ToolUse, (5, 8)),
|
||||
resp(vec![ContentBlock::Text { text: "25 * 4 = 100".into() }], StopReason::Stop, (8, 12)),
|
||||
resp(
|
||||
vec![ContentBlock::ToolUse {
|
||||
id: "t1".into(),
|
||||
name: "calc".into(),
|
||||
input: json!({"expr": "25 * 4"}),
|
||||
}],
|
||||
StopReason::ToolUse,
|
||||
(5, 8),
|
||||
),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "25 * 4 = 100".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(8, 12),
|
||||
),
|
||||
// 第 2 轮:note(save, last_calc, "100") → tool_result(saved) → 文本回答
|
||||
resp(vec![ContentBlock::ToolUse { id: "t2".into(), name: "note".into(),
|
||||
input: json!({"action": "save", "key": "last_calc", "content": "100"}) }],
|
||||
StopReason::ToolUse, (10, 14)),
|
||||
resp(vec![ContentBlock::Text { text: "已记录:last_calc = 100".into() }], StopReason::Stop, (12, 16)),
|
||||
resp(
|
||||
vec![ContentBlock::ToolUse {
|
||||
id: "t2".into(),
|
||||
name: "note".into(),
|
||||
input: json!({"action": "save", "key": "last_calc", "content": "100"}),
|
||||
}],
|
||||
StopReason::ToolUse,
|
||||
(10, 14),
|
||||
),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "已记录:last_calc = 100".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(12, 16),
|
||||
),
|
||||
// 第 3 轮:note(query) → tool_result([100]) → 文本回答
|
||||
resp(vec![ContentBlock::ToolUse { id: "t3".into(), name: "note".into(),
|
||||
input: json!({"action": "query"}) }], StopReason::ToolUse, (8, 8)),
|
||||
resp(vec![ContentBlock::Text { text: "您刚才的计算结果是 100".into() }], StopReason::Stop, (10, 14)),
|
||||
resp(
|
||||
vec![ContentBlock::ToolUse {
|
||||
id: "t3".into(),
|
||||
name: "note".into(),
|
||||
input: json!({"action": "query"}),
|
||||
}],
|
||||
StopReason::ToolUse,
|
||||
(8, 8),
|
||||
),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "您刚才的计算结果是 100".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(10, 14),
|
||||
),
|
||||
// 后续冗余响应(防止队列耗尽报错)
|
||||
resp(vec![ContentBlock::Text { text: "done".into() }], StopReason::Stop, (1, 1)),
|
||||
resp(vec![ContentBlock::Text { text: "done".into() }], StopReason::Stop, (1, 1)),
|
||||
resp(vec![ContentBlock::Text { text: "done".into() }], StopReason::Stop, (1, 1)),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "done".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(1, 1),
|
||||
),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "done".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(1, 1),
|
||||
),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "done".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(1, 1),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -168,14 +278,21 @@ fn mock_responses() -> Vec<MessageResponse> {
|
||||
fn select_provider() -> Arc<dyn LlmProvider> {
|
||||
if env::var("AG_LLM_BASE_URL").is_ok() && env::var("AG_LLM_API_KEY").is_ok() {
|
||||
let cfg = ProviderConfig::from_env("AG_LLM").expect("AG_LLM_* 环境变量解析失败");
|
||||
let provider_type = env::var("AG_LLM_PROVIDER").ok()
|
||||
let provider_type = env::var("AG_LLM_PROVIDER")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<ProviderType>().ok())
|
||||
.unwrap_or(ProviderType::OpenaiChat);
|
||||
Arc::from(create_provider(provider_type, cfg).expect("Provider 创建失败"))
|
||||
} else {
|
||||
let found: Vec<&str> = ["AG_LLM_BASE_URL", "AG_LLM_API_KEY", "AG_LLM_MODEL"]
|
||||
.iter().filter(|k| env::var(k).is_ok()).copied().collect();
|
||||
eprintln!("AG_LLM_* 环境变量不完整(检测到: {:?}),回退到 MockProvider", found);
|
||||
.iter()
|
||||
.filter(|k| env::var(k).is_ok())
|
||||
.copied()
|
||||
.collect();
|
||||
eprintln!(
|
||||
"AG_LLM_* 环境变量不完整(检测到: {:?}),回退到 MockProvider",
|
||||
found
|
||||
);
|
||||
Arc::new(MockProvider::new(mock_responses()))
|
||||
}
|
||||
}
|
||||
@@ -190,52 +307,74 @@ async fn main() {
|
||||
let backend: Arc<dyn MemoryStore> =
|
||||
Arc::new(SqliteStore::open(&db_path).expect("SqliteStore 打开失败"));
|
||||
println!("💾 SqliteStore: {}", db_path.display());
|
||||
let provider_label = if env::var("AG_LLM_BASE_URL").is_ok() && env::var("AG_LLM_API_KEY").is_ok() {
|
||||
"真实 LLM Provider"
|
||||
} else {
|
||||
"MockProvider (离线回退模式)"
|
||||
};
|
||||
let provider_label =
|
||||
if env::var("AG_LLM_BASE_URL").is_ok() && env::var("AG_LLM_API_KEY").is_ok() {
|
||||
"真实 LLM Provider"
|
||||
} else {
|
||||
"MockProvider (离线回退模式)"
|
||||
};
|
||||
println!("🔄 Provider: {provider_label}");
|
||||
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Arc::new(EchoTool)).unwrap();
|
||||
registry.register(Arc::new(CalcTool)).unwrap();
|
||||
registry.register(Arc::new(NoteTool { store: backend.clone() })).unwrap();
|
||||
registry
|
||||
.register(Arc::new(NoteTool {
|
||||
store: backend.clone(),
|
||||
}))
|
||||
.unwrap();
|
||||
println!("🔧 注册工具: {:?}", registry.list_tools());
|
||||
|
||||
let bundle = Arc::new(AgentBuilder::new()
|
||||
.provider(select_provider())
|
||||
.tool_registry(Arc::new(registry))
|
||||
.hook_executor(Arc::new(HookExecutor::new()))
|
||||
.build().expect("RuntimeBundle 装配失败"));
|
||||
let bundle = Arc::new(
|
||||
AgentBuilder::new()
|
||||
.provider(select_provider())
|
||||
.tool_registry(Arc::new(registry))
|
||||
.hook_executor(Arc::new(HookExecutor::new()))
|
||||
.build()
|
||||
.expect("RuntimeBundle 装配失败"),
|
||||
);
|
||||
|
||||
let mut session = AgentSession::new(Arc::new(AssistantAgent), "e2e-1", bundle.clone());
|
||||
|
||||
println!("\n第 1 轮 用户: 帮我算 25 * 4");
|
||||
let r1 = session.submit_turn("帮我算 25 * 4").await.expect("turn 1 失败");
|
||||
let r1 = session
|
||||
.submit_turn("帮我算 25 * 4")
|
||||
.await
|
||||
.expect("turn 1 失败");
|
||||
println!(" → 回答: {}", r1.text());
|
||||
|
||||
println!("\n第 2 轮 用户: 记下来:结果是 100");
|
||||
let r2 = session.submit_turn("记下来:结果是 100").await.expect("turn 2 失败");
|
||||
let r2 = session
|
||||
.submit_turn("记下来:结果是 100")
|
||||
.await
|
||||
.expect("turn 2 失败");
|
||||
println!(" → 回答: {}", r2.text());
|
||||
|
||||
println!("\n第 3 轮 用户: 我刚才算了什么?");
|
||||
let r3 = session.submit_turn("我刚才算了什么?").await.expect("turn 3 失败");
|
||||
let r3 = session
|
||||
.submit_turn("我刚才算了什么?")
|
||||
.await
|
||||
.expect("turn 3 失败");
|
||||
println!(" → 回答: {}", r3.text());
|
||||
|
||||
let total = session.usage().total();
|
||||
println!("\n📊 用量: prompt={}, completion={}, total={}",
|
||||
total.prompt_tokens, total.completion_tokens, total.total_tokens);
|
||||
println!(
|
||||
"\n📊 用量: prompt={}, completion={}, total={}",
|
||||
total.prompt_tokens, total.completion_tokens, total.total_tokens
|
||||
);
|
||||
|
||||
println!("\n=== 持久化验证 ===");
|
||||
// 显式释放所有对 backend 的 Arc 引用,确保 SqliteStore Connection 真正关闭。
|
||||
// 释放顺序:session → bundle(间接持有 NoteTool → backend clone)→ backend 局部变量。
|
||||
drop(session); // session.bundle Arc 计数 -1
|
||||
drop(bundle); // bundle Arc 计数归零 → registry → NoteTool → backend clone Arc 计数 2→1
|
||||
drop(backend); // backend 局部变量 Arc 计数 1→0 → SqliteStore::drop → Connection 自动 close
|
||||
drop(session); // session.bundle Arc 计数 -1
|
||||
drop(bundle); // bundle Arc 计数归零 → registry → NoteTool → backend clone Arc 计数 2→1
|
||||
drop(backend); // backend 局部变量 Arc 计数 1→0 → SqliteStore::drop → Connection 自动 close
|
||||
let backend2: Arc<dyn MemoryStore> =
|
||||
Arc::new(SqliteStore::open(&db_path).expect("重开 SqliteStore 失败"));
|
||||
let filter = MemoryFilter { prefix: Some("note:".into()), ..Default::default() };
|
||||
let filter = MemoryFilter {
|
||||
prefix: Some("note:".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let items = backend2.list(&filter).await.expect("list 失败");
|
||||
println!("✓ 跨连接数据存活: 找到 {} 条 note", items.len());
|
||||
assert!(!items.is_empty(), "持久化验证失败:重开后无数据");
|
||||
@@ -244,4 +383,4 @@ async fn main() {
|
||||
}
|
||||
|
||||
println!("\n✓ 端到端演示完成");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
//! engine_demo —— SessionManager + Checkpointer 端到端示例。
|
||||
//! Required features: cargo run --example engine_demo --features "engine"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. SessionManager::create 创建 session
|
||||
//! 2. SessionManager::submit_turn(auto_checkpoint=true 自动写 checkpoint)
|
||||
//! 3. SessionManager::create_child 创建子 session
|
||||
//! 4. children() / parent() 树形查询
|
||||
//! 5. Checkpointer::list_checkpoints 列出所有 checkpoint
|
||||
//! 6. SessionManager::recover 从 checkpoint 恢复(模拟进程重启)
|
||||
//! 7. AgentSession::to_snapshot + SessionManager::replace 演示 rollback 流程
|
||||
//! 8. SessionManager::destroy 清理
|
||||
//!
|
||||
//! 运行:`cargo run --example engine_demo`
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agcore::agent::{Agent, AgentBuilder, AgentSession};
|
||||
use agcore::engine::SessionManager;
|
||||
use agcore::llm::hooks::HookExecutor;
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::types::Usage;
|
||||
use agcore::llm::types::message::{ContentBlock, Message};
|
||||
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
|
||||
use agcore::memory::store::InMemoryStore;
|
||||
use agcore::tools::ToolRegistry;
|
||||
|
||||
struct DemoAgent;
|
||||
|
||||
impl Agent for DemoAgent {
|
||||
fn name(&self) -> &str {
|
||||
"demo"
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
Some("你是 demo agent,每轮回复一句话。")
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant_text(text: &str) -> MessageResponse {
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: String::new(),
|
||||
message: Message::Assistant {
|
||||
content: vec![ContentBlock::Text { text: text.into() }],
|
||||
},
|
||||
usage: Usage::from_input_output(8, 4),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// 1. 准备底层组件
|
||||
let store: Arc<dyn agcore::memory::store::MemoryStore> = Arc::new(InMemoryStore::new());
|
||||
let provider = Arc::new(MockProvider::new(vec![
|
||||
assistant_text("turn 1 response"),
|
||||
assistant_text("turn 2 response"),
|
||||
assistant_text("turn 3 response"),
|
||||
assistant_text("child turn 1 response"),
|
||||
assistant_text("recovered turn response"),
|
||||
]));
|
||||
|
||||
let bundle = Arc::new(
|
||||
AgentBuilder::new()
|
||||
.provider(provider.clone())
|
||||
.tool_registry(Arc::new(ToolRegistry::new()))
|
||||
.hook_executor(Arc::new(HookExecutor::new()))
|
||||
.session_memory_backend(store.clone())
|
||||
.build()
|
||||
.expect("RuntimeBundle 装配失败"),
|
||||
);
|
||||
|
||||
let agent: Arc<dyn Agent> = Arc::new(DemoAgent);
|
||||
|
||||
// 2. 构造 SessionManager(auto_checkpoint 默认 true)
|
||||
let sm = SessionManager::new(store.clone());
|
||||
println!("=== SessionManager 创建 ===");
|
||||
|
||||
// 3. create + submit_turn(auto_checkpoint 触发)
|
||||
println!("\n=== 创建根 session + 跑 3 轮 ===");
|
||||
let parent_id = sm
|
||||
.create(agent.clone(), bundle.clone())
|
||||
.await
|
||||
.expect("create 失败");
|
||||
println!("parent_id = {parent_id}");
|
||||
|
||||
for i in 1..=3 {
|
||||
let _resp = sm
|
||||
.submit_turn(&parent_id, format!("turn {i}"))
|
||||
.await
|
||||
.expect("submit_turn 失败");
|
||||
}
|
||||
|
||||
// 写入自定义 session memory 数据(演示持久层往返)
|
||||
sm.get(&parent_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.lock()
|
||||
.await
|
||||
.set_session_data("design", "PostgreSQL")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 4. 显式 checkpoint(覆盖 auto_checkpoint 的 turn-level,写入额外快照)
|
||||
println!("\n=== Checkpointer 显式 checkpoint ===");
|
||||
let ckpt_id = sm
|
||||
.checkpointer()
|
||||
.checkpoint(&*sm.get(&parent_id).await.unwrap().lock().await)
|
||||
.await
|
||||
.expect("checkpoint 失败");
|
||||
println!("explicit ckpt_id = {ckpt_id}");
|
||||
|
||||
// 5. list_checkpoints
|
||||
let metas = sm
|
||||
.checkpointer()
|
||||
.list_checkpoints(&parent_id)
|
||||
.await
|
||||
.expect("list_checkpoints 失败");
|
||||
println!("parent session 有 {} 个 checkpoint", metas.len());
|
||||
for m in &metas {
|
||||
println!(
|
||||
" - ckpt_id={}, turn_index={}, created_at={}",
|
||||
m.ckpt_id, m.turn_index, m.created_at
|
||||
);
|
||||
}
|
||||
|
||||
// 6. create_child
|
||||
println!("\n=== 创建子 session ===");
|
||||
let child_id = sm
|
||||
.create_child(&parent_id, agent.clone())
|
||||
.await
|
||||
.expect("create_child 失败");
|
||||
println!("child_id = {child_id}");
|
||||
|
||||
let children = sm.children(&parent_id).await.expect("children 失败");
|
||||
assert_eq!(children, vec![child_id.clone()]);
|
||||
println!("children(parent) = {children:?}");
|
||||
|
||||
let parent_of_child = sm.parent(&child_id).await.expect("parent 失败");
|
||||
assert_eq!(parent_of_child, Some(parent_id.clone()));
|
||||
println!("parent({child_id}) = {parent_of_child:?}");
|
||||
|
||||
// 7. recover(模拟"进程重启"——新建 SessionManager 实例,但 store 复用)
|
||||
println!("\n=== 从存储恢复 session(模拟进程重启)===");
|
||||
let sm2 = SessionManager::new(store.clone());
|
||||
let recovered = sm2
|
||||
.recover(&parent_id, agent.clone(), bundle.clone())
|
||||
.await
|
||||
.expect("recover 失败");
|
||||
let recovered_session = recovered.lock().await;
|
||||
let v = recovered_session
|
||||
.get_session_data("design")
|
||||
.await
|
||||
.expect("get_session_data 失败");
|
||||
println!("recovered session_memory['design'] = {v:?}");
|
||||
assert_eq!(v, Some("PostgreSQL".into()));
|
||||
|
||||
// 8. 演示 rollback 流程:先记录当前 turn_index,再 rollback 到一个早期 checkpoint,
|
||||
// 验证 session_memory 和 turn_index 已恢复到 checkpoint 时刻
|
||||
println!("\n=== rollback 流程 ===");
|
||||
let (before_turn, before_cost) = {
|
||||
let s = sm.get(&parent_id).await.unwrap();
|
||||
let g = s.lock().await;
|
||||
(g.turn_index(), g.usage().total().total_tokens)
|
||||
};
|
||||
println!(
|
||||
"rollback 前 turn_index={}, total_tokens={}",
|
||||
before_turn, before_cost
|
||||
);
|
||||
|
||||
let metas = sm
|
||||
.checkpointer()
|
||||
.list_checkpoints(&parent_id)
|
||||
.await
|
||||
.expect("list_checkpoints 失败");
|
||||
assert!(metas.len() >= 2, "至少 2 个 checkpoint 才能演示 rollback");
|
||||
// 取第二个 checkpoint(不是最新的)作为 rollback 目标
|
||||
let rollback_ckpt = &metas[metas.len() - 2];
|
||||
println!("rollback 到 ckpt_id={}", rollback_ckpt.ckpt_id);
|
||||
|
||||
let snapshot = sm
|
||||
.checkpointer()
|
||||
.rollback_load(&parent_id, &rollback_ckpt.ckpt_id)
|
||||
.await
|
||||
.expect("rollback_load 失败");
|
||||
let snapshot_turn = snapshot.turn_index;
|
||||
let snapshot_data_count = snapshot.session_memory_data.len();
|
||||
println!(
|
||||
"checkpoint 时刻 turn_index={}, session_memory 条目数={}",
|
||||
snapshot_turn, snapshot_data_count
|
||||
);
|
||||
|
||||
let mut rolled_back = AgentSession::from_snapshot(snapshot, agent.clone(), bundle.clone())
|
||||
.expect("from_snapshot");
|
||||
rolled_back
|
||||
.restore_memory()
|
||||
.await
|
||||
.expect("restore_memory 失败");
|
||||
sm.replace(&parent_id, rolled_back)
|
||||
.await
|
||||
.expect("replace 失败");
|
||||
|
||||
// 验证 rollback 后状态与 checkpoint 一致
|
||||
let (after_turn, after_cost) = {
|
||||
let s = sm.get(&parent_id).await.unwrap();
|
||||
let g = s.lock().await;
|
||||
(g.turn_index(), g.usage().total().total_tokens)
|
||||
};
|
||||
println!(
|
||||
"rollback 后 turn_index={}, total_tokens={}",
|
||||
after_turn, after_cost
|
||||
);
|
||||
assert!(
|
||||
after_turn <= before_turn,
|
||||
"rollback 后 turn_index({}) 应 ≤ rollback 前({})",
|
||||
after_turn,
|
||||
before_turn
|
||||
);
|
||||
assert_eq!(
|
||||
after_turn, snapshot_turn,
|
||||
"rollback 后 turn_index 应等于 checkpoint 时刻值"
|
||||
);
|
||||
assert!(
|
||||
after_cost <= before_cost,
|
||||
"rollback 后 cost 应 ≤ rollback 前"
|
||||
);
|
||||
println!("✓ rollback + replace 一致性验证通过");
|
||||
|
||||
// 9. destroy 父子 session
|
||||
println!("\n=== 销毁 session ===");
|
||||
sm.destroy(&child_id).await.expect("destroy child 失败");
|
||||
sm.destroy(&parent_id).await.expect("destroy parent 失败");
|
||||
|
||||
// 验证清理
|
||||
assert!(sm.get(&parent_id).await.is_err());
|
||||
assert!(
|
||||
sm.checkpointer()
|
||||
.list_checkpoints(&parent_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
println!("✓ parent 已彻底清理(内存 + meta + checkpoints)");
|
||||
|
||||
// 验证孤儿语义:父被销毁后子仍存在但 parent() 返回 None
|
||||
println!("\n=== 孤儿策略演示(先创建父子,再仅销毁父)===");
|
||||
let p_id = sm.create(agent.clone(), bundle.clone()).await.unwrap();
|
||||
let c_id = sm.create_child(&p_id, agent.clone()).await.unwrap();
|
||||
sm.destroy(&p_id).await.unwrap();
|
||||
let p_of_c = sm.parent(&c_id).await.expect("parent 失败");
|
||||
assert_eq!(p_of_c, None, "父被销毁后 child.parent() 应为 None");
|
||||
println!("✓ child({c_id}) 仍是孤儿 session,parent() = None");
|
||||
|
||||
// 清理孤儿
|
||||
sm.destroy(&c_id).await.unwrap();
|
||||
|
||||
println!("\n✓ engine_demo 完成");
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! knowledge_graph_demo -- 知识图谱 + 双通道检索演示。
|
||||
//! Required features: cargo run --example knowledge_graph_demo --features "memory"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. 构建 KnowledgeGraph(实体 + 关系)
|
||||
//! 2. BFS 图遍历(get_related)
|
||||
//! 3. MemoryRetriever 双通道检索(Hybrid / GraphOnly / KnowledgeOnly)
|
||||
//! 4. 标签管理(set_entity_tags / find_tags)
|
||||
//!
|
||||
//! 运行:`cargo run --example knowledge_graph_demo`
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agcore::memory::{
|
||||
GraphEntity, GraphRelation, InMemoryGraph, InMemoryStore, KnowledgeGraph, KnowledgePage,
|
||||
KnowledgeStore, MemoryRetriever, MemoryStore, RelationDirection, RetrievalItem,
|
||||
RetrievalStrategy, RetrieverConfig,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
fn make_page(id: &str, title: &str, content: &str) -> KnowledgePage {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
KnowledgePage {
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
summary: content.chars().take(40).collect(),
|
||||
content: content.to_string(),
|
||||
tags: Vec::new(),
|
||||
references: Vec::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// ── 1. 构建知识图谱 ──
|
||||
println!("=== 1. 构建知识图谱 ===");
|
||||
let graph = Arc::new(InMemoryGraph::new());
|
||||
|
||||
let mut langchain = GraphEntity::new("langchain", "LangChain", "framework");
|
||||
langchain.description = "LLM application framework".to_string();
|
||||
let mut langgraph = GraphEntity::new("langgraph", "LangGraph", "framework");
|
||||
langgraph.description = "Graph-based agent runtime from LangChain".to_string();
|
||||
let mut langsmith = GraphEntity::new("langsmith", "LangSmith", "tool");
|
||||
langsmith.description = "Tracing and evaluation platform".to_string();
|
||||
let mut python = GraphEntity::new("python", "Python", "language");
|
||||
python.description = "Programming language".to_string();
|
||||
let mut rust = GraphEntity::new("rust", "Rust", "language");
|
||||
rust.description = "Systems programming language".to_string();
|
||||
|
||||
for e in [&langchain, &langgraph, &langsmith, &python, &rust] {
|
||||
graph.add_entity(e.clone()).await.unwrap();
|
||||
}
|
||||
graph
|
||||
.add_relation(GraphRelation::new(
|
||||
"langchain",
|
||||
"langgraph",
|
||||
"includes",
|
||||
0.9,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
graph
|
||||
.add_relation(GraphRelation::new(
|
||||
"langchain",
|
||||
"langsmith",
|
||||
"includes",
|
||||
0.7,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
graph
|
||||
.add_relation(GraphRelation::new(
|
||||
"langchain",
|
||||
"python",
|
||||
"built_with",
|
||||
0.95,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
graph
|
||||
.add_relation(GraphRelation::new("langgraph", "python", "depends_on", 0.8))
|
||||
.await
|
||||
.unwrap();
|
||||
println!("已添加 5 个实体 + 4 条关系");
|
||||
|
||||
// ── 2. BFS 图遍历 ──
|
||||
println!("\n=== 2. BFS 图遍历:从 LangChain 出发,depth=2 ===");
|
||||
let related = graph
|
||||
.get_related("langchain", 2, RelationDirection::Outgoing, None)
|
||||
.await
|
||||
.unwrap();
|
||||
for se in &related {
|
||||
println!(
|
||||
" {} (score={:.3}, path={:?})",
|
||||
se.entity.name, se.score, se.path
|
||||
);
|
||||
}
|
||||
assert!(!related.is_empty(), "应找到关联实体");
|
||||
|
||||
// ── 3. 标签管理 ──
|
||||
println!("\n=== 3. 标签管理 ===");
|
||||
graph
|
||||
.set_entity_tags(
|
||||
"langchain",
|
||||
vec!["ai".into(), "framework".into(), "llm".into()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
graph
|
||||
.set_entity_tags("langgraph", vec!["ai".into(), "agent".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
let tags = graph.find_tags("a").await.unwrap();
|
||||
println!("前缀 'a' 查找标签: {:?}", tags);
|
||||
let count = graph.entity_count_by_tag("ai").await.unwrap();
|
||||
println!("标签 'ai' 下实体数: {}", count);
|
||||
|
||||
// ── 4. 双通道检索 ──
|
||||
println!("\n=== 4. 双通道检索 ===");
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(InMemoryStore::new());
|
||||
let ks = KnowledgeStore::new(store);
|
||||
ks.add_page(make_page(
|
||||
"p1",
|
||||
"LangChain 框架介绍",
|
||||
"LangChain 是用于构建 LLM 应用的开源框架",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
ks.add_page(make_page(
|
||||
"p2",
|
||||
"Rust 异步编程",
|
||||
"Rust 异步基于 tokio 与 futures 抽象",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Hybrid 策略(默认)
|
||||
let retriever =
|
||||
MemoryRetriever::new(ks, RetrieverConfig::default()).with_knowledge_graph(graph.clone());
|
||||
println!("\n--- Hybrid 检索: 'langchain' ---");
|
||||
let result = retriever.retrieve("langchain").await.unwrap();
|
||||
println!("策略: {:?}", result.strategy);
|
||||
for item in &result.items {
|
||||
match item {
|
||||
RetrievalItem::KnowledgePage { page, score } => {
|
||||
println!(" [Store] {} (score={:.3})", page.title, score);
|
||||
}
|
||||
RetrievalItem::GraphEntity {
|
||||
entity,
|
||||
score,
|
||||
path,
|
||||
..
|
||||
} => {
|
||||
println!(
|
||||
" [Graph] {} (score={:.3}, path={:?})",
|
||||
entity.name, score, path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let has_store = result
|
||||
.items
|
||||
.iter()
|
||||
.any(|i| matches!(i, RetrievalItem::KnowledgePage { .. }));
|
||||
let has_graph = result
|
||||
.items
|
||||
.iter()
|
||||
.any(|i| matches!(i, RetrievalItem::GraphEntity { .. }));
|
||||
assert!(has_store, "Hybrid 应有 Store 结果");
|
||||
assert!(has_graph, "Hybrid 应有 Graph 结果");
|
||||
|
||||
// GraphOnly 策略
|
||||
let store2: Arc<dyn MemoryStore> = Arc::new(InMemoryStore::new());
|
||||
let ks2 = KnowledgeStore::new(store2);
|
||||
ks2.add_page(make_page("p1", "LangChain", "LLM framework"))
|
||||
.await
|
||||
.unwrap();
|
||||
let retriever_g = MemoryRetriever::new(ks2, RetrieverConfig::default())
|
||||
.with_knowledge_graph(graph.clone())
|
||||
.with_strategy(RetrievalStrategy::GraphOnly);
|
||||
println!("\n--- GraphOnly 检索: 'langchain' ---");
|
||||
let result = retriever_g.retrieve("langchain").await.unwrap();
|
||||
println!("策略: {:?}", result.strategy);
|
||||
for item in &result.items {
|
||||
match item {
|
||||
RetrievalItem::GraphEntity { entity, score, .. } => {
|
||||
println!(" [Graph] {} (score={:.3})", entity.name, score);
|
||||
}
|
||||
RetrievalItem::KnowledgePage { page, score, .. } => {
|
||||
println!(" [Store] {} (score={:.3})", page.title, score);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
result
|
||||
.items
|
||||
.iter()
|
||||
.all(|i| matches!(i, RetrievalItem::GraphEntity { .. })),
|
||||
"GraphOnly 应只返回 Graph 结果"
|
||||
);
|
||||
|
||||
println!("\n✓ knowledge_graph_demo 完成");
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! knowledge_search_demo —— 知识页面存储与关键词检索。
|
||||
//! Required features: cargo run --example knowledge_search_demo --features "memory"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. `KnowledgeStore` 存储多个 `KnowledgePage`
|
||||
@@ -74,8 +75,15 @@ async fn main() {
|
||||
let result = retriever.retrieve("Rust 异步").await.unwrap();
|
||||
println!("query: {}", result.query);
|
||||
for item in &result.items {
|
||||
println!(" 命中: {} (score={:.3})", item.page.title, item.score);
|
||||
assert!((0.0..=1.0).contains(&item.score), "score 应在 [0, 1] 区间");
|
||||
match item {
|
||||
agcore::memory::RetrievalItem::KnowledgePage { page, score } => {
|
||||
println!(" 命中: {} (score={:.3})", page.title, score);
|
||||
assert!((0.0..=1.0).contains(score), "score 应在 [0, 1] 区间");
|
||||
}
|
||||
agcore::memory::RetrievalItem::GraphEntity { entity, score, .. } => {
|
||||
println!(" 命中实体: {} (score={:.3})", entity.name, score);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(!result.items.is_empty(), "应至少命中一个页面");
|
||||
|
||||
@@ -89,6 +97,7 @@ async fn main() {
|
||||
let cfg = RetrieverConfig {
|
||||
max_results: 20,
|
||||
min_score: 0.5,
|
||||
graph_depth: 2,
|
||||
};
|
||||
let retriever2 = MemoryRetriever::new(ks2, cfg);
|
||||
let result = retriever2.retrieve("完全不相关的火锅配方").await.unwrap();
|
||||
@@ -111,6 +120,7 @@ async fn main() {
|
||||
let cfg = RetrieverConfig {
|
||||
max_results: 2,
|
||||
min_score: 0.0,
|
||||
graph_depth: 2,
|
||||
};
|
||||
let retriever3 = MemoryRetriever::new(ks3, cfg);
|
||||
let result = retriever3.retrieve("Rust").await.unwrap();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! prompt_composer —— 提示词模板与组合器离线示例。
|
||||
//! Required features: cargo run --example prompt_composer --features "prompt,llm"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. `PromptTemplate::compile` + `render` 变量插值(`{{var}}` 语法)
|
||||
|
||||
+55
-18
@@ -1,41 +1,61 @@
|
||||
//! quick_start —— 30 行最小可运行示例,展示 Agent / BaseTool / Builder / Session 四层抽象。
|
||||
//! Required features: cargo run --example quick_start --features "agent"
|
||||
//!
|
||||
//! 运行:`cargo run --example quick_start`(离线,零配置)
|
||||
|
||||
use std::sync::Arc;
|
||||
use agcore::agent::{Agent, AgentBuilder, AgentSession};
|
||||
use agcore::llm::LlmProvider;
|
||||
use agcore::llm::hooks::HookExecutor;
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::provider::LlmProvider;
|
||||
use agcore::llm::types::{Usage, message::{ContentBlock, Message}, response_v2::{MessageResponse, StopReason}};
|
||||
use agcore::llm::types::{
|
||||
Usage,
|
||||
message::{ContentBlock, Message},
|
||||
response_v2::{MessageResponse, StopReason},
|
||||
};
|
||||
use agcore::tools::{BaseTool, ToolContext, ToolError, ToolRegistry};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Greeter;
|
||||
impl Agent for Greeter {
|
||||
fn name(&self) -> &str { "greeter" }
|
||||
fn system_prompt(&self) -> Option<&str> { Some("中文助手,先调用 echo 工具,再总结。") }
|
||||
fn name(&self) -> &str {
|
||||
"greeter"
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
Some("中文助手,先调用 echo 工具,再总结。")
|
||||
}
|
||||
}
|
||||
|
||||
struct EchoTool;
|
||||
#[async_trait]
|
||||
impl BaseTool for EchoTool {
|
||||
fn name(&self) -> &str { "echo" }
|
||||
fn description(&self) -> &str { "回显文本" }
|
||||
fn name(&self) -> &str {
|
||||
"echo"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"回显文本"
|
||||
}
|
||||
fn parameters(&self) -> Value {
|
||||
json!({"type":"object","properties":{"text":{"type":"string"}},"required":["text"]})
|
||||
}
|
||||
async fn execute(&self, args: Value, _: &ToolContext<'_>) -> Result<Value, ToolError> {
|
||||
let text = args.get("text").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidArguments("text".into(), "需要 string 类型的 text 参数".into()))?;
|
||||
let text = args.get("text").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
ToolError::InvalidArguments("text".into(), "需要 string 类型的 text 参数".into())
|
||||
})?;
|
||||
Ok(json!({"echoed": format!("收到: {text}")}))
|
||||
}
|
||||
}
|
||||
|
||||
fn resp(content: Vec<ContentBlock>, stop: StopReason, u: (u32, u32)) -> MessageResponse {
|
||||
MessageResponse { id: String::new(), model: "mock".into(), message: Message::Assistant { content },
|
||||
usage: Usage::from_input_output(u.0, u.1), stop_reason: stop, extra: Default::default() }
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: "mock".into(),
|
||||
message: Message::Assistant { content },
|
||||
usage: Usage::from_input_output(u.0, u.1),
|
||||
stop_reason: stop,
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -43,14 +63,31 @@ async fn main() {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Arc::new(EchoTool)).unwrap();
|
||||
let provider: Arc<dyn LlmProvider> = Arc::new(MockProvider::new(vec![
|
||||
resp(vec![ContentBlock::ToolUse { id: "c1".into(), name: "echo".into(),
|
||||
input: json!({"text": "你好"}) }], StopReason::ToolUse, (5, 8)),
|
||||
resp(vec![ContentBlock::Text { text: "EchoTool 已收到您的消息并完成回传。".into() }],
|
||||
StopReason::Stop, (8, 16)),
|
||||
resp(
|
||||
vec![ContentBlock::ToolUse {
|
||||
id: "c1".into(),
|
||||
name: "echo".into(),
|
||||
input: json!({"text": "你好"}),
|
||||
}],
|
||||
StopReason::ToolUse,
|
||||
(5, 8),
|
||||
),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "EchoTool 已收到您的消息并完成回传。".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(8, 16),
|
||||
),
|
||||
]));
|
||||
let bundle = Arc::new(AgentBuilder::new()
|
||||
.provider(provider).tool_registry(Arc::new(registry))
|
||||
.hook_executor(Arc::new(HookExecutor::new())).build().unwrap());
|
||||
let bundle = Arc::new(
|
||||
AgentBuilder::new()
|
||||
.provider(provider)
|
||||
.tool_registry(Arc::new(registry))
|
||||
.hook_executor(Arc::new(HookExecutor::new()))
|
||||
.build()
|
||||
.unwrap(),
|
||||
);
|
||||
let mut session = AgentSession::new(Arc::new(Greeter), "qs", bundle);
|
||||
let resp = session.submit_turn("你好").await.unwrap();
|
||||
let text = resp.text();
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Required features: cargo run --example response_api_demo --features "full"
|
||||
//!
|
||||
//! 演示 OpenAI Response API(`POST /responses`)的基本用法。
|
||||
//!
|
||||
//! 环境变量:
|
||||
//! - `OPENAI_BASE_URL` — 默认 `https://api.openai.com/v1`
|
||||
//! - `OPENAI_API_KEY` — 必填
|
||||
//! - `OPENAI_MODEL` — 默认 `gpt-4o-mini`
|
||||
//!
|
||||
//! 本示例展示:
|
||||
//! - 单轮对话 + 多轮接续(全量消息历史)
|
||||
//! - 流式响应事件消费
|
||||
//! - 工具调用(Function Calling)单轮演示
|
||||
|
||||
use std::env;
|
||||
|
||||
use agcore::llm::provider::{ProviderConfig, ProviderType, create_provider};
|
||||
use agcore::llm::types::message::{ContentBlock, Message};
|
||||
use agcore::llm::types::request_v2::MessageRequest;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let api_key = env::var("OPENAI_API_KEY").expect("未设置 OPENAI_API_KEY 环境变量");
|
||||
let base_url =
|
||||
env::var("OPENAI_BASE_URL").unwrap_or_else(|_| "https://api.openai.com/v1".to_string());
|
||||
let model = env::var("OPENAI_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string());
|
||||
|
||||
let provider = create_provider(
|
||||
ProviderType::OpenaiResponse,
|
||||
ProviderConfig {
|
||||
base_url,
|
||||
api_key,
|
||||
model,
|
||||
timeout_secs: 30,
|
||||
max_retries: 3,
|
||||
},
|
||||
)
|
||||
.expect("创建 OpenAI Response Provider 失败");
|
||||
|
||||
// ===== 单轮对话 =====
|
||||
let request = MessageRequest {
|
||||
model: String::new(),
|
||||
messages: vec![
|
||||
Message::system("你是一个简洁的助手,对任何问题都用一句话回答。"),
|
||||
Message::user_text("Rust 的所有权机制是什么?"),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match provider.chat(request).await {
|
||||
Ok(resp) => {
|
||||
println!("[单轮] {}", resp.text());
|
||||
println!(
|
||||
"用量: {} 输入 / {} 输出\n",
|
||||
resp.usage.prompt_tokens, resp.usage.completion_tokens
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[单轮] 请求失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 多轮接续(全量历史) =====
|
||||
let request = MessageRequest {
|
||||
model: String::new(),
|
||||
messages: vec![
|
||||
Message::user_text("knock knock."),
|
||||
Message::Assistant {
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Who's there?".into(),
|
||||
}],
|
||||
},
|
||||
Message::user_text("Orange."),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match provider.chat(request).await {
|
||||
Ok(resp) => println!("[多轮] {}", resp.text()),
|
||||
Err(e) => eprintln!("[多轮] 请求失败: {e}"),
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Required features: cargo run --example simple_visit --features "llm,provider-openai,tracing-init"
|
||||
|
||||
use std::env;
|
||||
|
||||
use agcore::init_tracing;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! streaming_events_demo —— LLM 流式响应事件流消费(含错误路径)。
|
||||
//! Required features: cargo run --example streaming_events_demo --features "llm,provider-openai"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. `MockProvider::chat_stream` 输出标准 `StreamEvent` 流(离线可跑)
|
||||
@@ -14,9 +15,9 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agcore::llm::LlmProvider;
|
||||
use agcore::llm::cycle::{CycleConfig, LlmCycle};
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::provider::LlmProvider;
|
||||
use agcore::llm::types::Usage;
|
||||
use agcore::llm::types::message::{ContentBlock, Message};
|
||||
use agcore::llm::types::response_v2::{MessageResponse, StopReason, StreamEvent};
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! sub_agent_dispatch_demo —— SubAgent 并行派发示例。
|
||||
//! Required features: cargo run --example sub_agent_dispatch_demo --features "engine"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. 创建父 session("主编" agent)
|
||||
//! 2. 并行 dispatch_all 3 个子 agent(研究员 / 写手 / 审校)
|
||||
//! 3. 收集子任务结果
|
||||
//! 4. 验证树形结构:children(parent_id) 应返回 3 个子 ID
|
||||
//!
|
||||
//! 运行:`cargo run --example sub_agent_dispatch_demo`
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agcore::agent::{Agent, AgentBuilder};
|
||||
use agcore::engine::{SessionManager, SubTaskResult};
|
||||
use agcore::llm::hooks::HookExecutor;
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::types::Usage;
|
||||
use agcore::llm::types::message::{ContentBlock, Message};
|
||||
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
|
||||
use agcore::memory::store::InMemoryStore;
|
||||
use agcore::tools::ToolRegistry;
|
||||
|
||||
struct EditorAgent;
|
||||
struct ResearcherAgent;
|
||||
struct WriterAgent;
|
||||
struct ReviewerAgent;
|
||||
|
||||
impl Agent for EditorAgent {
|
||||
fn name(&self) -> &str {
|
||||
"editor"
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
Some("You are an editor coordinating a team.")
|
||||
}
|
||||
}
|
||||
impl Agent for ResearcherAgent {
|
||||
fn name(&self) -> &str {
|
||||
"researcher"
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
Some("You are a researcher. Provide 3 key findings.")
|
||||
}
|
||||
}
|
||||
impl Agent for WriterAgent {
|
||||
fn name(&self) -> &str {
|
||||
"writer"
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
Some("You are a writer. Draft a section.")
|
||||
}
|
||||
}
|
||||
impl Agent for ReviewerAgent {
|
||||
fn name(&self) -> &str {
|
||||
"reviewer"
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
Some("You are a reviewer. Check for accuracy.")
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant_text(text: &str) -> MessageResponse {
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: String::new(),
|
||||
message: Message::Assistant {
|
||||
content: vec![ContentBlock::Text { text: text.into() }],
|
||||
},
|
||||
usage: Usage::from_input_output(8, 4),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_result(name: &str, r: &Result<SubTaskResult, agcore::engine::EngineError>) {
|
||||
match r {
|
||||
Ok(res) => println!(
|
||||
" ✓ {name} (child_id={}): {} tokens",
|
||||
&res.child_id[..20.min(res.child_id.len())],
|
||||
res.usage.total().total_tokens,
|
||||
),
|
||||
Err(e) => println!(" ✗ {name}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("=== SubAgent Dispatch Demo ===\n");
|
||||
|
||||
let store: Arc<dyn agcore::memory::store::MemoryStore> = Arc::new(InMemoryStore::new());
|
||||
let provider = Arc::new(MockProvider::new(vec![
|
||||
assistant_text("Researcher: finding 1, 2, 3"),
|
||||
assistant_text("Writer: section drafted"),
|
||||
assistant_text("Reviewer: looks good"),
|
||||
]));
|
||||
let bundle = Arc::new(
|
||||
AgentBuilder::new()
|
||||
.provider(provider)
|
||||
.tool_registry(Arc::new(ToolRegistry::new()))
|
||||
.hook_executor(Arc::new(HookExecutor::new()))
|
||||
.session_memory_backend(store.clone())
|
||||
.build()
|
||||
.expect("RuntimeBundle"),
|
||||
);
|
||||
|
||||
let editor: Arc<dyn Agent> = Arc::new(EditorAgent);
|
||||
let researcher: Arc<dyn Agent> = Arc::new(ResearcherAgent);
|
||||
let writer: Arc<dyn Agent> = Arc::new(WriterAgent);
|
||||
let reviewer: Arc<dyn Agent> = Arc::new(ReviewerAgent);
|
||||
|
||||
let sm = Arc::new(SessionManager::new(store));
|
||||
let parent_id = sm.create(editor, bundle.clone()).await.expect("create");
|
||||
println!("[1] parent session created: {parent_id}");
|
||||
|
||||
// dispatch_all 3 个子 agent
|
||||
println!("[2] dispatching 3 sub-agents in parallel...\n");
|
||||
let results = sm
|
||||
.dispatch_all(
|
||||
&parent_id,
|
||||
vec![
|
||||
(researcher, "Research topic X".to_string()),
|
||||
(writer, "Draft intro section".to_string()),
|
||||
(reviewer, "Review draft".to_string()),
|
||||
],
|
||||
agcore::engine::DispatchConfig::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
print_result("researcher", &results[0]);
|
||||
print_result("writer", &results[1]);
|
||||
print_result("reviewer", &results[2]);
|
||||
|
||||
let success_count = results.iter().filter(|r| r.is_ok()).count();
|
||||
assert_eq!(success_count, 3, "all 3 should succeed");
|
||||
|
||||
// 验证树形
|
||||
let children = sm.children(&parent_id).await.expect("children");
|
||||
println!("\n[3] children(parent) = {} session(s)", children.len());
|
||||
assert_eq!(children.len(), 3);
|
||||
|
||||
println!("\n✓ dispatch_all completed: 3/3 sub-agents succeeded");
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! task_agent_demo —— Plan 解析、Step 状态机、错误路径。
|
||||
//! Required features: cargo run --example task_agent_demo --features "agent"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. `JsonPlanParser::parse` 解析合法 JSON 输入
|
||||
@@ -12,9 +13,9 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use agcore::agent::{AgentError, JsonPlanParser, PlanParser, Step, StepStatus};
|
||||
use agcore::llm::types::Usage;
|
||||
use agcore::llm::types::message::Message;
|
||||
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
|
||||
use agcore::llm::types::Usage;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@@ -111,4 +112,4 @@ async fn main() {
|
||||
assert!(matches!(err, AgentError::PlanParse(_)));
|
||||
|
||||
println!("\n✓ task_agent_demo 完成");
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -16,18 +16,20 @@ pub mod error;
|
||||
pub mod runtime;
|
||||
pub mod session;
|
||||
pub mod session_memory;
|
||||
pub mod summary;
|
||||
pub mod task;
|
||||
|
||||
// 重导出公共 API(按使用频度排序)
|
||||
pub use agent::Agent;
|
||||
pub use builder::AgentBuilder;
|
||||
pub use context::{
|
||||
ContextBudget, ContextSlot, DeriveStrategy, FocusedConfig, MergeStrategy, SlotConfig,
|
||||
SlotMeta, SlotMode, SlotSource,
|
||||
ContextBudget, ContextSlot, DeriveStrategy, FocusedConfig, MergeStrategy, SlotConfig, SlotMeta,
|
||||
SlotMode, SlotSource,
|
||||
};
|
||||
pub use error::AgentError;
|
||||
pub use runtime::{AgentConfig, RuntimeBundle};
|
||||
pub use session::AgentSession;
|
||||
pub use session_memory::SessionMemory;
|
||||
pub use summary::SummaryConfig;
|
||||
pub use task::JsonPlanParser;
|
||||
pub use task::{Plan, PlanParser, Step, StepStatus, TaskAgent};
|
||||
|
||||
+12
-2
@@ -11,8 +11,9 @@ use std::sync::Arc;
|
||||
|
||||
use crate::agent::error::AgentError;
|
||||
use crate::agent::runtime::{AgentConfig, RuntimeBundle};
|
||||
use crate::agent::summary::SummaryConfig;
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::llm::hooks::HookExecutor;
|
||||
use crate::llm::provider::LlmProvider;
|
||||
use crate::memory::retriever::MemoryRetriever;
|
||||
use crate::memory::store::MemoryStore;
|
||||
use crate::tools::ToolRegistry;
|
||||
@@ -86,6 +87,15 @@ impl AgentBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置摘要自动生成配置(覆盖字段,而非整体覆盖 config)。
|
||||
/// 不传则沿用现有 `config.summary_config`(默认 `None`,即关闭)。
|
||||
pub fn summary_config(mut self, cfg: SummaryConfig) -> Self {
|
||||
let mut config = self.config.take().unwrap_or_default();
|
||||
config.summary_config = Some(cfg);
|
||||
self.config = Some(config);
|
||||
self
|
||||
}
|
||||
|
||||
/// 构造 `RuntimeBundle`,校验必填字段。
|
||||
///
|
||||
/// **错误**:`provider` / `tool_registry` / `hook_executor` 任一缺失则返回
|
||||
@@ -122,9 +132,9 @@ impl AgentBuilder {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
|
||||
use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
use async_trait::async_trait;
|
||||
use futures_core::Stream;
|
||||
use std::pin::Pin;
|
||||
|
||||
+142
-49
@@ -17,7 +17,7 @@ use crate::memory::store::MemoryStore;
|
||||
use crate::memory::types::{MemoryFilter, MemoryItem};
|
||||
|
||||
/// 上下文槽 —— 一段带策略配置的消息列表。
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContextSlot {
|
||||
/// 当前 slot 的唯一标识(同一个 session_id 内唯一)。
|
||||
pub id: String,
|
||||
@@ -74,8 +74,9 @@ pub struct FocusedConfig {
|
||||
pub keep_system: bool,
|
||||
/// 保留的最近消息条数(以消息条数而非对话轮次为单位,因为一轮对话可能包含多条 tool 消息)。
|
||||
pub recent_messages: usize,
|
||||
/// 摘要覆盖(v0.2 仅消费端:手动设置则注入,不自动生成)。
|
||||
/// v0.3 将支持 Hook 驱动的自动摘要生成。
|
||||
/// 摘要覆盖(消费端:手动或自动生成的摘要会注入到消息列表末尾)。
|
||||
/// v0.3 Phase 16 起,`AgentBuilder::summary_config(cfg)` 内联检查点会
|
||||
/// 自动调用 LLM 生成摘要并写入此字段,详见 `docs/22-phase16-summary-auto-generation.md`。
|
||||
pub summary_override: Option<String>,
|
||||
}
|
||||
|
||||
@@ -105,8 +106,8 @@ pub enum DeriveStrategy {
|
||||
|
||||
/// 合并策略 —— Phase 13 新增,控制 `ContextSlot::merge` 如何将子 slot 消息合入父 slot。
|
||||
///
|
||||
/// `#[non_exhaustive]` 允许 Phase 16 加入 `Summarize` 变体而不破坏现有匹配。
|
||||
#[derive(Debug, Clone)]
|
||||
/// `#[non_exhaustive]` 预留未来扩展(如 `Summarize` 变体)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum MergeStrategy {
|
||||
/// 子 slot 消息追加到父 slot 末尾。
|
||||
@@ -249,12 +250,12 @@ impl ContextSlot {
|
||||
|
||||
/// 保存 slot 数据到存储后端(全量写入,含 config)。
|
||||
pub async fn save(&self, store: &dyn MemoryStore) -> Result<(), AgentError> {
|
||||
let data = serde_json::to_string(&self.messages)
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
let meta = serde_json::to_string(&self.meta)
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
let config = serde_json::to_string(&self.config)
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
let data =
|
||||
serde_json::to_string(&self.messages).map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
let meta =
|
||||
serde_json::to_string(&self.meta).map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
let config =
|
||||
serde_json::to_string(&self.config).map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
|
||||
store
|
||||
.save(Self::make_item(
|
||||
@@ -550,8 +551,10 @@ mod tests {
|
||||
async fn slot_save_load_roundtrip() {
|
||||
let store = make_store();
|
||||
let mut slot = make_slot("default", "s1");
|
||||
slot.append_messages(vec![Message::user_text("hi")]).unwrap();
|
||||
slot.append_messages(vec![Message::assistant("hello")]).unwrap();
|
||||
slot.append_messages(vec![Message::user_text("hi")])
|
||||
.unwrap();
|
||||
slot.append_messages(vec![Message::assistant("hello")])
|
||||
.unwrap();
|
||||
|
||||
slot.save(&*store).await.unwrap();
|
||||
let loaded = ContextSlot::load("default", "s1", &*store).await.unwrap();
|
||||
@@ -575,8 +578,14 @@ mod tests {
|
||||
.unwrap();
|
||||
b.save(&*store).await.unwrap();
|
||||
|
||||
let loaded_a = ContextSlot::load("main", "sA", &*store).await.unwrap().unwrap();
|
||||
let loaded_b = ContextSlot::load("main", "sB", &*store).await.unwrap().unwrap();
|
||||
let loaded_a = ContextSlot::load("main", "sA", &*store)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let loaded_b = ContextSlot::load("main", "sB", &*store)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(extract_text(&loaded_a.messages[0]), "only in A");
|
||||
assert_eq!(extract_text(&loaded_b.messages[0]), "only in B");
|
||||
}
|
||||
@@ -621,10 +630,13 @@ mod tests {
|
||||
async fn slot_delete_then_load_none() {
|
||||
let store = make_store();
|
||||
let mut slot = make_slot("to_delete", "s1");
|
||||
slot.append_messages(vec![Message::user_text("hi")]).unwrap();
|
||||
slot.append_messages(vec![Message::user_text("hi")])
|
||||
.unwrap();
|
||||
slot.save(&*store).await.unwrap();
|
||||
|
||||
ContextSlot::delete("to_delete", "s1", &*store).await.unwrap();
|
||||
ContextSlot::delete("to_delete", "s1", &*store)
|
||||
.await
|
||||
.unwrap();
|
||||
let loaded = ContextSlot::load("to_delete", "s1", &*store).await.unwrap();
|
||||
assert!(loaded.is_none());
|
||||
}
|
||||
@@ -722,7 +734,10 @@ mod tests {
|
||||
let store = make_store();
|
||||
let slot = make_slot("empty", "s1");
|
||||
slot.save(&*store).await.unwrap();
|
||||
let loaded = ContextSlot::load("empty", "s1", &*store).await.unwrap().unwrap();
|
||||
let loaded = ContextSlot::load("empty", "s1", &*store)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(loaded.messages.is_empty());
|
||||
assert_eq!(loaded.meta.message_count, 0);
|
||||
}
|
||||
@@ -744,7 +759,8 @@ mod tests {
|
||||
async fn derive_full_copies_parent_messages() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
for i in 0..3 {
|
||||
parent.append_messages(vec![Message::user_text(format!("u{i}"))])
|
||||
parent
|
||||
.append_messages(vec![Message::user_text(format!("u{i}"))])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -768,7 +784,10 @@ mod tests {
|
||||
let store = make_store();
|
||||
child.save(&*store).await.unwrap();
|
||||
|
||||
let loaded = ContextSlot::load("c", "s1", &*store).await.unwrap().unwrap();
|
||||
let loaded = ContextSlot::load("c", "s1", &*store)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(loaded.messages.len(), 3);
|
||||
assert!(matches!(loaded.config.source, SlotSource::Derived { .. }));
|
||||
}
|
||||
@@ -776,9 +795,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn derive_focused_filters_parent_messages() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::system("sys")]).unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::system("sys")])
|
||||
.unwrap();
|
||||
for i in 0..5 {
|
||||
parent.append_messages(vec![Message::user_text(format!("u{i}"))])
|
||||
parent
|
||||
.append_messages(vec![Message::user_text(format!("u{i}"))])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -817,7 +839,9 @@ mod tests {
|
||||
async fn derived_slot_loadable_independently() {
|
||||
let store = make_store();
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("u")]).unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::user_text("u")])
|
||||
.unwrap();
|
||||
parent.save(&*store).await.unwrap();
|
||||
|
||||
// 派生 child
|
||||
@@ -834,12 +858,16 @@ mod tests {
|
||||
compact: true,
|
||||
},
|
||||
);
|
||||
child.append_messages(vec![Message::user_text("derived msg")])
|
||||
child
|
||||
.append_messages(vec![Message::user_text("derived msg")])
|
||||
.unwrap();
|
||||
child.save(&*store).await.unwrap();
|
||||
|
||||
// child 可独立加载
|
||||
let loaded = ContextSlot::load("c", "s1", &*store).await.unwrap().unwrap();
|
||||
let loaded = ContextSlot::load("c", "s1", &*store)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(loaded.messages.len(), 1);
|
||||
assert_eq!(extract_text(&loaded.messages[0]), "derived msg");
|
||||
}
|
||||
@@ -866,17 +894,59 @@ mod tests {
|
||||
slot.save(&*store).await.unwrap();
|
||||
|
||||
// 确认所有记录存在
|
||||
assert!(store.get(&ContextSlot::data_key("s1", "x")).await.unwrap().is_some());
|
||||
assert!(store.get(&ContextSlot::meta_key("s1", "x")).await.unwrap().is_some());
|
||||
assert!(store.get(&ContextSlot::config_key("s1", "x")).await.unwrap().is_some());
|
||||
assert!(store.get(&ContextSlot::rel_key("s1", "x")).await.unwrap().is_some());
|
||||
assert!(
|
||||
store
|
||||
.get(&ContextSlot::data_key("s1", "x"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get(&ContextSlot::meta_key("s1", "x"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get(&ContextSlot::config_key("s1", "x"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get(&ContextSlot::rel_key("s1", "x"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
|
||||
ContextSlot::delete("x", "s1", &*store).await.unwrap();
|
||||
|
||||
// data/meta/config 已删
|
||||
assert!(store.get(&ContextSlot::data_key("s1", "x")).await.unwrap().is_none());
|
||||
assert!(store.get(&ContextSlot::meta_key("s1", "x")).await.unwrap().is_none());
|
||||
assert!(store.get(&ContextSlot::config_key("s1", "x")).await.unwrap().is_none());
|
||||
assert!(
|
||||
store
|
||||
.get(&ContextSlot::data_key("s1", "x"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get(&ContextSlot::meta_key("s1", "x"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get(&ContextSlot::config_key("s1", "x"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 基础类型测试 =====
|
||||
@@ -892,7 +962,10 @@ mod tests {
|
||||
#[test]
|
||||
fn context_budget_default_sum_128k() {
|
||||
let b = ContextBudget::default();
|
||||
assert_eq!(b.system + b.history + b.tools + b.tool_results + b.reserve, 128_000);
|
||||
assert_eq!(
|
||||
b.system + b.history + b.tools + b.tool_results + b.reserve,
|
||||
128_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -938,10 +1011,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn filter_focused_injects_summary() {
|
||||
let messages = vec![
|
||||
Message::user_text("u"),
|
||||
Message::assistant("a"),
|
||||
];
|
||||
let messages = vec![Message::user_text("u"), Message::assistant("a")];
|
||||
let cfg = FocusedConfig {
|
||||
keep_system: false,
|
||||
recent_messages: 100,
|
||||
@@ -963,8 +1033,12 @@ mod tests {
|
||||
#[test]
|
||||
fn fork_full_copies_messages() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("a")]).unwrap();
|
||||
parent.append_messages(vec![Message::assistant("b")]).unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::user_text("a")])
|
||||
.unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::assistant("b")])
|
||||
.unwrap();
|
||||
let child = parent.fork("c".into(), DeriveStrategy::Full);
|
||||
assert_eq!(child.messages.len(), 2);
|
||||
assert!(matches!(child.config.mode, SlotMode::Full));
|
||||
@@ -973,7 +1047,9 @@ mod tests {
|
||||
#[test]
|
||||
fn fork_focused_filters_messages() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::system("sys")]).unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::system("sys")])
|
||||
.unwrap();
|
||||
for i in 0..5 {
|
||||
parent
|
||||
.append_messages(vec![Message::user_text(format!("u{i}"))])
|
||||
@@ -993,7 +1069,9 @@ mod tests {
|
||||
#[test]
|
||||
fn fork_preserves_independence() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("a")]).unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::user_text("a")])
|
||||
.unwrap();
|
||||
let mut child = parent.fork("c".into(), DeriveStrategy::Full);
|
||||
let child_count_at_fork = child.messages.len();
|
||||
|
||||
@@ -1002,7 +1080,9 @@ mod tests {
|
||||
.append_messages(vec![Message::user_text("b")])
|
||||
.unwrap();
|
||||
// 子 slot 追加
|
||||
child.append_messages(vec![Message::user_text("c")]).unwrap();
|
||||
child
|
||||
.append_messages(vec![Message::user_text("c")])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(parent.messages.len(), 2);
|
||||
assert_eq!(child.messages.len(), child_count_at_fork + 1);
|
||||
@@ -1012,10 +1092,15 @@ mod tests {
|
||||
#[test]
|
||||
fn fork_sets_derived_source() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("a")]).unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::user_text("a")])
|
||||
.unwrap();
|
||||
let child = parent.fork("c".into(), DeriveStrategy::Full);
|
||||
match &child.config.source {
|
||||
SlotSource::Derived { parent_id, strategy } => {
|
||||
SlotSource::Derived {
|
||||
parent_id,
|
||||
strategy,
|
||||
} => {
|
||||
assert_eq!(parent_id, "p");
|
||||
assert!(matches!(strategy, DeriveStrategy::Full));
|
||||
}
|
||||
@@ -1029,7 +1114,9 @@ mod tests {
|
||||
#[test]
|
||||
fn merge_append_appends_messages() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("p1")]).unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::user_text("p1")])
|
||||
.unwrap();
|
||||
let child = {
|
||||
let mut c = parent.fork("c".into(), DeriveStrategy::Full);
|
||||
// fork 时 child 继承父的 "p1";再追加一条 c1
|
||||
@@ -1048,8 +1135,12 @@ mod tests {
|
||||
#[test]
|
||||
fn merge_replace_replaces_messages() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("p1")]).unwrap();
|
||||
parent.append_messages(vec![Message::user_text("p2")]).unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::user_text("p1")])
|
||||
.unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::user_text("p2")])
|
||||
.unwrap();
|
||||
let child = {
|
||||
let mut c = parent.fork("c".into(), DeriveStrategy::Full);
|
||||
// 清空 child 再追加
|
||||
@@ -1087,7 +1178,9 @@ mod tests {
|
||||
#[test]
|
||||
fn merge_cross_session_rejected() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("a")]).unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::user_text("a")])
|
||||
.unwrap();
|
||||
let child = ContextSlot::new("OTHER_SESSION", "c", SlotConfig::default());
|
||||
let err = parent.merge(child, MergeStrategy::Append).unwrap_err();
|
||||
assert!(matches!(err, AgentError::Config(_)));
|
||||
@@ -1120,4 +1213,4 @@ mod tests {
|
||||
.unwrap()
|
||||
.block_on(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::agent::summary::SummaryConfig;
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::llm::compact::CompactConfig;
|
||||
use crate::llm::hooks::HookExecutor;
|
||||
use crate::llm::provider::LlmProvider;
|
||||
use crate::memory::retriever::MemoryRetriever;
|
||||
use crate::memory::store::MemoryStore;
|
||||
use crate::tools::ToolRegistry;
|
||||
@@ -33,6 +34,10 @@ pub struct AgentConfig {
|
||||
pub session_ttl: Option<Duration>,
|
||||
/// 上下文压缩配置(None 表示不启用自动压缩),默认 None。
|
||||
pub compact_config: Option<CompactConfig>,
|
||||
/// 摘要自动生成配置(`None` = 不启用)。
|
||||
/// 设置后 `AgentSession` 每轮 OnTurnEnd 之后进行水位 + 防抖检查,触发时调 LLM
|
||||
/// 生成摘要并写入 `FocusedConfig.summary_override` 与 `SessionMemory["conversation_summary"]`。
|
||||
pub summary_config: Option<SummaryConfig>,
|
||||
}
|
||||
|
||||
impl Default for AgentConfig {
|
||||
@@ -42,6 +47,7 @@ impl Default for AgentConfig {
|
||||
max_tool_turns: 10,
|
||||
session_ttl: None,
|
||||
compact_config: None,
|
||||
summary_config: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+636
-38
@@ -15,16 +15,21 @@ use std::sync::Arc;
|
||||
use futures_core::Stream;
|
||||
|
||||
use crate::agent::agent::Agent;
|
||||
use crate::agent::context::{
|
||||
ContextSlot, DeriveStrategy, SlotConfig, SlotMode,
|
||||
};
|
||||
use crate::agent::context::{ContextSlot, DeriveStrategy, SlotConfig, SlotMode};
|
||||
// SlotSource 仅在 `mod tests` 中使用(通过 `use super::*;` 引入),lib 主体保留以避免测试 import 变更。
|
||||
#[allow(unused_imports)]
|
||||
use crate::agent::context::SlotSource;
|
||||
use crate::agent::error::AgentError;
|
||||
use crate::agent::runtime::RuntimeBundle;
|
||||
use crate::agent::session_memory::SessionMemory;
|
||||
use crate::agent::summary::{SummaryConfig, format_messages_as_text};
|
||||
#[cfg(feature = "engine")]
|
||||
use crate::engine::EngineError;
|
||||
#[cfg(feature = "engine")]
|
||||
use crate::engine::snapshot::{SessionMemoryEntry, SessionSnapshot};
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::llm::cycle::{CostTracker, CycleConfig, LlmCycle};
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::hooks::{HookContext, HookEvent};
|
||||
use crate::llm::stream::StreamEvent;
|
||||
use crate::llm::types::message::Message;
|
||||
@@ -55,6 +60,15 @@ pub struct AgentSession {
|
||||
slots: HashMap<String, ContextSlot>,
|
||||
/// Phase 10 新增:当前活跃 slot 的 id。
|
||||
current_slot_id: String,
|
||||
/// Phase 16 新增:上次摘要生成时的 `turn_index`(用于 `debounce_turns` 防抖)。
|
||||
/// `None` 表示从未生成过摘要(首次触发不受防抖约束)。
|
||||
last_summary_turn: Option<u32>,
|
||||
/// Phase 17 新增:`from_snapshot()` 后暂存的待写回条目。
|
||||
/// `None` 表示无 pending restore(正常状态)。
|
||||
/// 调用 `restore_memory()` 后会被消费并设为 `None`。
|
||||
/// 这是 transient state,不参与序列化(AgentSession 本身不 derive Serialize)。
|
||||
#[cfg(feature = "engine")]
|
||||
pending_memory_restore: Option<HashMap<String, SessionMemoryEntry>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AgentSession {
|
||||
@@ -96,11 +110,7 @@ impl AgentSession {
|
||||
let session_memory = SessionMemory::new(backend, &session_id_str);
|
||||
|
||||
// 自动创建 "default" slot
|
||||
let default_slot = ContextSlot::new(
|
||||
&session_id_str,
|
||||
"default",
|
||||
SlotConfig::default(),
|
||||
);
|
||||
let default_slot = ContextSlot::new(&session_id_str, "default", SlotConfig::default());
|
||||
let mut slots = HashMap::new();
|
||||
slots.insert("default".to_string(), default_slot);
|
||||
|
||||
@@ -113,6 +123,9 @@ impl AgentSession {
|
||||
session_memory,
|
||||
slots,
|
||||
current_slot_id: "default".to_string(),
|
||||
last_summary_turn: None,
|
||||
#[cfg(feature = "engine")]
|
||||
pending_memory_restore: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,15 +144,19 @@ impl AgentSession {
|
||||
&self.session_memory
|
||||
}
|
||||
|
||||
/// RuntimeBundle 引用(Phase 17 新增,供 SessionManager::create_child 继承父 bundle)。
|
||||
#[cfg(feature = "engine")]
|
||||
pub(crate) fn bundle(&self) -> &Arc<RuntimeBundle> {
|
||||
&self.bundle
|
||||
}
|
||||
|
||||
/// 写入一条会话级数据(覆盖同名 key)。
|
||||
pub async fn set_session_data(
|
||||
&mut self,
|
||||
key: impl Into<String>,
|
||||
value: impl Into<String>,
|
||||
) -> Result<(), AgentError> {
|
||||
self.session_memory
|
||||
.set(&key.into(), &value.into())
|
||||
.await
|
||||
self.session_memory.set(&key.into(), &value.into()).await
|
||||
}
|
||||
|
||||
/// 读取一条会话级数据。
|
||||
@@ -179,11 +196,7 @@ impl AgentSession {
|
||||
if self.slots.contains_key(&id) {
|
||||
return Err(AgentError::SlotAlreadyExists(id));
|
||||
}
|
||||
let slot = ContextSlot::new(
|
||||
&self.session_id,
|
||||
&id,
|
||||
config.unwrap_or_default(),
|
||||
);
|
||||
let slot = ContextSlot::new(&self.session_id, &id, config.unwrap_or_default());
|
||||
slot.save(&*self.resolve_store()).await?;
|
||||
self.slots.insert(id, slot);
|
||||
Ok(())
|
||||
@@ -239,7 +252,9 @@ impl AgentSession {
|
||||
/// - 已删除后再 load 返回 None
|
||||
pub async fn delete_slot(&mut self, id: &str) -> Result<(), AgentError> {
|
||||
if id == "default" {
|
||||
return Err(AgentError::Config("Cannot delete the 'default' slot".into()));
|
||||
return Err(AgentError::Config(
|
||||
"Cannot delete the 'default' slot".into(),
|
||||
));
|
||||
}
|
||||
if self.slots.len() <= 1 {
|
||||
return Err(AgentError::Config("Cannot delete the last slot".into()));
|
||||
@@ -329,12 +344,7 @@ impl AgentSession {
|
||||
// 6. 只将本轮新增消息追加到当前 slot(保留全量历史,确保 Focused 模式的"读时过滤"语义不丢失数据)
|
||||
// cycle.messages() 包含 [system_prompt?, history..., user_input, tool_calls..., final_response]
|
||||
// 新增消息 = cycle.messages()[input_len..](跳过 initial_messages,即跳过已被持久化的内容)
|
||||
let new_messages: Vec<Message> = cycle
|
||||
.messages()
|
||||
.iter()
|
||||
.skip(input_len)
|
||||
.cloned()
|
||||
.collect();
|
||||
let new_messages: Vec<Message> = cycle.messages().iter().skip(input_len).cloned().collect();
|
||||
let store = self.resolve_store();
|
||||
if let Some(slot) = self.slots.get_mut(&self.current_slot_id) {
|
||||
slot.append_messages(new_messages)?;
|
||||
@@ -345,6 +355,9 @@ impl AgentSession {
|
||||
let end_ctx = HookContext::new(HookEvent::OnTurnEnd).with_turn_index(turn_index);
|
||||
hook_executor.execute(HookEvent::OnTurnEnd, &end_ctx).await;
|
||||
|
||||
// 7.5 Phase 16: 摘要自动生成检查点
|
||||
self.maybe_summarize(turn_index).await;
|
||||
|
||||
// 8. turn_index 递增
|
||||
self.turn_index += 1;
|
||||
|
||||
@@ -409,10 +422,7 @@ impl AgentSession {
|
||||
|
||||
// 5. 调用流式工具循环
|
||||
let stream = cycle
|
||||
.submit_with_tools_stream(
|
||||
user_input.into(),
|
||||
Arc::clone(&self.bundle.tool_registry),
|
||||
)
|
||||
.submit_with_tools_stream(user_input.into(), Arc::clone(&self.bundle.tool_registry))
|
||||
.await?;
|
||||
|
||||
// 6. turn_index 递增 —— 配合 finalize_turn 用 (turn_index - 1) 传递正确的 OnTurnEnd 序号
|
||||
@@ -462,13 +472,273 @@ impl AgentSession {
|
||||
.hook_executor
|
||||
.execute(HookEvent::OnTurnEnd, &end_ctx)
|
||||
.await;
|
||||
|
||||
// Phase 16: 摘要检查点(流式路径 turn_index 已被 submit_turn_stream 提前 ++1)
|
||||
self.maybe_summarize(self.turn_index.saturating_sub(1))
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ====== Phase 17: 快照序列化 ======
|
||||
|
||||
/// 将当前状态拍平为 `SessionSnapshot`。
|
||||
///
|
||||
/// **需要 async**:因为 `session_memory` 的条目存储在 `MemoryStore` 中,读取需异步 I/O。
|
||||
/// 通过 `SessionMemory::list_entries()` 获取完整条目(保留 `metadata` 和 `created_at`)。
|
||||
///
|
||||
/// `Arc<dyn Agent>` 和 `Arc<RuntimeBundle>` **不进入快照**——由 `from_snapshot()` 调用方注入。
|
||||
#[cfg(feature = "engine")]
|
||||
pub async fn to_snapshot(&self) -> SessionSnapshot {
|
||||
// 拍平 session_memory → HashMap<String, SessionMemoryEntry>
|
||||
// 失败时回退到空 map(错误已记录,不阻断 checkpoint 主流程)。
|
||||
let session_memory_data = match self.session_memory.list_entries().await {
|
||||
Ok(entries) => entries
|
||||
.into_iter()
|
||||
.map(|(key, value, metadata, created_at)| {
|
||||
(
|
||||
key,
|
||||
SessionMemoryEntry {
|
||||
value,
|
||||
metadata,
|
||||
created_at: Some(created_at),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
tracing::error!("session_memory list_entries failed: {}", e);
|
||||
HashMap::new()
|
||||
}
|
||||
};
|
||||
|
||||
SessionSnapshot {
|
||||
session_id: self.session_id.clone(),
|
||||
agent_name: self.agent.name().to_string(),
|
||||
turn_index: self.turn_index,
|
||||
cost_so_far: self.cost_so_far.clone(),
|
||||
slots: self.slots.clone(),
|
||||
current_slot_id: self.current_slot_id.clone(),
|
||||
last_summary_turn: self.last_summary_turn,
|
||||
session_memory_data,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 `SessionSnapshot` + agent + bundle **纯同步**重建 `AgentSession`。
|
||||
///
|
||||
/// **不执行任何 I/O**:`session_memory_data` 暂存于 `pending_memory_restore` 字段,
|
||||
/// 由调用方显式 `await session.restore_memory()` 写回持久层。
|
||||
///
|
||||
/// 调用方负责提供与 `snapshot.agent_name` 对应的 `Arc<dyn Agent>`(引擎层只保留名字做调试用)。
|
||||
#[cfg(feature = "engine")]
|
||||
pub fn from_snapshot(
|
||||
snapshot: SessionSnapshot,
|
||||
agent: Arc<dyn Agent>,
|
||||
bundle: Arc<RuntimeBundle>,
|
||||
) -> Result<Self, EngineError> {
|
||||
// 校验 bundle 的 session_memory_backend 与 snapshot 兼容
|
||||
// (v0.3 不强制同 backend——以新构造的 session_memory 所属 backend 为准)
|
||||
let backend = bundle
|
||||
.session_memory_backend
|
||||
.clone()
|
||||
.unwrap_or_else(|| Arc::new(InMemoryStore::new()));
|
||||
let session_memory = SessionMemory::new(backend, &snapshot.session_id);
|
||||
|
||||
// 解析 agent_name 仅供调试(不强制匹配,因为不同进程的 Agent 实现可能不同)
|
||||
let _ = snapshot.agent_name.as_str();
|
||||
|
||||
// 确保至少有一个 slot(与 new() 行为一致)
|
||||
let mut slots = snapshot.slots;
|
||||
if slots.is_empty() {
|
||||
slots.insert(
|
||||
"default".to_string(),
|
||||
ContextSlot::new(&snapshot.session_id, "default", SlotConfig::default()),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
session_id: snapshot.session_id,
|
||||
agent,
|
||||
bundle,
|
||||
turn_index: snapshot.turn_index,
|
||||
cost_so_far: snapshot.cost_so_far,
|
||||
session_memory,
|
||||
slots,
|
||||
current_slot_id: snapshot.current_slot_id,
|
||||
last_summary_turn: snapshot.last_summary_turn,
|
||||
pending_memory_restore: if snapshot.session_memory_data.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(snapshot.session_memory_data)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// 将 `from_snapshot()` 暂存的 `session_memory_data` 写回 `SessionMemory` 持久层。
|
||||
///
|
||||
/// **从 `from_snapshot()` 中剥离的异步操作**:确保构造函数是纯同步的。
|
||||
/// 调用方在 `from_snapshot()` 后显式 `await`。
|
||||
///
|
||||
/// **错误处理**:逐条写入。某条失败时返回 `Err` 但**不回滚**已写入条目。
|
||||
/// 调用方可选择重试或忽略——不影响 AgentSession 内存状态。
|
||||
///
|
||||
/// **幂等性**:重复调用安全(首次成功后 `pending_memory_restore` 已被设为 `None`,
|
||||
/// 第二次调用立即返回 `Ok(())`)。
|
||||
///
|
||||
/// **完整恢复**:使用 `SessionMemory::set_with_meta()` 保留原始 `metadata` 和 `created_at`
|
||||
/// ——不像 `set()` 会清空 metadata 并把 created_at 设为当前时间。
|
||||
#[cfg(feature = "engine")]
|
||||
pub async fn restore_memory(&mut self) -> Result<(), EngineError> {
|
||||
// 取出 pending 并立即清空(避免重复 restore 时二次写入;幂等性保证)
|
||||
let entries = self.pending_memory_restore.take();
|
||||
let entries = match entries {
|
||||
Some(m) if !m.is_empty() => m,
|
||||
_ => return Ok(()), // 无 pending 或已被清空 → 立即返回
|
||||
};
|
||||
|
||||
for (key, entry) in entries {
|
||||
self.session_memory
|
||||
.set_with_meta(&key, &entry.value, entry.metadata.clone(), entry.created_at)
|
||||
.await
|
||||
.map_err(EngineError::Agent)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 是否有待写回的 `session_memory_data`(`from_snapshot()` 后尚未 `restore_memory()`)。
|
||||
#[cfg(feature = "engine")]
|
||||
pub fn has_pending_memory_restore(&self) -> bool {
|
||||
self.pending_memory_restore
|
||||
.as_ref()
|
||||
.map(|m| !m.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// ====== Phase 16: 摘要自动生成 ======
|
||||
|
||||
/// 读取 SessionMemory 中最新的对话摘要(`None` 表示从未生成过)。
|
||||
pub async fn get_conversation_summary(&self) -> Result<Option<String>, AgentError> {
|
||||
self.session_memory.get("conversation_summary").await
|
||||
}
|
||||
|
||||
/// 水位 + 防抖检查:是否应当触发摘要生成。
|
||||
/// 防抖只对"上一轮与本轮之间的间隔"起作用——首次(`last_summary_turn.is_none()`)不阻塞。
|
||||
/// `current_turn` 显式传入而非读 `self.turn_index`,因为流式路径中 `submit_turn_stream` 已提前 ++1,
|
||||
/// `finalize_turn` 会用 `saturating_sub(1)` 修正后的值传入此函数。
|
||||
fn should_summarize(&self, cfg: &SummaryConfig, current_turn: u32) -> bool {
|
||||
let debounce_ok = match self.last_summary_turn {
|
||||
None => true,
|
||||
Some(last) => current_turn.saturating_sub(last) >= cfg.debounce_turns,
|
||||
};
|
||||
debounce_ok
|
||||
&& self.cost_so_far.total().total_tokens as f64
|
||||
>= cfg.max_context_tokens as f64 * cfg.trigger_token_ratio
|
||||
}
|
||||
|
||||
/// 检查点入口:水位超阈值时调 LLM 生成摘要,写入 slot config 与 SessionMemory。
|
||||
/// 所有错误(含 LLM error、save 失败、session_memory 写失败)均静默(`tracing::error!` 后返回)。
|
||||
async fn maybe_summarize(&mut self, current_turn: u32) {
|
||||
let cfg = match self.bundle.config.summary_config.clone() {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
if !self.should_summarize(&cfg, current_turn) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 先 clone 出 &self 借用范围内所需数据,后续释放借用再 await/mut
|
||||
let provider = Arc::clone(&self.bundle.provider);
|
||||
let messages = self
|
||||
.slots
|
||||
.get(&self.current_slot_id)
|
||||
.map(|s| s.messages.clone())
|
||||
.unwrap_or_default();
|
||||
if messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
let max_tool_result_chars = cfg.max_tool_result_chars;
|
||||
let model = cfg.summary_model.clone();
|
||||
let prompt = cfg.summary_prompt.clone();
|
||||
|
||||
let result = Self::generate_summary(
|
||||
&provider,
|
||||
&messages,
|
||||
&prompt,
|
||||
model.as_deref(),
|
||||
max_tool_result_chars,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(text) => {
|
||||
tracing::info!(
|
||||
turn = current_turn,
|
||||
summary_len = text.len(),
|
||||
"摘要自动生成成功"
|
||||
);
|
||||
// Resolve store first (immutable borrow on self) before mutable borrow on slots.
|
||||
let store = self.resolve_store();
|
||||
if let Some(slot) = self.slots.get_mut(&self.current_slot_id)
|
||||
&& let SlotMode::Focused(ref mut focused_cfg) = slot.config.mode
|
||||
{
|
||||
focused_cfg.summary_override = Some(text.clone());
|
||||
// Full 模式下 summary_override 未被修改,无需持久化 slot
|
||||
if let Err(e) = slot.save(&*store).await {
|
||||
tracing::error!("summary config persist failed: {}", e);
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.session_memory.set("conversation_summary", &text).await {
|
||||
tracing::error!("summary session_memory write failed: {}", e);
|
||||
}
|
||||
self.last_summary_turn = Some(current_turn);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("摘要自动生成失败 (turn={}): {}", current_turn, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 关联函数:调一次 LLM 生成摘要。空消息列表直接返回空串(不浪费 LLM 调用)。
|
||||
/// `summary_model=None` 时沿用 `CycleConfig::default()` 的默认模型(避免硬编码到非 OpenAI 用户不适配的 `"gpt-4o"`)。
|
||||
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> {
|
||||
if messages.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let messages_text = format_messages_as_text(messages, max_tool_result_chars);
|
||||
let prompt = prompt_template.replace("{messages}", &messages_text);
|
||||
|
||||
let config = CycleConfig {
|
||||
max_tokens: Some(1024),
|
||||
..CycleConfig::default()
|
||||
};
|
||||
let config = if let Some(model) = summary_model {
|
||||
CycleConfig {
|
||||
model: model.to_string(),
|
||||
..config
|
||||
}
|
||||
} else {
|
||||
config
|
||||
};
|
||||
|
||||
let mut cycle = LlmCycle::new_with_arc(Arc::clone(provider), config);
|
||||
// submit_messages 使用自身参数构造 request,不读 self.messages——prompt 必须放在 messages 参数里
|
||||
let response = cycle
|
||||
.submit_messages(vec![Message::user_text(prompt)], vec![])
|
||||
.await?;
|
||||
Ok(response.text())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agent::FocusedConfig;
|
||||
use crate::agent::builder::AgentBuilder;
|
||||
use crate::llm::hooks::{Hook, HookContext, HookExecutor, HookResult};
|
||||
use crate::llm::mock::MockProvider;
|
||||
@@ -528,7 +798,9 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_session(provider_responses: Vec<MessageResponse>) -> (AgentSession, Arc<CountHook>, Arc<CountHook>) {
|
||||
fn build_session(
|
||||
provider_responses: Vec<MessageResponse>,
|
||||
) -> (AgentSession, Arc<CountHook>, Arc<CountHook>) {
|
||||
let mut hook_executor = HookExecutor::new();
|
||||
let start_count = Arc::new(CountHook(AtomicU32::new(0)));
|
||||
let end_count = Arc::new(CountHook(AtomicU32::new(0)));
|
||||
@@ -562,7 +834,8 @@ mod tests {
|
||||
/// 烟雾测试 1:AgentSession::submit_turn 跑通 mock provider(向后兼容)。
|
||||
#[tokio::test]
|
||||
async fn submit_turn_runs_with_mock_provider() {
|
||||
let (mut session, start_count, end_count) = build_session(vec![assistant_text("hello back")]);
|
||||
let (mut session, start_count, end_count) =
|
||||
build_session(vec![assistant_text("hello back")]);
|
||||
assert_eq!(session.turn_index(), 0);
|
||||
|
||||
let response = session.submit_turn("hi").await.unwrap();
|
||||
@@ -597,10 +870,8 @@ mod tests {
|
||||
/// 烟雾测试 3:submit_turn 触发 OnTurnStart / OnTurnEnd hook。
|
||||
#[tokio::test]
|
||||
async fn submit_turn_triggers_turn_hooks() {
|
||||
let (mut session, start_count, end_count) = build_session(vec![
|
||||
assistant_text("ok"),
|
||||
assistant_text("ok 2"),
|
||||
]);
|
||||
let (mut session, start_count, end_count) =
|
||||
build_session(vec![assistant_text("ok"), assistant_text("ok 2")]);
|
||||
|
||||
session.submit_turn("hi").await.unwrap();
|
||||
assert_eq!(start_count.0.load(Ordering::SeqCst), 1);
|
||||
@@ -653,9 +924,15 @@ mod tests {
|
||||
// 即 [user_input, tool_results?, final_response](不含 system_prompt,system 由 agent 提供)
|
||||
assert!(slot.messages.len() >= 2, "应至少包含 user 和 assistant");
|
||||
// 验证 user 输入和 assistant 响应都已写入
|
||||
let has_user = slot.messages.iter().any(|m| extract_text(m) == "user input");
|
||||
let has_user = slot
|
||||
.messages
|
||||
.iter()
|
||||
.any(|m| extract_text(m) == "user input");
|
||||
let has_resp = slot.messages.iter().any(|m| extract_text(m) == "resp");
|
||||
assert!(has_user && has_resp, "slot 应包含 user input 和 assistant response");
|
||||
assert!(
|
||||
has_user && has_resp,
|
||||
"slot 应包含 user input 和 assistant response"
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 10: create_slot 创建新 slot。
|
||||
@@ -699,7 +976,11 @@ mod tests {
|
||||
// 3. 检查 slot_a 的消息数
|
||||
let slot_a = session.slots.get("slot_a").unwrap();
|
||||
let slot_a_count = slot_a.messages.len();
|
||||
assert!(slot_a_count >= 2, "slot_a 至少 2 条消息,实际 {}", slot_a_count);
|
||||
assert!(
|
||||
slot_a_count >= 2,
|
||||
"slot_a 至少 2 条消息,实际 {}",
|
||||
slot_a_count
|
||||
);
|
||||
|
||||
// 4. 切回 default,验证 default 不包含 slot_a 的消息
|
||||
session.switch_slot("default").await.unwrap();
|
||||
@@ -994,7 +1275,10 @@ mod tests {
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::User { .. }));
|
||||
let has_resp = slot.messages.iter().any(|m| extract_text(m) == "hi back");
|
||||
assert!(has_user && has_resp, "default slot 应包含 user 和 assistant 消息");
|
||||
assert!(
|
||||
has_user && has_resp,
|
||||
"default slot 应包含 user 和 assistant 消息"
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 9 Step 5.2 — `submit_turn_stream` 触发 OnTurnStart / OnTurnEnd hook。
|
||||
@@ -1050,4 +1334,318 @@ mod tests {
|
||||
"OnTurnEnd 应在 finalize_turn 后触发"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Phase 16: 摘要自动生成测试 ======
|
||||
|
||||
/// 构造带 `SummaryConfig` 的 session。
|
||||
/// mock provider 队列按 `[conv_1, summary_1, conv_2, summary_2, ...]` 交错排列,
|
||||
/// 因为每轮 `submit_turn` 中 conversation LLM 调用先于 summary LLM 调用。
|
||||
fn build_session_with_summary(
|
||||
provider_responses: Vec<MessageResponse>,
|
||||
summary_responses: Vec<MessageResponse>,
|
||||
cfg: SummaryConfig,
|
||||
) -> AgentSession {
|
||||
let mut interleaved = Vec::new();
|
||||
let max_len = provider_responses.len().max(summary_responses.len());
|
||||
for i in 0..max_len {
|
||||
if let Some(r) = provider_responses.get(i) {
|
||||
interleaved.push(r.clone());
|
||||
}
|
||||
if let Some(r) = summary_responses.get(i) {
|
||||
interleaved.push(r.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let provider = Arc::new(MockProvider::new(interleaved));
|
||||
let agent = Arc::new(StubAgent {
|
||||
name: "stub".into(),
|
||||
prompt: None,
|
||||
});
|
||||
let bundle = Arc::new(
|
||||
AgentBuilder::new()
|
||||
.provider(provider)
|
||||
.tool_registry(Arc::new(ToolRegistry::new()))
|
||||
.hook_executor(Arc::new(HookExecutor::new()))
|
||||
.summary_config(cfg)
|
||||
.build()
|
||||
.unwrap(),
|
||||
);
|
||||
AgentSession::new(agent, "summary-session", bundle)
|
||||
}
|
||||
|
||||
/// 默认用法:token 用量 ~15,远低于默认 32K 窗口的 0.75=24K 阈值 → 不触发摘要。
|
||||
#[tokio::test]
|
||||
async fn summary_not_generated_below_threshold() {
|
||||
let mut session = build_session_with_summary(
|
||||
vec![
|
||||
assistant_text("a"),
|
||||
assistant_text("b"),
|
||||
assistant_text("c"),
|
||||
],
|
||||
vec![assistant_text("should_not_appear")],
|
||||
SummaryConfig::default(),
|
||||
);
|
||||
|
||||
for i in 0..3 {
|
||||
session
|
||||
.submit_turn(&format!("msg {}", i))
|
||||
.await
|
||||
.expect("submit_turn 应成功");
|
||||
}
|
||||
|
||||
let summary = session.get_conversation_summary().await.unwrap();
|
||||
assert!(summary.is_none(), "未达阈值时不应生成摘要");
|
||||
}
|
||||
|
||||
/// 设置极低 max_context_tokens=100 + 0.5 比例 → 第一轮触发(usage 为 10+5=15 > 50)。
|
||||
#[tokio::test]
|
||||
async fn summary_generated_above_threshold() {
|
||||
let mut session = build_session_with_summary(
|
||||
vec![
|
||||
assistant_text("a"),
|
||||
assistant_text("b"),
|
||||
assistant_text("c"),
|
||||
],
|
||||
vec![
|
||||
assistant_text("summary-1"),
|
||||
assistant_text("summary-2"),
|
||||
assistant_text("summary-3"),
|
||||
],
|
||||
SummaryConfig {
|
||||
max_context_tokens: 20, // 阈值 20 * 0.5 = 10
|
||||
trigger_token_ratio: 0.5,
|
||||
debounce_turns: 0, // 关闭防抖便于测试
|
||||
..SummaryConfig::default()
|
||||
},
|
||||
);
|
||||
|
||||
// 第 1 轮:usage=15 ≥ 10,debounce=0 → 触发
|
||||
session.submit_turn("m1").await.unwrap();
|
||||
let summary = session.get_conversation_summary().await.unwrap();
|
||||
assert!(summary.is_some(), "应触发摘要");
|
||||
}
|
||||
|
||||
/// 防抖:trigger 触发后,debounce_turns=3 内即使再次达阈值也不重复。
|
||||
#[tokio::test]
|
||||
async fn summary_debounce_works() {
|
||||
let mut session = build_session_with_summary(
|
||||
vec![
|
||||
assistant_text("r1"),
|
||||
assistant_text("r2"),
|
||||
assistant_text("r3"),
|
||||
assistant_text("r4"),
|
||||
],
|
||||
vec![assistant_text("sum-1")],
|
||||
SummaryConfig {
|
||||
max_context_tokens: 20,
|
||||
trigger_token_ratio: 0.5,
|
||||
debounce_turns: 3,
|
||||
..SummaryConfig::default()
|
||||
},
|
||||
);
|
||||
|
||||
session.submit_turn("m1").await.unwrap();
|
||||
let first_summary = session.get_conversation_summary().await.unwrap();
|
||||
assert_eq!(first_summary.as_deref(), Some("sum-1"));
|
||||
|
||||
// 第 2、3 轮:即使都超阈值,debounce 阻止再次触发
|
||||
for _ in 0..2 {
|
||||
session.submit_turn("m").await.unwrap();
|
||||
}
|
||||
let still_summary = session.get_conversation_summary().await.unwrap();
|
||||
assert_eq!(
|
||||
still_summary.as_deref(),
|
||||
Some("sum-1"),
|
||||
"debounce 内不应重复生成(Provider 上没有更多预设摘要响应可用)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Full 模式:摘要被生成并写入 session_memory,但 slot config.summary_override 仍为 None。
|
||||
#[tokio::test]
|
||||
async fn summary_written_to_session_memory_but_full_mode_does_not_inject() {
|
||||
let mut session = build_session_with_summary(
|
||||
vec![
|
||||
assistant_text("a"),
|
||||
assistant_text("b"),
|
||||
assistant_text("c"),
|
||||
],
|
||||
vec![assistant_text("captured-summary")],
|
||||
SummaryConfig {
|
||||
max_context_tokens: 20,
|
||||
trigger_token_ratio: 0.5,
|
||||
debounce_turns: 0,
|
||||
..SummaryConfig::default()
|
||||
},
|
||||
);
|
||||
|
||||
session.submit_turn("m1").await.unwrap();
|
||||
|
||||
let summary = session.get_conversation_summary().await.unwrap();
|
||||
assert_eq!(summary.as_deref(), Some("captured-summary"));
|
||||
|
||||
// default slot 是 Full 模式 → summary_override 应为 None(filter_focused 不会触发)
|
||||
let slot = session.slots.get("default").unwrap();
|
||||
assert!(matches!(slot.config.mode, SlotMode::Full));
|
||||
}
|
||||
|
||||
/// 摘要生成失败不阻断 submit_turn(Provider 队列只够对话轮次,摘要调用返回 Other 错误)。
|
||||
#[tokio::test]
|
||||
async fn summary_failure_does_not_block_turn() {
|
||||
// 故意只提供 1 个对话响应;摘要调用时队列耗尽,MockProvider 返回 LlmError::Other
|
||||
let mut session = build_session_with_summary(
|
||||
vec![assistant_text("only-one")], // 后续摘要会失败
|
||||
vec![], // 无摘要响应
|
||||
SummaryConfig {
|
||||
max_context_tokens: 20,
|
||||
trigger_token_ratio: 0.5,
|
||||
debounce_turns: 0,
|
||||
..SummaryConfig::default()
|
||||
},
|
||||
);
|
||||
|
||||
let response = session
|
||||
.submit_turn("m1")
|
||||
.await
|
||||
.expect("submit_turn 应成功(即便摘要失败)");
|
||||
assert_eq!(extract_text(&response.message), "only-one");
|
||||
|
||||
// 摘要未生成(Provider 已耗尽)
|
||||
let summary = session.get_conversation_summary().await.unwrap();
|
||||
assert!(summary.is_none());
|
||||
}
|
||||
|
||||
/// 未配置 SummaryConfig 时零影响。
|
||||
#[tokio::test]
|
||||
async fn summary_skipped_when_not_configured() {
|
||||
let (mut session, _, _) = build_session(vec![assistant_text("r1"), assistant_text("r2")]);
|
||||
for _ in 0..2 {
|
||||
session.submit_turn("m").await.unwrap();
|
||||
}
|
||||
let summary = session.get_conversation_summary().await.unwrap();
|
||||
assert!(summary.is_none());
|
||||
}
|
||||
|
||||
/// 流式路径(submit_turn_stream + finalize_turn):摘要检查点正确触发。
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn summary_stream_path_triggers_check() {
|
||||
let mut session = build_session_with_summary(
|
||||
vec![assistant_text("stream-resp")],
|
||||
vec![assistant_text("stream-summary")],
|
||||
SummaryConfig {
|
||||
max_context_tokens: 20,
|
||||
trigger_token_ratio: 0.5,
|
||||
debounce_turns: 0,
|
||||
..SummaryConfig::default()
|
||||
},
|
||||
);
|
||||
|
||||
let mut stream = session
|
||||
.submit_turn_stream("user msg")
|
||||
.await
|
||||
.expect("stream ok");
|
||||
let mut response: Option<MessageResponse> = None;
|
||||
while let Some(ev) = stream.next().await {
|
||||
if let StreamEvent::MessageComplete { full_response } = &ev {
|
||||
response = Some(full_response.clone());
|
||||
}
|
||||
}
|
||||
let resp = response.expect("MessageComplete event");
|
||||
// finalize_turn 需要本轮新增消息:用户输入 + assistant 响应。
|
||||
// slot.append_messages 之后才会被 maybe_summarize 看到。
|
||||
let new_messages = vec![Message::user_text("user msg"), resp.message.clone()];
|
||||
session
|
||||
.finalize_turn(&resp, new_messages)
|
||||
.await
|
||||
.expect("finalize_turn ok");
|
||||
|
||||
let summary = session.get_conversation_summary().await.unwrap();
|
||||
assert_eq!(summary.as_deref(), Some("stream-summary"));
|
||||
}
|
||||
|
||||
/// W7:Focused 模式摘要写入 `summary_override` + `slot.save()` 正向验证。
|
||||
#[tokio::test]
|
||||
async fn summary_written_to_focused_slot_config() {
|
||||
let mut session = build_session_with_summary(
|
||||
vec![assistant_text("a"), assistant_text("b")],
|
||||
vec![assistant_text("the-summary")],
|
||||
SummaryConfig {
|
||||
max_context_tokens: 20,
|
||||
trigger_token_ratio: 0.5,
|
||||
debounce_turns: 0,
|
||||
..SummaryConfig::default()
|
||||
},
|
||||
);
|
||||
|
||||
// 1. 把 default slot 切到 Focused 模式
|
||||
session
|
||||
.create_slot(
|
||||
"focused",
|
||||
Some(SlotConfig {
|
||||
mode: SlotMode::Focused(FocusedConfig {
|
||||
keep_system: false,
|
||||
recent_messages: 5,
|
||||
summary_override: None,
|
||||
}),
|
||||
source: SlotSource::New,
|
||||
budget: Default::default(),
|
||||
compact: true,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
session.switch_slot("focused").await.unwrap();
|
||||
|
||||
// 2. 触发摘要
|
||||
session.submit_turn("m1").await.unwrap();
|
||||
|
||||
// 3. SessionMemory 有值
|
||||
let summary = session.get_conversation_summary().await.unwrap();
|
||||
assert_eq!(summary.as_deref(), Some("the-summary"));
|
||||
|
||||
// 4. Focused slot 的 summary_override 也应有值(正向验证)
|
||||
let slot = session.slots.get("focused").unwrap();
|
||||
assert!(
|
||||
matches!(&slot.config.mode, SlotMode::Focused(focused) if focused.summary_override.is_some()),
|
||||
"Focused 模式下 summary_override 应被写入"
|
||||
);
|
||||
}
|
||||
|
||||
/// W1: 空消息守卫——`generate_summary` 空消息直接返回 `""`,不调用 LLM。
|
||||
/// 这里通过构建一个空 slot 触发,第一次 `submit_turn` 后 slot 才有消息。
|
||||
/// 验证:先调用 `format_messages_as_text` 走纯函数路径检查。
|
||||
#[tokio::test]
|
||||
async fn summary_skipped_for_empty_messages() {
|
||||
// 直接走 format_messages_as_text,验证空消息返回空串。
|
||||
// 这等同于 generate_summary 入口守卫(见 session.rs:560-562)。
|
||||
let text = format_messages_as_text(&[], 500);
|
||||
assert_eq!(text, "");
|
||||
}
|
||||
|
||||
/// W1: `max_context_tokens` 设置过大时永不触发摘要。
|
||||
#[tokio::test]
|
||||
async fn summary_not_generated_if_max_context_unreachable() {
|
||||
let mut session = build_session_with_summary(
|
||||
vec![
|
||||
assistant_text("r1"),
|
||||
assistant_text("r2"),
|
||||
assistant_text("r3"),
|
||||
assistant_text("r4"),
|
||||
],
|
||||
vec![assistant_text("should-not-appear")],
|
||||
SummaryConfig {
|
||||
max_context_tokens: 1_000_000, // 远大于任何合理累计 token
|
||||
trigger_token_ratio: 0.75,
|
||||
debounce_turns: 0,
|
||||
..SummaryConfig::default()
|
||||
},
|
||||
);
|
||||
|
||||
// 多轮 submit_turn,全部 15 token/轮,远低于 0.75 * 1M = 750K 阈值
|
||||
for i in 0..4 {
|
||||
session.submit_turn(&format!("m{}", i)).await.unwrap();
|
||||
}
|
||||
|
||||
let summary = session.get_conversation_summary().await.unwrap();
|
||||
assert!(summary.is_none(), "巨型 max_context_tokens 应永不触发");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,12 +44,38 @@ impl SessionMemory {
|
||||
}
|
||||
|
||||
/// 写入一条 key-value 条目(覆盖同名 key)。
|
||||
///
|
||||
/// **不保留 metadata 和 created_at** —— 写入时 metadata 为空 JSON `{}`,created_at 为 `now_utc()`。
|
||||
/// 若需保留这两个字段(如 checkpoint rollback),使用 [`Self::set_with_meta`]。
|
||||
pub async fn set(&self, key: &str, value: &str) -> Result<(), AgentError> {
|
||||
self.set_with_meta(key, value, serde_json::json!({}), None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 写入一条 key-value 条目(含完整 metadata + created_at)。
|
||||
///
|
||||
/// Phase 17 新增:供 `AgentSession::restore_memory()` 使用,保证 checkpoint rollback 时
|
||||
/// 恢复完整的 session_memory 条目(包括原 metadata 和创建时间戳)。
|
||||
///
|
||||
/// - `metadata`: 通常为 `serde_json::Value`(快照中保留的 metadata JSON)
|
||||
/// - `created_at`: 快照中的原始时间戳(Unix 秒);若为 `None` 则用 `now_utc()`(默认行为)
|
||||
pub async fn set_with_meta(
|
||||
&self,
|
||||
key: &str,
|
||||
value: &str,
|
||||
metadata: serde_json::Value,
|
||||
created_at: Option<i64>,
|
||||
) -> Result<(), AgentError> {
|
||||
let created_at_dt = match created_at {
|
||||
Some(secs) => OffsetDateTime::from_unix_timestamp(secs)
|
||||
.unwrap_or_else(|_| OffsetDateTime::now_utc()),
|
||||
None => OffsetDateTime::now_utc(),
|
||||
};
|
||||
let item = MemoryItem {
|
||||
id: self.internal_key(key),
|
||||
content: value.to_string(),
|
||||
metadata: serde_json::json!({}),
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
metadata,
|
||||
created_at: created_at_dt,
|
||||
};
|
||||
self.store.save(item).await.map_err(AgentError::Memory)
|
||||
}
|
||||
@@ -103,6 +129,34 @@ impl SessionMemory {
|
||||
.map_err(AgentError::Memory)
|
||||
}
|
||||
|
||||
/// 列出当前 namespace 下所有条目(含完整 `MemoryItem`:value / metadata / created_at)。
|
||||
///
|
||||
/// Phase 17 新增:供 `AgentSession::to_snapshot()` 拍平 session_memory 时使用,
|
||||
/// 保留 metadata 和 created_at 时间戳(用 `set/get/remove` 三个 API 会丢字段)。
|
||||
///
|
||||
/// 返回 `Vec<(原始 key, value, metadata, created_at_unix_secs)>`,原始 key 已剥离 namespace 前缀。
|
||||
pub async fn list_entries(
|
||||
&self,
|
||||
) -> Result<Vec<(String, String, serde_json::Value, i64)>, AgentError> {
|
||||
let filter = MemoryFilter {
|
||||
prefix: Some(format!("{}:", self.namespace)),
|
||||
..Default::default()
|
||||
};
|
||||
let items = self.store.list(&filter).await.map_err(AgentError::Memory)?;
|
||||
let prefix_with_colon = format!("{}:", self.namespace);
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
let key = item
|
||||
.id
|
||||
.strip_prefix(&prefix_with_colon)
|
||||
.unwrap_or(&item.id)
|
||||
.to_string();
|
||||
let created_at_unix = item.created_at.unix_timestamp();
|
||||
out.push((key, item.content, item.metadata, created_at_unix));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 清空当前 namespace 下所有条目。
|
||||
pub async fn clear(&self) -> Result<(), AgentError> {
|
||||
let filter = MemoryFilter {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! 摘要自动生成 —— 在长对话中自动压缩上下文。
|
||||
//!
|
||||
//! 通过 `AgentSession` 内联检查点检测 token 水位,调用 LLM 生成摘要,
|
||||
//! 写入 `FocusedConfig.summary_override` 与 `SessionMemory["conversation_summary"]`。
|
||||
//!
|
||||
//! 关闭端位于 `FocusedConfig::filter_focused`(见 `agent/context.rs`)。
|
||||
|
||||
use crate::llm::types::message::{ContentBlock, Message};
|
||||
|
||||
/// 默认摘要 prompt(含 `{messages}` 占位符,运行期替换为对话历史文本)。
|
||||
pub const DEFAULT_SUMMARY_PROMPT: &str = "请为以下对话生成一个简洁的中文摘要,突出关键结论、用户偏好和重要上下文信息。保持客观,不要添加对话中不存在的信息。\n\n{messages}";
|
||||
|
||||
/// 摘要自动生成配置(opt-in:通过 `AgentBuilder::summary_config(cfg)` 启用)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SummaryConfig {
|
||||
/// Token 水位触发比例(0.0 ~ 1.0)。
|
||||
pub trigger_token_ratio: f64,
|
||||
|
||||
/// 模型上下文窗口大小(token)。
|
||||
/// ⚠️ 设置为超过模型实际窗口的值会导致摘要永远不触发。
|
||||
pub max_context_tokens: u32,
|
||||
|
||||
/// 摘要 prompt 模板。`{messages}` 将被替换为对话历史纯文本。
|
||||
pub summary_prompt: String,
|
||||
|
||||
/// 摘要间隔防抖(轮次):两次摘要至少间隔这么多次 `submit_turn`。
|
||||
pub debounce_turns: u32,
|
||||
|
||||
/// 摘要生成使用的模型(`None` = 沿用主 provider 默认模型)。
|
||||
/// 推荐设为便宜模型(如 `"gpt-4o-mini"`)以节省摘要成本。
|
||||
pub summary_model: Option<String>,
|
||||
|
||||
/// 单个 `ToolResult` 在摘要输入中保留的最大 Unicode 字符数。
|
||||
/// 超过此值从开头截断(`chars().take(n)`,字符级安全)。
|
||||
pub max_tool_result_chars: usize,
|
||||
}
|
||||
|
||||
impl Default for SummaryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
trigger_token_ratio: 0.75,
|
||||
max_context_tokens: 32_000,
|
||||
summary_prompt: DEFAULT_SUMMARY_PROMPT.into(),
|
||||
debounce_turns: 3,
|
||||
summary_model: None,
|
||||
max_tool_result_chars: 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把消息列表格式化为摘要 LLM 所需的纯文本(简洁版)。
|
||||
///
|
||||
/// 每行一条消息:
|
||||
/// - `System/User/Assistant` 取首个 `Text` block 拼接
|
||||
/// - `Assistant` 中的 `ToolUse` 标记为 `[Tool: {name}]`
|
||||
/// - `ToolResult` 标记为 `Tool Result [{tool_call_id}]:`(含 tool_call_id 以便多工具场景关联)
|
||||
/// - 长 `ToolResult` 截断到 `max_tool_result_chars` 个字符
|
||||
///
|
||||
/// 整段对话若超过 `30_000` 字符,从前面截断,**优先保留最新消息**,
|
||||
/// 因为新近交互对摘要而言更有信息量。
|
||||
pub fn format_messages_as_text(messages: &[Message], max_tool_result_chars: usize) -> String {
|
||||
let mut lines = Vec::with_capacity(messages.len());
|
||||
for msg in messages {
|
||||
match msg {
|
||||
Message::System { content } => {
|
||||
if let Some(text) = first_text(content) {
|
||||
lines.push(format!("System: {}", text));
|
||||
}
|
||||
}
|
||||
Message::User { content } => {
|
||||
if let Some(text) = first_text(content) {
|
||||
lines.push(format!("User: {}", text));
|
||||
}
|
||||
}
|
||||
Message::Assistant { content } => {
|
||||
let mut parts = Vec::new();
|
||||
for block in content {
|
||||
match block {
|
||||
ContentBlock::Text { text } => parts.push(text.clone()),
|
||||
ContentBlock::ToolUse { name, .. } => {
|
||||
parts.push(format!("[Tool: {}]", name));
|
||||
}
|
||||
ContentBlock::Thinking { text, .. } => {
|
||||
parts.push(format!("[Thinking: {}]", truncate_chars(text, 100)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
lines.push(format!("Assistant: {}", parts.join(" ")));
|
||||
}
|
||||
}
|
||||
Message::UserImage { .. } => {
|
||||
lines.push("User: [image]".to_string());
|
||||
}
|
||||
Message::ToolResult {
|
||||
tool_call_id,
|
||||
content,
|
||||
is_error,
|
||||
} => {
|
||||
let label = if *is_error {
|
||||
"Tool Error"
|
||||
} else {
|
||||
"Tool Result"
|
||||
};
|
||||
if let Some(text) = first_text(content) {
|
||||
let truncated = truncate_chars(text, max_tool_result_chars);
|
||||
lines.push(format!("{} [{}]: {}", label, tool_call_id, truncated));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let joined = lines.join("\n");
|
||||
truncate_total_chars(&joined, MAX_TOTAL_CHARS)
|
||||
}
|
||||
|
||||
/// 整段对话输出字符上限。超过时从前面截断,保留尾部最新消息。
|
||||
const MAX_TOTAL_CHARS: usize = 30_000;
|
||||
|
||||
fn truncate_total_chars(s: &str, max_chars: usize) -> String {
|
||||
let total = s.chars().count();
|
||||
if total <= max_chars {
|
||||
return s.to_string();
|
||||
}
|
||||
// 计算需要从前面丢弃的字符数。保留窗口从 (total - max_chars) 开始。
|
||||
let skip = total - max_chars;
|
||||
let dropped: String = s.chars().take(skip).collect();
|
||||
let mut kept = String::with_capacity(max_chars + 8);
|
||||
kept.push_str("[... earlier messages truncated ...]\n");
|
||||
// 字节切安全:dropped 由 s.chars().take(skip).collect() 构建,
|
||||
// 每个 char 的 UTF-8 字节序列完整保留,故 dropped.len() 恰好是 s 的某个 char 边界字节偏移。
|
||||
kept.push_str(&s[dropped.len()..]);
|
||||
kept
|
||||
}
|
||||
|
||||
fn first_text(content: &[ContentBlock]) -> Option<&str> {
|
||||
content.iter().find_map(|b| match b {
|
||||
ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn truncate_chars(s: &str, max_chars: usize) -> String {
|
||||
if s.chars().count() <= max_chars {
|
||||
return s.to_string();
|
||||
}
|
||||
let truncated: String = s.chars().take(max_chars).collect();
|
||||
format!("{}...", truncated)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_values() {
|
||||
let cfg = SummaryConfig::default();
|
||||
assert_eq!(cfg.trigger_token_ratio, 0.75);
|
||||
assert_eq!(cfg.max_context_tokens, 32_000);
|
||||
assert_eq!(cfg.debounce_turns, 3);
|
||||
assert_eq!(cfg.max_tool_result_chars, 500);
|
||||
assert!(cfg.summary_model.is_none());
|
||||
assert!(cfg.summary_prompt.contains("{messages}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_skips_empty_input() {
|
||||
let text = format_messages_as_text(&[], 500);
|
||||
assert!(text.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_user_assistant_round_trip() {
|
||||
let msgs = vec![
|
||||
Message::system("you are a translator"),
|
||||
Message::user_text("hello"),
|
||||
Message::assistant("hi"),
|
||||
];
|
||||
let text = format_messages_as_text(&msgs, 500);
|
||||
assert!(text.contains("System: you are a translator"));
|
||||
assert!(text.contains("User: hello"));
|
||||
assert!(text.contains("Assistant: hi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tool_result_includes_tool_call_id() {
|
||||
let msgs = vec![Message::tool_result("call_42", "ok", false)];
|
||||
let text = format_messages_as_text(&msgs, 500);
|
||||
assert_eq!(text, "Tool Result [call_42]: ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tool_result_error_label() {
|
||||
let msgs = vec![Message::tool_result("call_9", "boom", true)];
|
||||
let text = format_messages_as_text(&msgs, 500);
|
||||
assert_eq!(text, "Tool Error [call_9]: boom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tool_use_in_assistant() {
|
||||
let msgs = vec![Message::Assistant {
|
||||
content: vec![
|
||||
ContentBlock::Text {
|
||||
text: "let me search".into(),
|
||||
},
|
||||
ContentBlock::ToolUse {
|
||||
id: "c1".into(),
|
||||
name: "search".into(),
|
||||
input: serde_json::json!({"q": "rust"}),
|
||||
},
|
||||
],
|
||||
}];
|
||||
let text = format_messages_as_text(&msgs, 500);
|
||||
assert_eq!(text, "Assistant: let me search [Tool: search]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_truncates_long_tool_result_at_unicode_boundary() {
|
||||
let long = "a".repeat(1000);
|
||||
let msgs = vec![Message::tool_result("c", &long, false)];
|
||||
let text = format_messages_as_text(&msgs, 100);
|
||||
// 100 chars + "..."
|
||||
assert!(text.contains("..."));
|
||||
let truncated_part = text.split("...").next().unwrap();
|
||||
// "Tool Result [c]: " is 18 chars, plus 100 a's
|
||||
let a_count = truncated_part.chars().filter(|c| *c == 'a').count();
|
||||
assert_eq!(a_count, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_total_charset_truncation_keeps_recent() {
|
||||
// 50 段 user 消息,每段 1000 字符 = ~50K,触发 30K 整体截断
|
||||
let mut msgs = Vec::new();
|
||||
for _ in 0..50 {
|
||||
msgs.push(Message::user_text("x".repeat(1000)));
|
||||
}
|
||||
let text = format_messages_as_text(&msgs, 500);
|
||||
// 总字符数 ≤ 30K + prefix "[... earlier messages truncated ...]\n"
|
||||
assert!(text.chars().count() <= 30_000 + 40);
|
||||
// 头部有截断标记
|
||||
assert!(text.contains("[... earlier messages truncated ...]"));
|
||||
// 最后一行的标记字符 (30 个 x) 应保留在末尾
|
||||
assert!(text.ends_with("xxxxxxxxxx"));
|
||||
}
|
||||
}
|
||||
+64
-14
@@ -339,7 +339,14 @@ impl RecursiveCharacterSplitter {
|
||||
let take_n = overlap.min(prev_chars_count);
|
||||
|
||||
// 字符级安全地取 prev 末尾 take_n 个字符
|
||||
let tail: String = prev.chars().rev().take(take_n).collect::<Vec<_>>().into_iter().rev().collect();
|
||||
let tail: String = prev
|
||||
.chars()
|
||||
.rev()
|
||||
.take(take_n)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
chunks[i] = format!("{}{}", tail, chunks[i]);
|
||||
}
|
||||
|
||||
@@ -408,8 +415,14 @@ mod tests {
|
||||
let chunks = splitter.split(&[doc]);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].content, "hello");
|
||||
assert_eq!(chunks[0].metadata.get("chunk_index").map(|s| s.as_str()), Some("0"));
|
||||
assert_eq!(chunks[0].metadata.get("chunk_count").map(|s| s.as_str()), Some("1"));
|
||||
assert_eq!(
|
||||
chunks[0].metadata.get("chunk_index").map(|s| s.as_str()),
|
||||
Some("0")
|
||||
);
|
||||
assert_eq!(
|
||||
chunks[0].metadata.get("chunk_count").map(|s| s.as_str()),
|
||||
Some("1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -464,7 +477,11 @@ mod tests {
|
||||
// para1 (5 chars) > chunk_size=4 → 递归降级到 char 级拆分
|
||||
// para2 同理
|
||||
// 总共应该产生多个 chunk
|
||||
assert!(chunks.len() >= 2, "expected >= 2 chunks, got {}", chunks.len());
|
||||
assert!(
|
||||
chunks.len() >= 2,
|
||||
"expected >= 2 chunks, got {}",
|
||||
chunks.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -474,10 +491,18 @@ mod tests {
|
||||
let text: String = "a".repeat(200);
|
||||
let doc = Document::from_raw("long", &text);
|
||||
let chunks = splitter.split(&[doc]);
|
||||
assert!(chunks.len() >= 3, "expected >= 3 chunks, got {}", chunks.len());
|
||||
assert!(
|
||||
chunks.len() >= 3,
|
||||
"expected >= 3 chunks, got {}",
|
||||
chunks.len()
|
||||
);
|
||||
for chunk in &chunks {
|
||||
// chunk 内容 = overlap_tail(≤5) + new_content(≤50),故 ≤ 55
|
||||
assert!(chars_len(&chunk.content) <= 55, "chunk too long: {} chars", chars_len(&chunk.content));
|
||||
assert!(
|
||||
chars_len(&chunk.content) <= 55,
|
||||
"chunk too long: {} chars",
|
||||
chars_len(&chunk.content)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,7 +513,11 @@ mod tests {
|
||||
let doc = Document::from_raw("g", "aa\n\nbb\n\ncc\n\ndd");
|
||||
let chunks = splitter.split(&[doc]);
|
||||
// 短段应被合并:总共应该少于 4 个 chunk
|
||||
assert!(chunks.len() <= 3, "expected <= 3 chunks after merge, got {}", chunks.len());
|
||||
assert!(
|
||||
chunks.len() <= 3,
|
||||
"expected <= 3 chunks after merge, got {}",
|
||||
chunks.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -538,11 +567,19 @@ mod tests {
|
||||
let doc = Document::from_raw("cjk", &text);
|
||||
let chunks = splitter.split(&[doc]);
|
||||
// 30 字符 / 10 chunk_size = 3 个 chunk
|
||||
assert!(chunks.len() >= 3, "expected >= 3 chunks for 30 chars / chunk_size=10, got {}", chunks.len());
|
||||
assert!(
|
||||
chunks.len() >= 3,
|
||||
"expected >= 3 chunks for 30 chars / chunk_size=10, got {}",
|
||||
chunks.len()
|
||||
);
|
||||
for chunk in &chunks {
|
||||
let char_count = chars_len(&chunk.content);
|
||||
// chunk = overlap_tail(≤2) + new_content(≤10),故 ≤ 12
|
||||
assert!(char_count <= 12, "chunk char count {} exceeds 10+overlap", char_count);
|
||||
assert!(
|
||||
char_count <= 12,
|
||||
"chunk char count {} exceeds 10+overlap",
|
||||
char_count
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,12 +606,25 @@ mod tests {
|
||||
fn split_metadata_inheritance() {
|
||||
let splitter = RecursiveCharacterSplitter::new(100, 10);
|
||||
let mut doc = Document::new("m", "short content", "text/plain");
|
||||
doc.metadata.insert("author".to_string(), "alice".to_string());
|
||||
doc.metadata
|
||||
.insert("author".to_string(), "alice".to_string());
|
||||
let chunks = splitter.split(&[doc]);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].metadata.get("author").map(|s| s.as_str()), Some("alice"));
|
||||
assert_eq!(chunks[0].metadata.get("source_id").map(|s| s.as_str()), Some("m"));
|
||||
assert_eq!(chunks[0].metadata.get("chunk_index").map(|s| s.as_str()), Some("0"));
|
||||
assert_eq!(chunks[0].metadata.get("chunk_count").map(|s| s.as_str()), Some("1"));
|
||||
assert_eq!(
|
||||
chunks[0].metadata.get("author").map(|s| s.as_str()),
|
||||
Some("alice")
|
||||
);
|
||||
assert_eq!(
|
||||
chunks[0].metadata.get("source_id").map(|s| s.as_str()),
|
||||
Some("m")
|
||||
);
|
||||
assert_eq!(
|
||||
chunks[0].metadata.get("chunk_index").map(|s| s.as_str()),
|
||||
Some("0")
|
||||
);
|
||||
assert_eq!(
|
||||
chunks[0].metadata.get("chunk_count").map(|s| s.as_str()),
|
||||
Some("1")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Checkpointer —— Time-travel 检查点管理器(Phase 17 Step 4)。
|
||||
//!
|
||||
//! 设计要点:
|
||||
//! - **不依赖 SessionManager**,可独立使用。直接操作 `MemoryStore`。
|
||||
//! - **存储 key 格式**:`ckpt:{session_id}:{ckpt_id}` → `SessionSnapshot` JSON
|
||||
//! - **ckpt_id 生成**:时间戳(纳秒)+ 单调计数器,无外部依赖(ponytail 优先于 uuid)
|
||||
//! - **rollback_load** 两阶段:仅反序列化为 `SessionSnapshot`;不重建 `AgentSession`。
|
||||
//! 调用方拿到 `SessionSnapshot` 后自行 `AgentSession::from_snapshot` + `restore_memory` + `replace`。
|
||||
//!
|
||||
//! 所有持久化错误通过 `EngineError::Memory` 透传。
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::agent::session::AgentSession;
|
||||
use crate::engine::EngineError;
|
||||
use crate::engine::snapshot::SessionSnapshot;
|
||||
use crate::memory::store::MemoryStore;
|
||||
use crate::memory::types::{MemoryFilter, MemoryItem};
|
||||
|
||||
/// 检查点元数据(公开 API)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct CkptMeta {
|
||||
pub ckpt_id: String,
|
||||
pub session_id: String,
|
||||
pub turn_index: u32,
|
||||
/// Unix 时间戳秒(人类可读)。
|
||||
pub created_at: u64,
|
||||
/// Unix 时间戳纳秒(用于同秒内的精确排序)。
|
||||
pub created_at_nanos: u128,
|
||||
}
|
||||
|
||||
/// 全局单调计数器(避免同一纳秒内并发 checkpoint 撞 id)。
|
||||
static CKPT_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// 生成 ckpt_id:纳秒时间戳 + 单调计数器(避免同纳秒冲突)。
|
||||
fn generate_ckpt_id() -> String {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let counter = CKPT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
format!("{:x}_{:x}", nanos, counter)
|
||||
}
|
||||
|
||||
fn ckpt_key(session_id: &str, ckpt_id: &str) -> String {
|
||||
format!("ckpt:{}:{}", session_id, ckpt_id)
|
||||
}
|
||||
|
||||
fn assert_no_colon(id: &str, field: &str) {
|
||||
if id.contains(':') {
|
||||
panic!(
|
||||
"{field} '{id}' contains ':' which would break key format. \
|
||||
Use only letters, digits, hyphens and underscores."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Time-travel 检查点管理器。
|
||||
pub struct Checkpointer {
|
||||
store: std::sync::Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
impl Checkpointer {
|
||||
/// 构造 Checkpointer。
|
||||
pub fn new(store: std::sync::Arc<dyn MemoryStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// 创建新检查点。返回生成的 `ckpt_id`。
|
||||
///
|
||||
/// 流程:`session.to_snapshot().await` → 序列化为 JSON → 存 `ckpt:{session_id}:{ckpt_id}`。
|
||||
pub async fn checkpoint(&self, session: &AgentSession) -> Result<String, EngineError> {
|
||||
let snapshot = session.to_snapshot().await;
|
||||
assert_no_colon(&session.session_id, "session_id");
|
||||
|
||||
let ckpt_id = generate_ckpt_id();
|
||||
let key = ckpt_key(&session.session_id, &ckpt_id);
|
||||
|
||||
let json = serde_json::to_string(&snapshot)
|
||||
.map_err(|e| EngineError::Serialization(format!("snapshot serialize failed: {e}")))?;
|
||||
|
||||
let item = MemoryItem {
|
||||
id: key,
|
||||
content: json,
|
||||
metadata: serde_json::json!({
|
||||
"turn_index": snapshot.turn_index,
|
||||
}),
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
};
|
||||
self.store.save(item).await?;
|
||||
|
||||
tracing::info!(
|
||||
session_id = %session.session_id,
|
||||
ckpt_id = %ckpt_id,
|
||||
turn_index = snapshot.turn_index,
|
||||
snapshot_size = snapshot.session_memory_data.len(),
|
||||
"checkpoint created"
|
||||
);
|
||||
|
||||
Ok(ckpt_id)
|
||||
}
|
||||
|
||||
/// 反序列化 checkpoint 为 `SessionSnapshot`(不重建 `AgentSession`)。
|
||||
///
|
||||
/// 两阶段 rollback 的第一阶段。调用方拿到 `SessionSnapshot` 后自行:
|
||||
/// `AgentSession::from_snapshot(snapshot, agent, bundle)` → `restore_memory()` → `replace()`
|
||||
pub async fn rollback_load(
|
||||
&self,
|
||||
session_id: &str,
|
||||
ckpt_id: &str,
|
||||
) -> Result<SessionSnapshot, EngineError> {
|
||||
let key = ckpt_key(session_id, ckpt_id);
|
||||
let item = self.store.get(&key).await?.ok_or_else(|| {
|
||||
EngineError::CheckpointNotFound(format!("{} (session={})", ckpt_id, session_id))
|
||||
})?;
|
||||
|
||||
let snapshot: SessionSnapshot = serde_json::from_str(&item.content).map_err(|e| {
|
||||
EngineError::Serialization(format!(
|
||||
"snapshot deserialize failed (ckpt_id={}): {e}",
|
||||
ckpt_id
|
||||
))
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
session_id = %session_id,
|
||||
ckpt_id = %ckpt_id,
|
||||
turn_index = snapshot.turn_index,
|
||||
"checkpoint loaded for rollback"
|
||||
);
|
||||
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// 列出某 session 的所有检查点(按创建时间**降序**——最新的在前)。
|
||||
///
|
||||
/// prefix 查询 `ckpt:{session_id}:` → 反序列化 `SessionSnapshot` → 提取元数据。
|
||||
/// 不需要 `CkptMeta` 单独存储——`SessionSnapshot` 已含 `turn_index` 字段,
|
||||
/// `created_at` 用 `MemoryItem.created_at` 转换。
|
||||
pub async fn list_checkpoints(&self, session_id: &str) -> Result<Vec<CkptMeta>, EngineError> {
|
||||
let prefix = format!("ckpt:{}:", session_id);
|
||||
let filter = MemoryFilter {
|
||||
prefix: Some(prefix),
|
||||
..Default::default()
|
||||
};
|
||||
let items = self.store.list(&filter).await?;
|
||||
|
||||
tracing::debug!(
|
||||
session_id = %session_id,
|
||||
count = items.len(),
|
||||
"checkpoints listed"
|
||||
);
|
||||
|
||||
let mut metas: Vec<CkptMeta> = items
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
// 从 id 中提取 ckpt_id: "ckpt:{session_id}:{ckpt_id}"
|
||||
let prefix_with_session = format!("ckpt:{}:", session_id);
|
||||
let ckpt_id = item.id.strip_prefix(&prefix_with_session)?.to_string();
|
||||
let snapshot: SessionSnapshot = serde_json::from_str(&item.content).ok()?;
|
||||
let created_at_nanos = item
|
||||
.created_at
|
||||
.unix_timestamp_nanos()
|
||||
.try_into()
|
||||
.unwrap_or(0u128);
|
||||
Some(CkptMeta {
|
||||
ckpt_id,
|
||||
session_id: session_id.to_string(),
|
||||
turn_index: snapshot.turn_index,
|
||||
created_at: item.created_at.unix_timestamp() as u64,
|
||||
created_at_nanos,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 按 created_at_nanos 降序(精确排序)
|
||||
metas.sort_by(|a, b| b.created_at_nanos.cmp(&a.created_at_nanos));
|
||||
Ok(metas)
|
||||
}
|
||||
|
||||
/// 删除某 session 的所有检查点(session 被 `destroy` 时调用)。
|
||||
pub async fn delete_all(&self, session_id: &str) -> Result<(), EngineError> {
|
||||
let prefix = format!("ckpt:{}:", session_id);
|
||||
let filter = MemoryFilter {
|
||||
prefix: Some(prefix.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let items = self.store.list(&filter).await?;
|
||||
let deleted = items.len();
|
||||
for item in items {
|
||||
self.store.delete(&item.id).await?;
|
||||
}
|
||||
tracing::info!(
|
||||
session_id = %session_id,
|
||||
deleted_count = deleted,
|
||||
"all checkpoints deleted"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取某 session 的最新 checkpoint(按 created_at 降序取第一个)。
|
||||
///
|
||||
/// 供 `SessionManager::recover()` 调用。
|
||||
pub async fn latest_snapshot(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<Option<SessionSnapshot>, EngineError> {
|
||||
let metas = self.list_checkpoints(session_id).await?;
|
||||
match metas.first() {
|
||||
Some(meta) => {
|
||||
let snap = self.rollback_load(session_id, &meta.ckpt_id).await?;
|
||||
Ok(Some(snap))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agent::builder::AgentBuilder;
|
||||
use crate::agent::summary::SummaryConfig;
|
||||
use crate::llm::mock::MockProvider;
|
||||
use crate::tools::ToolRegistry;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// 极简 Agent(无 system_prompt)。
|
||||
struct StubAgent;
|
||||
#[async_trait]
|
||||
impl crate::agent::agent::Agent for StubAgent {
|
||||
fn name(&self) -> &str {
|
||||
"stub"
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn make_bundle() -> Arc<crate::agent::runtime::RuntimeBundle> {
|
||||
Arc::new(
|
||||
AgentBuilder::new()
|
||||
.provider(Arc::new(MockProvider::new(vec![])))
|
||||
.tool_registry(Arc::new(ToolRegistry::new()))
|
||||
.hook_executor(Arc::new(crate::llm::hooks::HookExecutor::new()))
|
||||
.summary_config(SummaryConfig::default())
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
fn make_session(session_id: &str, agent: Arc<dyn crate::agent::agent::Agent>) -> AgentSession {
|
||||
AgentSession::new(agent, session_id, make_bundle())
|
||||
}
|
||||
|
||||
fn new_session_for_test(session_id: &str) -> AgentSession {
|
||||
let agent: Arc<dyn crate::agent::agent::Agent> = Arc::new(StubAgent);
|
||||
make_session(session_id, agent)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkpoint_roundtrip() {
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let cp = Checkpointer::new(store.clone());
|
||||
|
||||
let mut session = new_session_for_test("ckpt-session");
|
||||
session
|
||||
.set_session_data("design", "PostgreSQL")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ckpt_id = cp.checkpoint(&session).await.unwrap();
|
||||
assert!(!ckpt_id.is_empty());
|
||||
|
||||
let restored = cp.rollback_load("ckpt-session", &ckpt_id).await.unwrap();
|
||||
assert_eq!(restored.session_id, "ckpt-session");
|
||||
assert_eq!(restored.turn_index, 0);
|
||||
assert_eq!(restored.session_memory_data.len(), 1);
|
||||
let entry = restored.session_memory_data.get("design").unwrap();
|
||||
assert_eq!(entry.value, "PostgreSQL");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkpoint_not_found() {
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let cp = Checkpointer::new(store);
|
||||
|
||||
let err = cp.rollback_load("nonexistent", "ckpt_x").await.unwrap_err();
|
||||
match err {
|
||||
EngineError::CheckpointNotFound(_) => {}
|
||||
other => panic!("expected CheckpointNotFound, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_checkpoints_returns_desc() {
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let cp = Checkpointer::new(store);
|
||||
|
||||
let session = new_session_for_test("list-session");
|
||||
let ckpt_id_1 = cp.checkpoint(&session).await.unwrap();
|
||||
// 短暂 sleep 确保时间戳不同(InMemoryStore 内部用 OffsetDateTime 精度到 ns)
|
||||
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
|
||||
let ckpt_id_2 = cp.checkpoint(&session).await.unwrap();
|
||||
let ckpt_id_3 = cp.checkpoint(&session).await.unwrap();
|
||||
|
||||
let metas = cp.list_checkpoints("list-session").await.unwrap();
|
||||
assert_eq!(metas.len(), 3);
|
||||
// 降序:最新在前
|
||||
let ids: Vec<_> = metas.iter().map(|m| m.ckpt_id.clone()).collect();
|
||||
assert_eq!(ids[0], ckpt_id_3);
|
||||
assert_eq!(ids[1], ckpt_id_2);
|
||||
assert_eq!(ids[2], ckpt_id_1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_all_removes_checkpoints() {
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let cp = Checkpointer::new(store.clone());
|
||||
|
||||
let session = new_session_for_test("del-session");
|
||||
cp.checkpoint(&session).await.unwrap();
|
||||
cp.checkpoint(&session).await.unwrap();
|
||||
assert_eq!(cp.list_checkpoints("del-session").await.unwrap().len(), 2);
|
||||
|
||||
cp.delete_all("del-session").await.unwrap();
|
||||
assert_eq!(cp.list_checkpoints("del-session").await.unwrap().len(), 0);
|
||||
|
||||
// delete_all 幂等:再次调用不报错
|
||||
cp.delete_all("del-session").await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_snapshot_returns_most_recent() {
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let cp = Checkpointer::new(store);
|
||||
|
||||
let mut session = new_session_for_test("latest-session");
|
||||
cp.checkpoint(&session).await.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
|
||||
session.set_session_data("v", "2").await.unwrap();
|
||||
cp.checkpoint(&session).await.unwrap();
|
||||
|
||||
let latest = cp.latest_snapshot("latest-session").await.unwrap().unwrap();
|
||||
let v = latest.session_memory_data.get("v").unwrap();
|
||||
assert_eq!(v.value, "2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkpoint_isolation_between_sessions() {
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let cp = Checkpointer::new(store);
|
||||
|
||||
let a = new_session_for_test("iso-a");
|
||||
let b = new_session_for_test("iso-b");
|
||||
cp.checkpoint(&a).await.unwrap();
|
||||
cp.checkpoint(&b).await.unwrap();
|
||||
|
||||
let metas_a = cp.list_checkpoints("iso-a").await.unwrap();
|
||||
let metas_b = cp.list_checkpoints("iso-b").await.unwrap();
|
||||
assert_eq!(metas_a.len(), 1);
|
||||
assert_eq!(metas_b.len(), 1);
|
||||
assert_eq!(metas_a[0].session_id, "iso-a");
|
||||
assert_eq!(metas_b[0].session_id, "iso-b");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Engine 模块统一错误类型。
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::agent::error::AgentError;
|
||||
use crate::memory::error::MemoryError;
|
||||
|
||||
/// Engine 模块错误枚举。
|
||||
///
|
||||
/// - `Session*` / `Checkpoint*`:engine 层独有的错误变体
|
||||
/// - `Memory`:透传 `MemoryError`,与项目既有 `AgentError` 风格一致(对比 `AgentError::Memory`)
|
||||
/// - `Serialization`:快照 JSON 解析失败
|
||||
/// - `Agent`:透传 `AgentError`(后续 Stage 5/6 集成时需要)
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum EngineError {
|
||||
/// 指定 session_id 不存在。
|
||||
/// 适用场景:`get()` 内存未命中、`create_child()` parent 不存在、
|
||||
/// `recover()` 存储中查不到。
|
||||
///
|
||||
/// **不适用** `destroy()`:`destroy()` 对不存在的 session 静默返回 `Ok(())`
|
||||
/// (幂等删除语义,调用方无需先检查)。
|
||||
#[error("Session not found: {0}")]
|
||||
SessionNotFound(String),
|
||||
|
||||
/// 创建 session 时 ID 已存在(自动生成 UUID 时通常不会触发;当前主要在重复 `recover` 已存在 ID 时使用)。
|
||||
#[error("Session already exists: {0}")]
|
||||
SessionAlreadyExists(String),
|
||||
|
||||
/// 指定 ckpt_id 不存在。
|
||||
#[error("Checkpoint not found: {0}")]
|
||||
CheckpointNotFound(String),
|
||||
|
||||
/// 存储错误(透传 `MemoryError`)。
|
||||
/// Checkpointer 和 SessionManager 的所有 `MemoryStore` 操作通过此变体传播错误。
|
||||
#[error("存储错误: {0}")]
|
||||
Memory(#[from] MemoryError),
|
||||
|
||||
/// 序列化/反序列化失败(serde_json / snapshot 格式错误)。
|
||||
#[error("序列化错误: {0}")]
|
||||
Serialization(String),
|
||||
|
||||
/// Agent 错误(透传 `AgentError`,供后续 Stage 5/6 的 `recover`/`replace` 等集成入口使用)。
|
||||
#[error("Agent 错误: {0}")]
|
||||
Agent(#[from] AgentError),
|
||||
|
||||
/// 子代理调度失败(`dispatch` 过程中遇到不可恢复错误,子 session 已被清理)。
|
||||
/// 调用方收到此错误时,子 session 已通过 `destroy()` 清理(SessionMeta + checkpoint 全部清空)。
|
||||
#[error("Dispatch failed: {0}")]
|
||||
DispatchFailed(String),
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Engine 模块 —— Agent 执行引擎。
|
||||
//!
|
||||
//! Phase 17 新增。提供 SessionManager(会话树管理)和 Checkpointer(time-travel 检查点)能力。
|
||||
//!
|
||||
//! ## 子模块
|
||||
//!
|
||||
//! - [`session_manager`]:SessionManager + SessionManagerConfig
|
||||
//! - [`checkpointer`]:Checkpointer(time-travel 检查点)
|
||||
//! - [`snapshot`]:SessionSnapshot + SessionMemoryEntry(可序列化快照)
|
||||
//! - [`error`]:EngineError 枚举
|
||||
|
||||
pub mod checkpointer;
|
||||
pub mod error;
|
||||
pub mod session_manager;
|
||||
pub mod snapshot;
|
||||
pub mod sub_agent;
|
||||
pub mod switch;
|
||||
|
||||
pub use checkpointer::{Checkpointer, CkptMeta};
|
||||
pub use error::EngineError;
|
||||
pub use session_manager::{SessionManager, SessionManagerConfig};
|
||||
pub use snapshot::{SessionMemoryEntry, SessionSnapshot};
|
||||
pub use sub_agent::{DispatchConfig, SubTaskResult, SubTaskStreamEvent};
|
||||
@@ -0,0 +1,922 @@
|
||||
//! SessionManager —— Session 生命周期管理器(Phase 17 Step 5)。
|
||||
//!
|
||||
//! 组合持有 [`Checkpointer`],提供 session 的 CRUD、树形关系查询和检查点集成。
|
||||
//! 内部用 `tokio::sync::RwLock<HashMap>` 管理活跃 session。
|
||||
//!
|
||||
//! ## 锁契约
|
||||
//!
|
||||
//! - 所有写操作(`create`/`destroy`/`replace`)内部**先完成 HashMap 操作**(持写锁),
|
||||
//! 释放 RwLock 后再调用 Checkpointer/MemoryStore 的异步 I/O。
|
||||
//! - `get()` 返回 `Arc<Mutex<AgentSession>>` 后**立即释放 RwLock 读锁**,
|
||||
//! 调用方持有的是 session 级别的 Mutex 锁而非管理器级别的锁。
|
||||
//! - **不持有 RwLock 跨越 `.await`** —— 所有 .await 点必须在 RwLock guard drop 之后。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
use crate::agent::agent::Agent;
|
||||
use crate::agent::runtime::RuntimeBundle;
|
||||
use crate::agent::session::AgentSession;
|
||||
use crate::engine::checkpointer::Checkpointer;
|
||||
use crate::engine::error::EngineError;
|
||||
use crate::memory::store::MemoryStore;
|
||||
use crate::memory::types::{MemoryFilter, MemoryItem};
|
||||
|
||||
/// Session 元数据(持久化到 `session:{session_id}:meta`)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
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,
|
||||
}
|
||||
|
||||
impl SessionMeta {
|
||||
fn meta_key(session_id: &str) -> String {
|
||||
format!("session:{}:meta", session_id)
|
||||
}
|
||||
|
||||
fn assert_no_colon(id: &str, field: &str) {
|
||||
if id.contains(':') {
|
||||
panic!(
|
||||
"{field} '{id}' contains ':' which would break key format. \
|
||||
Use only letters, digits, hyphens and underscores."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn from_session(session: &AgentSession, parent_id: Option<String>) -> Self {
|
||||
Self::assert_no_colon(&session.session_id, "session_id");
|
||||
Self {
|
||||
session_id: session.session_id.clone(),
|
||||
agent_name: session.agent.name().to_string(),
|
||||
parent_id,
|
||||
created_at: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
turn_count: session.turn_index(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SessionManager 配置。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionManagerConfig {
|
||||
/// 是否在 submit_turn 后自动 checkpoint(Step 6 集成)。
|
||||
pub auto_checkpoint: bool,
|
||||
/// 可选的默认 bundle,用于 `recover` 时的 bundle 注入。
|
||||
pub default_bundle: Option<Arc<RuntimeBundle>>,
|
||||
}
|
||||
|
||||
impl Default for SessionManagerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_checkpoint: true,
|
||||
default_bundle: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session 生命周期管理器。
|
||||
///
|
||||
/// 内部 `RwLock<HashMap>`:读多写少场景优化。写操作先持写锁完成 HashMap 更新后立即释放,
|
||||
/// 再调用 Checkpointer/MemoryStore 的异步 I/O。
|
||||
pub struct SessionManager {
|
||||
pub(crate) sessions: RwLock<HashMap<String, Arc<Mutex<AgentSession>>>>,
|
||||
pub(crate) checkpointer: Checkpointer,
|
||||
pub(crate) store: Arc<dyn MemoryStore>,
|
||||
pub(crate) config: SessionManagerConfig,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
/// 构造 SessionManager(使用默认配置)。
|
||||
pub fn new(store: Arc<dyn MemoryStore>) -> Self {
|
||||
Self {
|
||||
sessions: RwLock::new(HashMap::new()),
|
||||
checkpointer: Checkpointer::new(store.clone()),
|
||||
store,
|
||||
config: SessionManagerConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造 SessionManager(带自定义配置)。
|
||||
pub fn with_config(store: Arc<dyn MemoryStore>, config: SessionManagerConfig) -> Self {
|
||||
Self {
|
||||
sessions: RwLock::new(HashMap::new()),
|
||||
checkpointer: Checkpointer::new(store.clone()),
|
||||
store,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// 暴露 Checkpointer 引用(调用方可直接操作检查点)。
|
||||
pub fn checkpointer(&self) -> &Checkpointer {
|
||||
&self.checkpointer
|
||||
}
|
||||
|
||||
/// 暴露 MemoryStore 引用。
|
||||
pub fn store(&self) -> &Arc<dyn MemoryStore> {
|
||||
&self.store
|
||||
}
|
||||
|
||||
// ====== 内部辅助:SessionMeta 持久化 ======
|
||||
|
||||
pub(crate) async fn save_session_meta(&self, meta: &SessionMeta) -> Result<(), EngineError> {
|
||||
let json = serde_json::to_string(meta)
|
||||
.map_err(|e| EngineError::Serialization(format!("SessionMeta serialize: {e}")))?;
|
||||
let item = MemoryItem {
|
||||
id: SessionMeta::meta_key(&meta.session_id),
|
||||
content: json,
|
||||
metadata: serde_json::json!({}),
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
};
|
||||
self.store.save(item).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn load_session_meta(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<Option<SessionMeta>, EngineError> {
|
||||
let item = self.store.get(&SessionMeta::meta_key(session_id)).await?;
|
||||
match item {
|
||||
Some(item) => {
|
||||
let meta: SessionMeta = serde_json::from_str(&item.content).map_err(|e| {
|
||||
EngineError::Serialization(format!("SessionMeta deserialize: {e}"))
|
||||
})?;
|
||||
Ok(Some(meta))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 公开 API ======
|
||||
|
||||
/// 创建新 session。session_id 内部自动生成(时间戳+计数器,ponytail)。
|
||||
///
|
||||
/// 流程:生成 session_id → `AgentSession::new` → 存 `SessionMeta` → 注册到 HashMap。
|
||||
pub async fn create(
|
||||
&self,
|
||||
agent: Arc<dyn Agent>,
|
||||
bundle: Arc<RuntimeBundle>,
|
||||
) -> Result<String, EngineError> {
|
||||
let session_id = Self::generate_session_id();
|
||||
|
||||
let session = AgentSession::new(agent, &session_id, bundle);
|
||||
let meta = SessionMeta::from_session(&session, None);
|
||||
|
||||
// 1. 存 SessionMeta(持久层)—— 在 lock 外做 I/O
|
||||
self.save_session_meta(&meta).await?;
|
||||
|
||||
// 2. 注册到 HashMap —— 短暂持写锁
|
||||
{
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.insert(session_id.clone(), Arc::new(Mutex::new(session)));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
session_id = %session_id,
|
||||
agent_name = %meta.agent_name,
|
||||
"session created"
|
||||
);
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
/// 从父 session 创建子 session。
|
||||
///
|
||||
/// 继承父的 `RuntimeBundle`(`Arc::clone` 共享引用)。
|
||||
/// session_id 内部自动生成。
|
||||
///
|
||||
/// **限制**:父 session **必须已加载到内存**(通过 `get()` 或 `recover()`)。
|
||||
/// 因为 `RuntimeBundle` 不可序列化,bundle 必须从内存中的父 session 获取。
|
||||
/// 父 session 已被 `destroy` 或冷启动后未加载时,本方法返回 `SessionNotFound`。
|
||||
///
|
||||
/// 如果 `parent_id` 在存储中查不到 SessionMeta,返回 `EngineError::SessionNotFound(parent_id)`。
|
||||
pub async fn create_child(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
agent: Arc<dyn Agent>,
|
||||
) -> Result<String, EngineError> {
|
||||
// 验证 parent 存在(从存储读 SessionMeta,避免依赖内存状态)
|
||||
let parent_meta = self
|
||||
.load_session_meta(parent_id)
|
||||
.await?
|
||||
.ok_or_else(|| EngineError::SessionNotFound(parent_id.to_string()))?;
|
||||
|
||||
// 读取父 session 的 bundle(必须在内存中才能拿到;如果不在内存则要求用户先 get)
|
||||
let parent_bundle = {
|
||||
let sessions = self.sessions.read().await;
|
||||
let parent_arc = sessions
|
||||
.get(parent_id)
|
||||
.ok_or_else(|| EngineError::SessionNotFound(parent_id.to_string()))?;
|
||||
Arc::clone(parent_arc.lock().await.bundle())
|
||||
};
|
||||
|
||||
let session_id = Self::generate_session_id();
|
||||
let session = AgentSession::new(agent, &session_id, parent_bundle);
|
||||
let meta = SessionMeta::from_session(&session, Some(parent_meta.session_id));
|
||||
|
||||
self.save_session_meta(&meta).await?;
|
||||
|
||||
{
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.insert(session_id.clone(), Arc::new(Mutex::new(session)));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
session_id = %session_id,
|
||||
parent_id = %parent_id,
|
||||
"child session created"
|
||||
);
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
/// 按 ID 获取 session(**仅查内存**,不自动从存储恢复)。
|
||||
///
|
||||
/// 冷启动时 `get()` 未命中返回 `SessionNotFound`。如需从存储恢复,使用 `recover()` 方法。
|
||||
pub async fn get(&self, session_id: &str) -> Result<Arc<Mutex<AgentSession>>, EngineError> {
|
||||
let sessions = self.sessions.read().await;
|
||||
let result = sessions.get(session_id).cloned();
|
||||
tracing::debug!(
|
||||
session_id = %session_id,
|
||||
found = result.is_some(),
|
||||
"session get"
|
||||
);
|
||||
result.ok_or_else(|| EngineError::SessionNotFound(session_id.to_string()))
|
||||
}
|
||||
|
||||
/// 从存储恢复 session。
|
||||
///
|
||||
/// 流程:读 SessionMeta → 从 latest checkpoint 读 SessionSnapshot →
|
||||
/// `AgentSession::from_snapshot(snapshot, agent, bundle)` → `restore_memory` →
|
||||
/// 注册到 HashMap。
|
||||
pub async fn recover(
|
||||
&self,
|
||||
session_id: &str,
|
||||
agent: Arc<dyn Agent>,
|
||||
bundle: Arc<RuntimeBundle>,
|
||||
) -> Result<Arc<Mutex<AgentSession>>, EngineError> {
|
||||
// 内存中已存在 → 拒绝(避免覆盖丢失数据)
|
||||
{
|
||||
let sessions = self.sessions.read().await;
|
||||
if sessions.contains_key(session_id) {
|
||||
return Err(EngineError::SessionAlreadyExists(session_id.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// 读 SessionMeta(如果不存在则报错)
|
||||
let meta = self
|
||||
.load_session_meta(session_id)
|
||||
.await?
|
||||
.ok_or_else(|| EngineError::SessionNotFound(session_id.to_string()))?;
|
||||
|
||||
// 从 latest checkpoint 读 SessionSnapshot
|
||||
let snapshot = self
|
||||
.checkpointer
|
||||
.latest_snapshot(session_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
EngineError::CheckpointNotFound(format!(
|
||||
"no checkpoint for session_id={session_id}"
|
||||
))
|
||||
})?;
|
||||
|
||||
// 同步重建 + 异步写回
|
||||
// 注意:restore_memory 现在是 &mut self(清空 pending_memory_restore),
|
||||
// 需要先 Arc<Mutex<>> 包装后再 lock + 调用
|
||||
let session = AgentSession::from_snapshot(snapshot, agent, bundle)?;
|
||||
let arc = Arc::new(Mutex::new(session));
|
||||
{
|
||||
let mut guard = arc.lock().await;
|
||||
guard.restore_memory().await?;
|
||||
}
|
||||
|
||||
{
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.insert(session_id.to_string(), arc.clone());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
session_id = %session_id,
|
||||
turn_index = meta.turn_count,
|
||||
"session recovered from storage"
|
||||
);
|
||||
Ok(arc)
|
||||
}
|
||||
|
||||
/// 替换 SessionManager 中指定 session_id 的 AgentSession 实例。
|
||||
///
|
||||
/// 用于 `Checkpointer::rollback_load() + from_snapshot + restore_memory` 后的无缝切换。
|
||||
///
|
||||
/// 内部执行:
|
||||
/// 1. 写回 SessionMeta
|
||||
/// 2. 调用 `session.restore_memory()` 写回持久层(`&mut self` 调用会清空 pending)
|
||||
/// 3. 内存替换
|
||||
pub async fn replace(
|
||||
&self,
|
||||
session_id: &str,
|
||||
mut session: AgentSession,
|
||||
) -> Result<(), EngineError> {
|
||||
// 1. 写回 SessionMeta(取新 session 的 turn_index)
|
||||
let meta = SessionMeta::from_session(&session, None);
|
||||
self.save_session_meta(&meta).await?;
|
||||
|
||||
// 2. restore_memory 写回持久层(pending_memory_restore → None)
|
||||
session.restore_memory().await?;
|
||||
|
||||
// 3. 替换内存中的 session
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.insert(session_id.to_string(), Arc::new(Mutex::new(session)));
|
||||
|
||||
tracing::info!(
|
||||
session_id = %session_id,
|
||||
turn_index = meta.turn_count,
|
||||
"session replaced"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 封装 `AgentSession::submit_turn`:自动加锁 + 可选自动 checkpoint。
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. `get(session_id)` 获取 session
|
||||
/// 2. lock + `session.submit_turn(user_input)`
|
||||
/// 3. 如果 `config.auto_checkpoint == true`,同步调用 `checkpointer.checkpoint(&session).await`
|
||||
/// - checkpoint 失败时通过 `tracing::error!` 记录,不阻断 `Ok` 返回
|
||||
/// - 调用方如需强持久化保证,应显式调用 `checkpointer.checkpoint()` 并处理 `Result`
|
||||
pub async fn submit_turn(
|
||||
&self,
|
||||
session_id: &str,
|
||||
user_input: impl Into<String>,
|
||||
) -> Result<crate::llm::types::response_v2::MessageResponse, EngineError> {
|
||||
let session = self.get(session_id).await?;
|
||||
let response = {
|
||||
let mut guard = session.lock().await;
|
||||
guard
|
||||
.submit_turn(user_input)
|
||||
.await
|
||||
.map_err(EngineError::from)?
|
||||
};
|
||||
|
||||
// 自动 checkpoint(在 lock 外做 I/O)
|
||||
if self.config.auto_checkpoint {
|
||||
let snapshot_session = session.lock().await;
|
||||
if let Err(e) = self.checkpointer.checkpoint(&snapshot_session).await {
|
||||
tracing::error!(
|
||||
session_id = %session_id,
|
||||
error = %e,
|
||||
"auto_checkpoint failed; submit_turn result already returned"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// 封装 `AgentSession::submit_turn_stream`:流式 API + 可选自动 checkpoint。
|
||||
///
|
||||
/// 与 `submit_turn` 的差异:自动 checkpoint 推迟到 `finalize_turn_stream` 调用时。
|
||||
/// 流期间不创建 checkpoint,避免客户端断开导致半成品 checkpoint 污染。
|
||||
pub async fn submit_turn_stream(
|
||||
&self,
|
||||
session_id: &str,
|
||||
user_input: impl Into<String>,
|
||||
) -> Result<
|
||||
std::pin::Pin<Box<dyn futures_core::Stream<Item = crate::llm::stream::StreamEvent> + Send>>,
|
||||
EngineError,
|
||||
> {
|
||||
let session = self.get(session_id).await?;
|
||||
let stream = {
|
||||
let mut guard = session.lock().await;
|
||||
guard
|
||||
.submit_turn_stream(user_input)
|
||||
.await
|
||||
.map_err(EngineError::from)?
|
||||
};
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// 流消费完成后调用:累计 cost + 触发 OnTurnEnd + 自动 checkpoint(如启用)。
|
||||
///
|
||||
/// 委托给 `AgentSession::finalize_turn`,然后在 lock 外执行 auto_checkpoint。
|
||||
pub async fn finalize_turn_stream(
|
||||
&self,
|
||||
session_id: &str,
|
||||
response: &crate::llm::types::response_v2::MessageResponse,
|
||||
new_messages_from_cycle: Vec<crate::llm::types::message::Message>,
|
||||
) -> Result<(), EngineError> {
|
||||
let session = self.get(session_id).await?;
|
||||
{
|
||||
let mut guard = session.lock().await;
|
||||
guard
|
||||
.finalize_turn(response, new_messages_from_cycle)
|
||||
.await
|
||||
.map_err(EngineError::from)?;
|
||||
}
|
||||
|
||||
if self.config.auto_checkpoint {
|
||||
let snapshot_session = session.lock().await;
|
||||
if let Err(e) = self.checkpointer.checkpoint(&snapshot_session).await {
|
||||
tracing::error!(
|
||||
session_id = %session_id,
|
||||
error = %e,
|
||||
"auto_checkpoint failed after finalize_turn"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 查询某 parent 的所有直接子 session 的 ID 列表。
|
||||
///
|
||||
/// 实现:prefix 查询所有 `session:*:meta`,过滤 `parent_id == parent_id`。
|
||||
// ponytail: O(N) 全表扫描,当前规模(≤10K session)可接受。
|
||||
// 如有性能需求,可维护 `parent:{parent_id}:children` 索引 key 替代扫描。
|
||||
pub async fn children(&self, parent_id: &str) -> Result<Vec<String>, EngineError> {
|
||||
let filter = MemoryFilter {
|
||||
prefix: Some("session:".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let items = self.store.list(&filter).await?;
|
||||
|
||||
let mut child_ids = Vec::new();
|
||||
for item in items {
|
||||
// 解析 SessionMeta JSON,过滤 parent_id
|
||||
if let Ok(meta) = serde_json::from_str::<SessionMeta>(&item.content)
|
||||
&& meta.parent_id.as_deref() == Some(parent_id)
|
||||
{
|
||||
child_ids.push(meta.session_id);
|
||||
}
|
||||
}
|
||||
Ok(child_ids)
|
||||
}
|
||||
|
||||
/// 查询某 child session 的 parent ID。
|
||||
///
|
||||
/// 如果 parent 已被销毁,返回 `Ok(None)`(允许孤儿 session 存在)。
|
||||
pub async fn parent(&self, child_id: &str) -> Result<Option<String>, EngineError> {
|
||||
let meta = self.load_session_meta(child_id).await?;
|
||||
let parent_id = match meta {
|
||||
Some(m) => m.parent_id,
|
||||
None => return Ok(None),
|
||||
};
|
||||
// 如果 parent_id 已被 destroy,load_session_meta 返回 None → 返回 Ok(None)
|
||||
match parent_id {
|
||||
Some(pid) => {
|
||||
let parent_meta = self.load_session_meta(&pid).await?;
|
||||
Ok(parent_meta.map(|_| pid))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 销毁 session:从内存移除 + 清理 SessionMeta + 清理检查点。
|
||||
///
|
||||
/// **孤儿策略**:允许孤儿 session 存在(子 session 的 `parent_id` 仍指向已删除的父,
|
||||
/// 但 `parent()` 返回 `None`)。不递归删除子 session。
|
||||
///
|
||||
/// **幂等性**:对不存在的 session 静默返回 `Ok(())`(不报错)。
|
||||
/// `MemoryStore::delete()` 和 `Checkpointer::delete_all()` 本身幂等。
|
||||
/// 调用方无需先 `get()` 检查存在性。
|
||||
pub async fn destroy(&self, session_id: &str) -> Result<(), EngineError> {
|
||||
// 从内存移除
|
||||
{
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.remove(session_id);
|
||||
}
|
||||
|
||||
// 删除 SessionMeta
|
||||
self.store
|
||||
.delete(&SessionMeta::meta_key(session_id))
|
||||
.await?;
|
||||
|
||||
// 删除所有 checkpoints
|
||||
self.checkpointer.delete_all(session_id).await?;
|
||||
|
||||
tracing::info!(session_id = %session_id, "session destroyed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 内部辅助:生成 session_id(纳秒+计数器)。
|
||||
fn generate_session_id() -> String {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
format!("sess-{:x}-{:x}", nanos, counter)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agent::builder::AgentBuilder;
|
||||
use crate::llm::hooks::HookExecutor;
|
||||
use crate::llm::mock::MockProvider;
|
||||
use crate::tools::ToolRegistry;
|
||||
|
||||
struct StubAgent(String);
|
||||
#[async_trait::async_trait]
|
||||
impl Agent for StubAgent {
|
||||
fn name(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn make_bundle() -> Arc<RuntimeBundle> {
|
||||
Arc::new(
|
||||
AgentBuilder::new()
|
||||
.provider(Arc::new(MockProvider::new(vec![])))
|
||||
.tool_registry(Arc::new(ToolRegistry::new()))
|
||||
.hook_executor(Arc::new(HookExecutor::new()))
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_and_get() {
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let sm = SessionManager::new(store);
|
||||
|
||||
let id = sm
|
||||
.create(Arc::new(StubAgent("a1".into())), make_bundle())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!id.is_empty());
|
||||
|
||||
let session = sm.get(&id).await.unwrap();
|
||||
assert_eq!(session.lock().await.session_id, id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_not_found() {
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let sm = SessionManager::new(store);
|
||||
|
||||
let err = sm.get("missing").await.unwrap_err();
|
||||
match err {
|
||||
EngineError::SessionNotFound(_) => {}
|
||||
other => panic!("expected SessionNotFound, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn destroy_removes_meta_and_checkpoints() {
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let sm = SessionManager::new(store.clone());
|
||||
|
||||
let id = sm
|
||||
.create(Arc::new(StubAgent("a1".into())), make_bundle())
|
||||
.await
|
||||
.unwrap();
|
||||
let session = sm.get(&id).await.unwrap();
|
||||
sm.checkpointer()
|
||||
.checkpoint(&*session.lock().await)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
sm.checkpointer().list_checkpoints(&id).await.unwrap().len(),
|
||||
1
|
||||
);
|
||||
|
||||
sm.destroy(&id).await.unwrap();
|
||||
|
||||
// 内存中查不到
|
||||
assert!(sm.get(&id).await.is_err());
|
||||
// SessionMeta 已删除
|
||||
assert!(sm.load_session_meta(&id).await.unwrap().is_none());
|
||||
// Checkpoint 已删除
|
||||
assert_eq!(
|
||||
sm.checkpointer().list_checkpoints(&id).await.unwrap().len(),
|
||||
0
|
||||
);
|
||||
|
||||
// destroy 不存在的 session 不报错
|
||||
sm.destroy(&id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_child_inherits_parent_bundle() {
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let sm = SessionManager::new(store);
|
||||
|
||||
let parent_id = sm
|
||||
.create(Arc::new(StubAgent("parent".into())), make_bundle())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let child_id = sm
|
||||
.create_child(&parent_id, Arc::new(StubAgent("child".into())))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(child_id, parent_id);
|
||||
|
||||
let children = sm.children(&parent_id).await.unwrap();
|
||||
assert_eq!(children, vec![child_id.clone()]);
|
||||
|
||||
let parent = sm.parent(&child_id).await.unwrap();
|
||||
assert_eq!(parent, Some(parent_id.clone()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_child_parent_not_found() {
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let sm = SessionManager::new(store);
|
||||
|
||||
let err = sm
|
||||
.create_child("nope", Arc::new(StubAgent("c".into())))
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
EngineError::SessionNotFound(_) => {}
|
||||
other => panic!("expected SessionNotFound, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parent_returns_none_after_parent_destroyed() {
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let sm = SessionManager::new(store);
|
||||
|
||||
let parent_id = sm
|
||||
.create(Arc::new(StubAgent("p".into())), make_bundle())
|
||||
.await
|
||||
.unwrap();
|
||||
let child_id = sm
|
||||
.create_child(&parent_id, Arc::new(StubAgent("c".into())))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sm.destroy(&parent_id).await.unwrap();
|
||||
|
||||
// 父被销毁 → parent() 返回 None(孤儿策略)
|
||||
assert_eq!(sm.parent(&child_id).await.unwrap(), None);
|
||||
// 孤儿 session 仍然存在于存储
|
||||
assert!(sm.load_session_meta(&child_id).await.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_after_recover() {
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let sm = SessionManager::new(store.clone());
|
||||
|
||||
// 1. 创建 session + checkpoint
|
||||
let id = sm
|
||||
.create(Arc::new(StubAgent("a".into())), make_bundle())
|
||||
.await
|
||||
.unwrap();
|
||||
{
|
||||
let s = sm.get(&id).await.unwrap();
|
||||
s.lock().await.set_session_data("k", "v1").await.unwrap();
|
||||
sm.checkpointer()
|
||||
.checkpoint(&*s.lock().await)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 2. 模拟"进程重启"——清空内存但保留 store
|
||||
let sm2 = SessionManager::new(store.clone());
|
||||
let bundle = make_bundle();
|
||||
|
||||
// 3. recover
|
||||
let recovered = sm2
|
||||
.recover(&id, Arc::new(StubAgent("a".into())), bundle)
|
||||
.await
|
||||
.unwrap();
|
||||
let recovered_session = recovered.lock().await;
|
||||
let v = recovered_session.get_session_data("k").await.unwrap();
|
||||
assert_eq!(v, Some("v1".into()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recover_session_already_in_memory() {
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let sm = SessionManager::new(store.clone());
|
||||
|
||||
let id = sm
|
||||
.create(Arc::new(StubAgent("a".into())), make_bundle())
|
||||
.await
|
||||
.unwrap();
|
||||
{
|
||||
let s = sm.get(&id).await.unwrap();
|
||||
sm.checkpointer()
|
||||
.checkpoint(&*s.lock().await)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let err = sm
|
||||
.recover(&id, Arc::new(StubAgent("a".into())), make_bundle())
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
EngineError::SessionAlreadyExists(_) => {}
|
||||
other => panic!("expected SessionAlreadyExists, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn children_empty() {
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let sm = SessionManager::new(store);
|
||||
|
||||
let id = sm
|
||||
.create(Arc::new(StubAgent("a".into())), make_bundle())
|
||||
.await
|
||||
.unwrap();
|
||||
// 无子 session
|
||||
assert_eq!(sm.children(&id).await.unwrap(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_turn_no_provider_responses() {
|
||||
// 没有 LLM 响应 → MockProvider 返回 LlmError → EngineError::Llm
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let sm = SessionManager::new(store);
|
||||
|
||||
let id = sm
|
||||
.create(Arc::new(StubAgent("a".into())), make_bundle())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let err = sm.submit_turn(&id, "hello").await.unwrap_err();
|
||||
match err {
|
||||
EngineError::Agent(crate::agent::error::AgentError::Llm(_)) => {}
|
||||
other => panic!(
|
||||
"expected EngineError::Agent(AgentError::Llm), got {:?}",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_turn_auto_checkpoint_off() {
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let config = SessionManagerConfig {
|
||||
auto_checkpoint: false,
|
||||
default_bundle: None,
|
||||
};
|
||||
let sm = SessionManager::with_config(store, config);
|
||||
|
||||
let id = sm
|
||||
.create(Arc::new(StubAgent("a".into())), make_bundle())
|
||||
.await
|
||||
.unwrap();
|
||||
let s = sm.get(&id).await.unwrap();
|
||||
// 配置 auto_checkpoint=false → 即便 submit_turn 失败(MockProvider 空响应)也不会触发 checkpoint
|
||||
let _ = sm.submit_turn(&id, "x").await;
|
||||
|
||||
// 手动 checkpoint 仍可工作
|
||||
sm.checkpointer()
|
||||
.checkpoint(&*s.lock().await)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
sm.checkpointer().list_checkpoints(&id).await.unwrap().len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_preserves_session_id() {
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let sm = SessionManager::new(store);
|
||||
|
||||
let id = sm
|
||||
.create(Arc::new(StubAgent("a".into())), make_bundle())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 构造一个新 session(同 session_id)然后 replace
|
||||
let mut new_session =
|
||||
AgentSession::new(Arc::new(StubAgent("a".into())), &id, make_bundle());
|
||||
new_session
|
||||
.set_session_data("replaced", "yes")
|
||||
.await
|
||||
.unwrap();
|
||||
sm.replace(&id, new_session).await.unwrap();
|
||||
|
||||
let v = sm
|
||||
.get(&id)
|
||||
.await
|
||||
.unwrap()
|
||||
.lock()
|
||||
.await
|
||||
.get_session_data("replaced")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(v, Some("yes".into()));
|
||||
}
|
||||
|
||||
// ====== 实施审查补充:3 个边界测试 ======
|
||||
|
||||
/// 序列化向前兼容:旧版 SessionSnapshot 缺少新字段时,`#[serde(default)]` 兜底生效。
|
||||
#[test]
|
||||
fn snapshot_deserialize_with_minimal_fields() {
|
||||
// 构造一个 v0.1 风格的最小 JSON(仅含核心标识字段,缺少 cost_so_far/slots/
|
||||
// session_memory_data/last_summary_turn)
|
||||
let minimal_json = r#"{
|
||||
"session_id": "legacy-session",
|
||||
"agent_name": "legacy",
|
||||
"turn_index": 5,
|
||||
"current_slot_id": "default"
|
||||
}"#;
|
||||
let snapshot: crate::engine::snapshot::SessionSnapshot =
|
||||
serde_json::from_str(minimal_json).expect("应能反序列化最小 JSON");
|
||||
|
||||
// 核心字段保留
|
||||
assert_eq!(snapshot.session_id, "legacy-session");
|
||||
assert_eq!(snapshot.agent_name, "legacy");
|
||||
assert_eq!(snapshot.turn_index, 5);
|
||||
assert_eq!(snapshot.current_slot_id, "default");
|
||||
|
||||
// 可选字段走 #[serde(default)]
|
||||
assert_eq!(snapshot.cost_so_far.total().total_tokens, 0);
|
||||
assert!(snapshot.slots.is_empty());
|
||||
assert!(snapshot.session_memory_data.is_empty());
|
||||
assert_eq!(snapshot.last_summary_turn, None);
|
||||
}
|
||||
|
||||
/// restore_memory 幂等性:第二次调用应立即返回 Ok(())(pending 已被清空)。
|
||||
#[tokio::test]
|
||||
async fn restore_memory_is_idempotent() {
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let sm = SessionManager::new(store.clone());
|
||||
|
||||
let id = sm
|
||||
.create(Arc::new(StubAgent("a".into())), make_bundle())
|
||||
.await
|
||||
.unwrap();
|
||||
{
|
||||
let s = sm.get(&id).await.unwrap();
|
||||
s.lock().await.set_session_data("k", "v").await.unwrap();
|
||||
sm.checkpointer()
|
||||
.checkpoint(&*s.lock().await)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 模拟"进程重启"——新建 SessionManager,复用 store
|
||||
let sm2 = SessionManager::new(store.clone());
|
||||
let recovered = sm2
|
||||
.recover(&id, Arc::new(StubAgent("a".into())), make_bundle())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 1. recover 已调用 restore_memory → pending_memory_restore 应为 None
|
||||
assert!(!recovered.lock().await.has_pending_memory_restore());
|
||||
|
||||
// 2. 再次 restore_memory → 幂等(不会二次写入,不会 panic)
|
||||
recovered.lock().await.restore_memory().await.unwrap();
|
||||
assert!(!recovered.lock().await.has_pending_memory_restore());
|
||||
|
||||
// 3. 第三次仍然幂等
|
||||
recovered.lock().await.restore_memory().await.unwrap();
|
||||
}
|
||||
|
||||
/// 10 并发 session 创建:验证 RwLock 写锁争用下不冲突,所有 ID 唯一。
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn concurrent_create_ten_sessions() {
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let sm = Arc::new(SessionManager::new(store));
|
||||
|
||||
let mut handles = Vec::with_capacity(10);
|
||||
for _ in 0..10 {
|
||||
let sm_clone = Arc::clone(&sm);
|
||||
handles.push(tokio::spawn(async move {
|
||||
sm_clone
|
||||
.create(Arc::new(StubAgent("a".into())), make_bundle())
|
||||
.await
|
||||
}));
|
||||
}
|
||||
|
||||
let mut ids = Vec::with_capacity(10);
|
||||
for h in handles {
|
||||
ids.push(h.await.expect("task join").expect("create ok"));
|
||||
}
|
||||
|
||||
// 所有 ID 唯一
|
||||
let unique: std::collections::HashSet<_> = ids.iter().collect();
|
||||
assert_eq!(unique.len(), 10, "并发创建应产生 10 个唯一 session_id");
|
||||
|
||||
// 全部可 get
|
||||
for id in &ids {
|
||||
assert!(sm.get(id).await.is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! SessionSnapshot —— 见下文 doc comment。Step 3 将填充完整实现。
|
||||
//! Step 2 仅占位:定义空 struct + derive,使 `engine` 模块编译通过。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// SessionMemory 条目的可序列化形式(保留元数据与时间戳)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SessionMemoryEntry {
|
||||
/// 原始值(字符串)。
|
||||
pub value: String,
|
||||
/// 元数据(自由 JSON)。
|
||||
#[serde(default)]
|
||||
pub metadata: serde_json::Value,
|
||||
/// 创建时间(Unix 时间戳秒;`None` 兼容旧快照)。
|
||||
///
|
||||
/// 类型为 `i64` 而非 `u64`:`time` crate 的 `OffsetDateTime::from_unix_timestamp` 接收 `i64`,
|
||||
/// 这里保持与 `SessionMemory::set_with_meta` 签名一致,避免来回转换。
|
||||
#[serde(default)]
|
||||
pub created_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// AgentSession 的可序列化快照。
|
||||
///
|
||||
/// Step 2 占位:字段已定义但未实装 to_snapshot/from_snapshot。
|
||||
/// Step 3 将基于 `#[serde(default)]` 宽松反序列化,添加 `agent_name`/`turn_index`/slot 等字段。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SessionSnapshot {
|
||||
pub session_id: String,
|
||||
pub agent_name: String,
|
||||
pub turn_index: u32,
|
||||
#[serde(default)]
|
||||
pub cost_so_far: crate::llm::types::usage::CostTracker,
|
||||
#[serde(default)]
|
||||
pub slots: std::collections::HashMap<String, crate::agent::context::ContextSlot>,
|
||||
pub current_slot_id: String,
|
||||
pub last_summary_turn: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub session_memory_data: std::collections::HashMap<String, SessionMemoryEntry>,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user