Compare commits
74
Commits
v0.1.0
...
8ea01d373e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
32d886f870 | ||
|
|
b04427e83f | ||
|
|
d4c4d8fa3c | ||
|
|
4686063ca8 | ||
|
|
c36668071e | ||
|
|
d4f27b5865 | ||
|
|
1c0e1e0ed1 | ||
|
|
760de46623 | ||
|
|
f8df6a9421 | ||
|
|
802518b5fe | ||
|
|
993118f661 | ||
|
|
4348e4bf3e | ||
|
|
0dc91faa43 | ||
|
|
b4e5c7d651 | ||
|
|
71abe881ed | ||
|
|
2af92cd554 | ||
|
|
635942248b | ||
|
|
fe51961202 | ||
|
|
212cfcc916 | ||
|
|
88d00ac927 | ||
|
|
358e971094 | ||
|
|
85b92ae9d4 | ||
|
|
57b2fbaaed | ||
|
|
2c8e31919d | ||
|
|
c6651c9b75 | ||
|
|
e636e16820 | ||
|
|
1c89d23ba2 | ||
|
|
5b4343a051 | ||
|
|
6e1182e64c | ||
|
|
b8f4fe0fe3 | ||
|
|
7574f9c24c | ||
|
|
c82af60f81 | ||
|
|
c8a91f6eaf | ||
|
|
13edacd775 | ||
|
|
821cea8e60 | ||
|
|
517ef7db32 | ||
|
|
4cf5918b9c | ||
|
|
9da9b83167 | ||
|
|
b1875192fd | ||
|
|
d3067e2f53 | ||
|
|
5648b1d217 | ||
|
|
98dfe6c1ed | ||
|
|
9e476e79bb | ||
|
|
3bd135ec98 | ||
|
|
76f3235ed7 | ||
|
|
6315f2d008 | ||
|
|
fba78f5f33 |
@@ -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"
|
||||
@@ -198,6 +198,18 @@ pub use vector_store::VectorStore;
|
||||
5. **风险评估** - 潜在风险、缓解措施
|
||||
6. **验收标准** - 可验证的完成条件
|
||||
|
||||
### 进度同步规范 (docs/roadmap.md)
|
||||
|
||||
完成一项实施后,必须检查 `docs/roadmap.md` 是否存在对应内容;若存在,必须同步标记为完成:
|
||||
|
||||
- **Step / Phase 状态行**:对应 Step 加 ✅ 标记;Phase 章节末尾「状态」行从 ⏳ 改为 ✅ Phase X 全部交付物已完成
|
||||
- **里程碑表**:更新对应里程碑状态从 ⏳ 改为 ✅ + 完成日期
|
||||
- **依赖关系图(Mermaid)**:节点 `class` 从 `pending` / `core` 改为 `done`,必要时更新节点摘要
|
||||
- **文末「已完成 / 进行中阶段」列表**:追加一行 `- ✅ Phase X — 一句话要点`
|
||||
- **顶部「当前状态」**:补充新完成 Phase,更新「下一步」指向
|
||||
|
||||
参考案例:2026-07-05 完成 Phase 7 SqliteStore 时同步更新 6 处(顶部状态 / Phase 章节 / 依赖图 / M3 / 下一步行动 / 已完成列表)。
|
||||
|
||||
---
|
||||
|
||||
## 项目特定规则
|
||||
|
||||
+124
@@ -2,6 +2,130 @@
|
||||
|
||||
本项目所有重要变更均记录于此文件。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
|
||||
|
||||
## [0.3.0] - 未发布
|
||||
|
||||
v0.3.0 首个增量 Phase。技术债清理 + ContextSlot fork/merge + Phase 9 审查修复。
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
**类型路径变更(0.3.0):**
|
||||
- `agcore::llm::types::request::ToolChoice` → `agcore::llm::types::tool::ToolChoice`(公共 re-export 路径 `agcore::llm::types::ToolChoice` 保持不变)
|
||||
- `agcore::llm::types::request::StreamOptions` → `agcore::llm::provider::openai::StreamOptions`
|
||||
- `agcore::llm::types::request::OpenaiChatRequest` → `agcore::llm::provider::openai::OpenaiChatRequest`
|
||||
- `agcore::llm::types::response::OpenaiChatResponse` → `agcore::llm::provider::openai::OpenaiChatResponse`
|
||||
- `agcore::llm::types::response::OpenaiChatChunk` → `agcore::llm::provider::openai::OpenaiChatChunk`
|
||||
- 其余 `request.rs`/`response.rs` 中的 wire-format 类型(`OpenaiTool`、`AudioParam`、`Choice`、`Delta`、`ChunkChoice`、`Annotation`、`Logprobs`、`TokenLogprob`、`URLCitation` 等)同步移入 `agcore::llm::provider::openai` 模块,可见性 `pub(crate)`
|
||||
|
||||
**类型删除:**
|
||||
- `agcore::llm::types::ChatResponse` 已删除(自 v0.1.0 标记 `#[deprecated]`,请改用 `MessageResponse`)
|
||||
- `agcore::llm::types::old_stream::LegacyStreamEvent` 已删除(内部死代码)
|
||||
|
||||
**模块签名变化:**
|
||||
- `LlmCycle::convert_request` / `convert_response` 由 `pub` 降级为 `pub(crate)`(因依赖的 `OpenaiChatRequest` / `OpenaiChatResponse` 已 `pub(crate)`)
|
||||
|
||||
### Added
|
||||
|
||||
**Phase 13 — ContextSlot fork/merge**
|
||||
- `ContextSlot::fork(child_id, strategy)` — 从父槽派生独立子槽(数据层操作,不持久化;调用方需自行 `save()`)
|
||||
- `ContextSlot::merge(child, strategy)` — 将子槽消息合并回父槽(`Append` 追加 / `Replace` 替换两种策略)
|
||||
- `MergeStrategy` 枚举(`#[non_exhaustive]`,Phase 16 可扩展 `Summarize`)
|
||||
- `MergeStrategy` 防御性检查:禁止 self-merge / 跨 session merge / 合并到 Readonly slot
|
||||
- `agcore::agent::MergeStrategy` 公共 re-export 路径可用
|
||||
- `AgentSession::derive_slot` 重构复用 `fork()` 消除重复代码(行为不变)
|
||||
|
||||
**Phase 9 实施审查修复(2026-07-08)**
|
||||
- 2 个集成测试覆盖方案 §4 Step 5:`submit_turn_stream_end_to_end` + `submit_turn_stream_triggers_turn_hooks`
|
||||
|
||||
### Changed
|
||||
|
||||
**Phase 13 — 技术债清理**
|
||||
- 3 个旧 types 文件删除(`src/llm/types/request.rs` 187 行 + `response.rs` 177 行 + `old_stream.rs` 45 行)
|
||||
- 所有 OpenAI wire-format 类型迁入 `provider/openai.rs`,可见性 `pub(crate)`
|
||||
- `src/llm/stream.rs` 简化为 module doc + `pub use` 重导出(保持 `use crate::llm::stream::StreamEvent` 路径兼容,零下游破坏)
|
||||
- `ToolChoice` 从 `request.rs` 迁入 `tool.rs`(serde impl 原样搬入)
|
||||
|
||||
**Phase 9 实施审查修复**
|
||||
- `LlmCycle::run_tool_loop` 实现 `PreRequest` hook(之前 `let _ = hook_executor.as_ref()` 是空操作,导致 hook-based logging/monitoring 在流式工具循环中失效;现在与 `submit_with_tools` 行为对齐,含 `should_block` 检查,阻断时通过 `StreamEvent::Error` 事件化)
|
||||
|
||||
### Fixed
|
||||
|
||||
**Phase 9 实施审查修复**
|
||||
- `AgentSession::submit_turn_stream` 末尾 `let _ = hook_executor;` 死代码移除(Arc 引用生命周期由 Arc 自动管理)
|
||||
|
||||
### Migration Guide (v0.2.0-rc.1 → v0.3.0)
|
||||
|
||||
```rust
|
||||
// ❌ v0.2.0-rc.1 — 已删除
|
||||
use agcore::llm::types::ChatResponse;
|
||||
use agcore::llm::types::request::OpenaiChatRequest;
|
||||
|
||||
// ✅ v0.3.0 — 替代路径
|
||||
use agcore::llm::types::MessageResponse; // ChatResponse → MessageResponse
|
||||
// OpenAI wire-format 类型为内部使用,不再公共 re-export
|
||||
// 如需自定义 Provider,请直接 import agcore::llm::provider::openai::*(当前 pub(crate))
|
||||
```
|
||||
|
||||
## [0.2.0-rc.1] - 2026-07-05
|
||||
|
||||
v0.2.0 候选发布。Phase 5-7 三大 P0 全部交付完成,API 稳定性扫尾,新增 2 个面向新用户的集成示例。
|
||||
|
||||
### Added
|
||||
|
||||
**Phase 5 — 热身准备**
|
||||
- `ProviderConfig::from_env(prefix)`:从 `{prefix}_BASE_URL` / `{prefix}_API_KEY` / `{prefix}_MODEL` / `{prefix}_TIMEOUT_SECS` / `{prefix}_MAX_RETRIES` 环境变量构造配置
|
||||
- `ProviderConfig::timeout_secs` / `max_retries` 字段(默认 30 / 3)
|
||||
- `OllamaProvider`:本地推理 Provider(OpenAI-compatible,`http://localhost:11434/v1` 默认端点)
|
||||
- `ProviderType::Ollama` 变体 + `FromStr` 解析
|
||||
|
||||
**Phase 6 — ToolDef IR 正式化**
|
||||
- `ToolDef` 结构体(name / description / parameters)替代已废弃的 `OpenaiToolDefinition`
|
||||
- `MessageRequest.tools` 切换为 `Vec<ToolDef>`
|
||||
- `OpenaiToolDefinition` 降级为 `#[doc(hidden)]`,仅供 OpenAI 适配层内部消费
|
||||
|
||||
**Phase 7 — SqliteStore 持久化**
|
||||
- `SqliteStore`:`MemoryStore` 的 SQLite 后端实现,基于 `rusqlite 0.32` bundled
|
||||
- WAL 模式 + `synchronous=NORMAL` + `busy_timeout=5s` 兼顾崩溃安全与吞吐
|
||||
- `Arc<Mutex<Connection>>` + `spawn_blocking` 卸载 IO;10×10 并发写入无 race
|
||||
- `MemoryStore::save / get / delete / list` CRUD + prefix / since / offset+limit 过滤
|
||||
- 进程重启数据不丢的 round-trip 验证
|
||||
|
||||
**Phase 8 — MVP 集成出口**
|
||||
- `examples/quick_start`:30 行最小可运行示例(MockProvider + EchoTool + submit_turn)
|
||||
- `examples/end_to_end`:3 工具 + 3 轮对话 + SqliteStore 持久化跨连接验证
|
||||
|
||||
### Changed
|
||||
|
||||
- **API 稳定性护栏**:14 个公开枚举标记 `#[non_exhaustive]`,覆盖 P0 核心 IR(`Message` / `ContentBlock` / `ContentBlockType` / `StreamEvent` / `HookEvent`)、P0 Error(`AgentError` / `LlmError` / `ToolError` / `MemoryError` / `PromptError`)、P1 其他(`MemoryStrategy` / `StepStatus` / `ToolChoice` / `ResponseFormat`)
|
||||
- **`StepStatus::Completed` 字段类型**:从废弃的 `ChatResponse` 切换为 IR 层 `MessageResponse`(同时清理 `task_agent_demo.rs` 的 `ChatResponse` / `OpenaiChatMessage` / `FinishReason` 三处废弃类型引用)
|
||||
|
||||
### Non-exhaustive 清单
|
||||
|
||||
为防止未来新增变体时下游 exhaustive match 静默失效,14 个枚举追加 `#[non_exhaustive]`:
|
||||
|
||||
| 优先级 | 枚举 |
|
||||
|--------|------|
|
||||
| P0 核心 IR | `Message`, `ContentBlock`, `ContentBlockType`, `StreamEvent`, `HookEvent` |
|
||||
| P0 Error | `AgentError`, `LlmError`, `ToolError`, `MemoryError`, `PromptError` |
|
||||
| P1 其他 | `MemoryStrategy`, `StepStatus`, `ToolChoice`, `ResponseFormat` |
|
||||
|
||||
明确不加:内部 wire-format(`OpenaiChatMessage` 等)/ 语义已收敛(`Role` / `ServiceTier` / `Modality` / `ImageDetail` / `AudioFormat` / `StopSequence`)/ 使用面窄(`Permission` / `McpTransport` 等)。
|
||||
|
||||
### Deprecated
|
||||
|
||||
(继承自 0.1.0,无新增)`ChatResponse` / `ToolDefinition` 保持 `#[deprecated]` 标记。
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复 `StepStatus::Completed(ChatResponse)` 字段类型与 IR 体系不一致问题(已完成迁移)
|
||||
|
||||
### Migration Guide (v0.1 → v0.2.0-rc.1)
|
||||
|
||||
1. **枚举 match**:14 个 `#[non_exhaustive]` 枚举在 crate 外必须使用 `_ =>` 通配分支
|
||||
2. **`StepStatus::Completed`**:字段类型从 `ChatResponse` 切换为 `MessageResponse`,需做字段映射(参考 `docs/15-phase8-mvp-integration.md` §3.1.2)
|
||||
3. **`ToolDefinition` → `ToolDef`**:Phase 6 已彻底替换 `#[deprecated]` 别名,需全局重命名
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] - 2026-07-04
|
||||
|
||||
首个公开版本。涵盖 Phase 0-4c 的全部核心能力、Provider IR 重构、LlmCycle 简化,以及面向用户的 7 个离线示例。
|
||||
|
||||
+142
-12
@@ -1,26 +1,156 @@
|
||||
[package]
|
||||
name = "agcore"
|
||||
version = "0.1.0"
|
||||
version = "0.3.4"
|
||||
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"] }
|
||||
|
||||
# 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.1"
|
||||
agcore = "0.3"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
```
|
||||
|
||||
@@ -38,7 +38,7 @@ use std::sync::Arc;
|
||||
use agcore::agent::{Agent, AgentBuilder, AgentSession};
|
||||
use agcore::llm::hooks::HookExecutor;
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::provider::LlmProvider;
|
||||
use agcore::llm::LlmProvider;
|
||||
use agcore::llm::types::message::{ContentBlock, Message};
|
||||
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
|
||||
use agcore::llm::types::Usage;
|
||||
@@ -110,10 +110,12 @@ let provider = create_provider(
|
||||
).expect("创建 Provider 失败");
|
||||
```
|
||||
|
||||
更多端到端示例见 [`examples/`](./examples/) 目录(共 7 个,全部可 `cargo run --example <name>`):
|
||||
更多端到端示例见 [`examples/`](./examples/) 目录(全部可 `cargo run --example <name>`):
|
||||
|
||||
| 示例 | 说明 |
|
||||
|------|------|
|
||||
| `quick_start` | **最短可运行示例**:MockProvider + EchoTool + submit_turn,新用户 5 分钟上手 |
|
||||
| `end_to_end` | **完整集成示例**:3 工具 + 3 轮对话 + SqliteStore 持久化跨连接验证 |
|
||||
| `agent_session_demo` | Agent + 会话 + SessionMemory 完整链路(MockProvider 离线) |
|
||||
| `custom_tool` | 自定义工具注册、单次 / 并行调用、权限检查 |
|
||||
| `prompt_composer` | 提示词模板与组合器(纯离线) |
|
||||
@@ -121,16 +123,112 @@ let provider = create_provider(
|
||||
| `conversation_memory_demo` | 对话记忆滑动窗口与隔离 |
|
||||
| `knowledge_search_demo` | 知识页面关键词检索 |
|
||||
| `streaming_events_demo` | LLM 流式响应事件消费(含错误路径) |
|
||||
| `simple_visit` | 真实 LLM 调用(OpenAI / Anthropic,设置 `OPENAI_*` / `ANTHROPIC_*` 环境变量) |
|
||||
| `document_demo` | 文档分割:RecursiveCharacterSplitter 将长文本切分为可嵌入片段 |
|
||||
| `knowledge_graph_demo` | 知识图谱:实体-关系 CRUD、BFS 遍历、关键词/标签检索 |
|
||||
| `context_slot_demo` | 多上下文分区:ContextSlot 分区管理、FocusedConfig 聚焦策略 |
|
||||
| `sub_agent_dispatch_demo` | 子任务分发:SubTask 异步执行与结果汇聚 |
|
||||
| `dispatch_stream_demo` | 分发流式输出:SubTaskStreamEvent 实时消费 |
|
||||
| `engine_demo` | Agent 执行引擎:SessionManager 会话树 + Checkpointer 快照恢复 |
|
||||
| `bridge_keys_demo` | 桥接键:Agent 间上下文键值透传 |
|
||||
| `agent_switch_demo` | Agent 热切换:会话中动态切换 Agent 角色 |
|
||||
| `response_api_demo` | OpenAI Response API(`POST /responses`)真实调用 |
|
||||
|
||||
## Feature 组合
|
||||
|
||||
AG Core 通过 Cargo features 让下游按需选择模块,跳过不需要的编译单元和重型依赖。`default = ["full"]` 保持向后兼容——不指定 features 时行为与 v0.3.0 一致。
|
||||
|
||||
### 快捷组合
|
||||
|
||||
| 组合 | 场景 | 包含的 features |
|
||||
|------|------|----------------|
|
||||
| `full`(default) | 全栈使用,兼容 v0.3.0 | 全部 16 个 feature |
|
||||
| `light` | 生产常用,跳过 Anthropic/DeepSeek/Qwen/Ollama | llm + provider-openai + tools + tools-mcp + memory + agent + engine + prompt + document |
|
||||
| `chat` | 纯对话(跳过 SQLite 和 MCP) | agent + provider-openai |
|
||||
| `multi` | 多 Agent 复合(chat + subagent + switch + checkpointer) | engine + provider-openai |
|
||||
|
||||
### Cargo.toml 配置示例
|
||||
|
||||
```toml
|
||||
# 默认全栈(兼容 v0.3.0)
|
||||
[dependencies]
|
||||
agcore = "0.3"
|
||||
|
||||
# 纯对话场景:跳过 SQLite 和 MCP,编译更快
|
||||
[dependencies]
|
||||
agcore = { version = "0.3", default-features = false, features = ["chat", "provider-openai"] }
|
||||
|
||||
# 生产常用:OpenAI + 工具 + 记忆 + Agent
|
||||
[dependencies]
|
||||
agcore = { version = "0.3", default-features = false, features = ["light"] }
|
||||
|
||||
# 多 Agent 复合 + MCP 工具
|
||||
[dependencies]
|
||||
agcore = { version = "0.3", default-features = false, features = ["multi", "provider-openai", "tools-mcp"] }
|
||||
```
|
||||
|
||||
### 模块级 features
|
||||
|
||||
如需更细粒度控制,可单独启用模块级 features:
|
||||
|
||||
| Feature | 覆盖内容 | imply |
|
||||
|---------|---------|-------|
|
||||
| `document` | Document + RecursiveCharacterSplitter | — |
|
||||
| `llm-types` | Message / ToolDef / Usage 等 IR 类型 | — |
|
||||
| `prompt` | PromptTemplate + PromptComposer | `llm-types` |
|
||||
| `llm` | Provider trait + LlmCycle + hooks + compact + embedding + mock | `llm-types` |
|
||||
| `tools` | BaseTool + ToolRegistry | `llm-types` |
|
||||
| `tools-mcp` | 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` 递归字符级分割) |
|
||||
|
||||
## 架构关系图
|
||||
|
||||
@@ -140,9 +238,14 @@ let provider = create_provider(
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│ 使用
|
||||
┌───────────────────────────▼─────────────────────────────────┐
|
||||
│ Agent Engine (agcore::engine) │
|
||||
│ SessionManager / Checkpointer / SubTask 分发 / Agent 切换 │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│ 编排
|
||||
┌───────────────────────────▼─────────────────────────────────┐
|
||||
│ Agent Runtime (agcore::agent) │
|
||||
│ Agent / AgentBuilder / RuntimeBundle / AgentSession / │
|
||||
│ SessionMemory / Plan / Step │
|
||||
│ ContextSlot / SummaryConfig / Plan / Step │
|
||||
└─────┬───────────────┬───────────────┬───────────────┬───────┘
|
||||
│ │ │ │
|
||||
┌─────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||
@@ -151,10 +254,15 @@ let provider = create_provider(
|
||||
│ llm │ │ prompt │ │ tools │ │ memory │
|
||||
└─────┬─────┘ └─────────────┘ └─────┬───────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
└──────────────┬────────────────┘ │
|
||||
▼ │
|
||||
┌─────────────────┐ │
|
||||
│ Mock Provider │◄──────────────────────┘
|
||||
│ ┌──────────┐ │ ┌───────────┴──────┐
|
||||
│ │ Document │ │ │ Graph / Vector │
|
||||
│ │ agcore:: │ │ │ RagPipeline │
|
||||
│ │ document │ │ │ Retriever │
|
||||
│ └──────────┘ │ └──────────────────┘
|
||||
└──────────────┬────────────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Mock Provider │
|
||||
│ 公开 API │ 离线测试 / 示例
|
||||
└─────────────────┘
|
||||
```
|
||||
@@ -163,28 +271,35 @@ let provider = create_provider(
|
||||
|
||||
```mermaid
|
||||
graph BT
|
||||
LLM["<b>llm</b><br/>Provider / Cycle /<br/>Hooks / Stream /<br/>Compact / Mock"]:::core
|
||||
LLM["<b>llm</b><br/>Provider / Cycle /<br/>Hooks / Stream /<br/>Compact / Embedding /<br/>Mock"]:::core
|
||||
Prompt["<b>prompt</b><br/>Template / Composer"]:::core
|
||||
Tool["<b>tools</b><br/>BaseTool / Registry /<br/>Permission / MCP"]:::core
|
||||
Memory["<b>memory</b><br/>Store / Conversation /<br/>Knowledge / Retriever"]:::core
|
||||
Agent["<b>agent</b><br/>Agent / Builder /<br/>Session / Plan"]:::core
|
||||
Tool["<b>tools</b><br/>BaseTool / Registry /<br/>MCP"]:::core
|
||||
Memory["<b>memory</b><br/>Store / Conversation /<br/>Knowledge / Graph /<br/>VectorStore / RagPipeline /<br/>Retriever"]:::core
|
||||
Agent["<b>agent</b><br/>Agent / Builder /<br/>Session / ContextSlot /<br/>Summary / Plan"]:::core
|
||||
Engine["<b>engine</b><br/>SessionManager /<br/>Checkpointer /<br/>SubTask / Switch"]:::phase
|
||||
Document["<b>document</b><br/>Splitter"]:::phase
|
||||
|
||||
Prompt --> LLM
|
||||
Tool --> LLM
|
||||
Memory --> LLM
|
||||
Memory --> Document
|
||||
Document --> LLM
|
||||
Agent --> LLM
|
||||
Agent --> Tool
|
||||
Agent --> Memory
|
||||
Engine --> Agent
|
||||
|
||||
classDef core fill:#60a5fa,stroke:#2563eb,color:#fff
|
||||
classDef phase fill:#a78bfa,stroke:#7c3aed,color:#fff
|
||||
```
|
||||
|
||||
**依赖规则**:
|
||||
|
||||
- `llm` 是叶子,被其他四个模块使用
|
||||
- `prompt` / `tools` / `memory` 互相不依赖,可独立使用
|
||||
- `agent` 编译期依赖 `llm` / `tools` / `memory`,与 `prompt` 无直接编译依赖(system prompt 以 `&str` 形式传入)
|
||||
- 上层应用只应依赖 `agent` + 必要的子模块,不应跨层直接 `use`
|
||||
- `llm` 被 `agent` / `engine` 使用,同时 `llm::cycle` 依赖 `tools::ToolRegistry`(用于工具调用循环)
|
||||
- `prompt` / `tools` 互不依赖,可独立使用;`memory` 依赖 `document`(向量存储的分割器)和 `llm`(embedding trait)
|
||||
- `agent` 编译期依赖 `llm` / `tools` / `memory`,与 `prompt` / `document` 无直接编译依赖(system prompt 以 `&str` 形式传入,文档分割由应用层处理)
|
||||
- `engine` 编译期依赖 `agent`,提供会话树管理和快照恢复能力
|
||||
- 上层应用通常依赖 `engine`(完整能力)或 `agent`(轻量场景),不应跨层直接 `use`
|
||||
|
||||
## 环境变量
|
||||
|
||||
@@ -198,7 +313,7 @@ graph BT
|
||||
| `ANTHROPIC_API_KEY` | 用 Anthropic 时 | — | Anthropic Claude API key |
|
||||
| `ANTHROPIC_BASE_URL` | 是 | `https://api.anthropic.com` | Anthropic 兼容端点 base URL |
|
||||
| `ANTHROPIC_MODEL` | 是 | `claude-3-5-sonnet-latest` | Claude 模型名 |
|
||||
| `PROVIDER` | 否 | `openai` | Provider 类型:`openai` / `openai-response` / `anthropic` / `deepseek` / `qwen` |
|
||||
| `PROVIDER` | 否 | `openai` | Provider 类型:`openai` / `anthropic` / `deepseek` / `qwen` / `ollama` |
|
||||
| `RUST_LOG` | 否 | `agcore=info` | tracing 日志级别(其他 crate 可加 `=debug`) |
|
||||
|
||||
示例(运行 `examples/simple_visit.rs` 真实调用 OpenAI):
|
||||
@@ -223,7 +338,7 @@ AG Core 在 Phase 4 设计阶段调研了 4 个 2026 年公开的 AI Agent 项
|
||||
| [OpenHuman](https://github.com/tinyhumansai/openhuman) | 桌面助手 | Rust + Tauri | 记忆树与 Token 压缩 |
|
||||
| [OpenHarness](https://github.com/HKUDS/OpenHarness) | Agent Harness 框架 | Python | 显式依赖注入容器 + 三级权限 |
|
||||
|
||||
AG Core 不"抄代码",只参考架构模式。当前实现已经采纳:
|
||||
AG Core 借鉴了以下架构模式(不直接复用代码):
|
||||
|
||||
- **OpenHarness 风格** —— 显式 `RuntimeBundle` 依赖注入容器
|
||||
- **Hermes 风格** —— `Agent` trait(角色)与 `AgentSession`(会话)解耦
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
# Phase 5:热身准备 — 实施方案
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
Phase 5 是 v0.2.0 发布周期的**热身准备阶段**,包含三个互不依赖的 Step,为后续 Phase 6-12 的端到端集成提供基础设施。
|
||||
|
||||
**核心目标**:
|
||||
- 为 Phase 8(端到端示例)提供零 API key 的运行路径(Ollama)
|
||||
- 为公共枚举的向后兼容性加上编译期护栏(`#[non_exhaustive]`)
|
||||
- 为 Provider 构造提供统一的超时与重试配置入口(`ProviderConfig` 扩展)
|
||||
|
||||
三个 Step 之间**无依赖关系**,但出于实现效率考虑,按 **5.2 → 5.3 → 5.1** 顺序执行。理由:5.2 先新增 `Ollama` 枚举变体,5.3 再加 `#[non_exhaustive]`,避免枚举标记后添加变体需要在外部 crate 加 `_ =>` 兜底分支的困扰。
|
||||
|
||||
## 2. 需求分析
|
||||
|
||||
### Step 5.2 — Ollama Provider
|
||||
|
||||
| 维度 | 内容 |
|
||||
|------|------|
|
||||
| **需求** | 新增 `OllamaProvider`,newtype 包装 `GenericOpenaiProvider`,默认连接本地 Ollama 实例 |
|
||||
| **优先级** | P0 — 为 Phase 8 端到端示例提供无需 API key 的运行路径 |
|
||||
| **预期交付物** | `src/llm/provider/ollama.rs` 新建文件;`ProviderType` 新增 `Ollama` 变体 |
|
||||
| **代码量** | ~55 行 |
|
||||
|
||||
### Step 5.3 — `#[non_exhaustive]` 前置标记
|
||||
|
||||
| 维度 | 内容 |
|
||||
|------|------|
|
||||
| **需求** | 为 4 个公共枚举添加 `#[non_exhaustive]` 属性,避免后续新增变体时破坏下游 match |
|
||||
| **优先级** | P1 — 编译期兼容性保障 |
|
||||
| **预期交付物** | 修改 4 个枚举定义,各加一行属性 |
|
||||
| **代码量** | ~4 行 |
|
||||
|
||||
### Step 5.1 — ProviderConfig 扩展
|
||||
|
||||
| 维度 | 内容 |
|
||||
|------|------|
|
||||
| **需求** | `ProviderConfig` 新增 `timeout_secs` 和 `max_retries` 字段;实现 `Default`、`from_env()` 构造;timeout 传导到各 Provider HTTP Client |
|
||||
| **优先级** | P0 — 与 Roadmap 一致,Phase 8(MVP 出口)依赖 from_env |
|
||||
| **预期交付物** | `ProviderConfig` 扩展;`create_provider()` 超时注入;`from_env()` + 单元测试 |
|
||||
| **代码量** | ~60 行 + 测试 |
|
||||
|
||||
## 3. 方案设计
|
||||
|
||||
### 3.1 Step 5.2 — Ollama Provider(先执行)
|
||||
|
||||
#### 改动文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `src/llm/provider/ollama.rs` | **新建** | OllamaProvider newtype 包装 |
|
||||
| `src/llm/provider.rs` | 修改 | `ProviderType` 新增 `Ollama` 变体;`FromStr` 加解析;`create_provider()` 加分支 |
|
||||
| `src/llm/provider/mod.rs` 或其他模块注册文件 | 修改(如需要) | 注册 `pub mod ollama` |
|
||||
|
||||
#### 关键代码
|
||||
|
||||
**`src/llm/provider/ollama.rs`**(新建):
|
||||
|
||||
```rust
|
||||
//! Ollama Provider —— OpenAI-compatible 协议的 newtype 包装,零 API key。
|
||||
//!
|
||||
//! 默认 base_url = `http://localhost:11434/v1`,空 api_key 也可工作。
|
||||
//! 实现方式同 DeepSeekProvider / QwenProvider,共享 GenericOpenaiProvider 的 HTTP/SSE/转换逻辑。
|
||||
|
||||
use reqwest::Client;
|
||||
use std::pin::Pin;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures_core::Stream;
|
||||
|
||||
use super::openai::GenericOpenaiProvider;
|
||||
use super::{LlmProvider, ProviderCapabilities};
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
|
||||
|
||||
pub struct OllamaProvider(pub GenericOpenaiProvider);
|
||||
|
||||
impl OllamaProvider {
|
||||
pub fn new(base_url: String, api_key: String, model: String) -> Self {
|
||||
let url = if base_url.is_empty() {
|
||||
"http://localhost:11434/v1".to_string()
|
||||
} else {
|
||||
base_url
|
||||
};
|
||||
Self(GenericOpenaiProvider::new_with_name(
|
||||
url,
|
||||
api_key,
|
||||
model,
|
||||
"ollama",
|
||||
))
|
||||
}
|
||||
|
||||
/// 替换默认 HTTP Client(用于 timeout 注入等场景)。
|
||||
/// 与 `OpenaiChatProvider::with_client` 和 `DeepSeekProvider::with_client` 一致。
|
||||
pub fn with_client(self, client: Client) -> Self {
|
||||
Self(self.0.with_client(client))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for OllamaProvider {
|
||||
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
|
||||
self.0.chat(request).await
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
|
||||
self.0.chat_stream(request).await
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
let mut caps = self.0.capabilities();
|
||||
caps.provider_name = "ollama";
|
||||
caps
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`src/llm/provider.rs`** 的修改:
|
||||
|
||||
```rust
|
||||
// ProviderType 新增变体
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderType {
|
||||
OpenaiChat,
|
||||
OpenaiResponse,
|
||||
Anthropic,
|
||||
DeepSeek,
|
||||
Qwen,
|
||||
/// Ollama(本地),默认 base_url = `http://localhost:11434/v1`。
|
||||
Ollama,
|
||||
}
|
||||
|
||||
// FromStr 加解析
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
// ... 已有条目 ...
|
||||
"ollama" => Ok(ProviderType::Ollama),
|
||||
_ => Err(format!("未知的 Provider 类型: {s}")),
|
||||
}
|
||||
}
|
||||
|
||||
// create_provider() 加分支
|
||||
// Step 5.2 阶段仅展示基本构造。Step 5.1(ProviderConfig 扩展)
|
||||
// 执行到此分支时,将同步补充 with_client 链式调用注入 timeout:
|
||||
//
|
||||
// let client = Client::builder()
|
||||
// .timeout(Duration::from_secs(config.timeout_secs))
|
||||
// .build()?;
|
||||
// Ok(Box::new(
|
||||
// ollama::OllamaProvider::new(config.base_url, config.api_key, config.model)
|
||||
// .with_client(client),
|
||||
// ))
|
||||
ProviderType::Ollama => Ok(Box::new(ollama::OllamaProvider::new(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
))),
|
||||
```
|
||||
|
||||
#### 集成方式
|
||||
|
||||
OllamaProvider 的 newtype 包装模式与 `DeepSeekProvider`、`QwenProvider` 完全一致,`LlmProvider` trait 委托给 `self.0`。`capabilities().provider_name` 返回 `"ollama"`。
|
||||
|
||||
### 3.2 Step 5.3 — `#[non_exhaustive]` 前置标记
|
||||
|
||||
#### 改动文件清单
|
||||
|
||||
| 文件 | 行号 | 枚举 | 操作 |
|
||||
|------|------|------|------|
|
||||
| `src/llm/provider.rs` | ~21 | `ProviderType` | 加 `#[non_exhaustive]` |
|
||||
| `src/llm/types/response_v2.rs` | ~22 | `StopReason` | 加 `#[non_exhaustive]` |
|
||||
| `src/llm/types/shared.rs` | ~16 | `FinishReason` | 加 `#[non_exhaustive]` |
|
||||
| `src/memory/store.rs` | ~35 | `EvictionPolicy` | 加 `#[non_exhaustive]` |
|
||||
|
||||
**排除清单**:`SlotMode`。
|
||||
|
||||
**决策理由**:`SlotMode` 枚举在 Phase 10(`src/llm/context.rs`)中才实际定义,Phase 5 尚不存在此类型。`#[non_exhaustive]` 无法标注不存在的枚举,因此排除标注。Roadmap(v0.2.0 §Phase 5 Step 5.3)列出的 `SlotMode`(预置) 推迟到 Phase 10 实现时一并添加。
|
||||
|
||||
#### 关键代码
|
||||
|
||||
每个枚举在 `derive` 上方或下方加一行属性:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum ProviderType {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 影响分析
|
||||
|
||||
- `#[non_exhaustive]` 是纯编译期属性,不影响运行时行为
|
||||
- 同一 crate 内的 exhaustive match 不受影响(同 crate 可穷举)
|
||||
- 下游 crate 的 match 必须加 `_ =>` 兜底分支,这是期望行为——确保未来新增变体时不会 silent break
|
||||
- **单向门**:此步骤一旦通过 `v0.2.0` 发布到公共 API 后,**不可回退**。回退意味着移除 `#[non_exhaustive]`,可能破坏已添加 `_ =>` 的下游代码。因此必须在发布前完成并确认所有枚举变体正确
|
||||
|
||||
### 3.3 Step 5.1 — ProviderConfig 扩展(最后执行)
|
||||
|
||||
#### 改动文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `src/llm/provider.rs` | 修改 | `ProviderConfig` 加字段;加 `impl Default`;加 `from_env()`;`create_provider` 注入 timeout |
|
||||
| `src/llm/provider/openai.rs` | 修改 | `GenericOpenaiProvider` 新增 `timeout_secs` 字段;`new_with_name` 接受 timeout 参数;`map_reqwest_error` 参数化 |
|
||||
| `src/llm/provider/anthropic.rs` | 修改 | 新增 `timeout_secs` 字段;`new()` 接受 timeout 参数;`map_reqwest_error` 参数化 |
|
||||
| `src/llm/provider/anthropic.rs` | 修改 | 新增 `with_timeout()` 方法(返回 `Result<Self, LlmError>`) |
|
||||
| `src/llm/provider/openai_compat.rs` | 修改 | `DeepSeekProvider` 和 `QwenProvider` 新增公开 `with_client()` 方法 |
|
||||
| `src/llm/provider/ollama.rs` | 修改 | `OllamaProvider` 新增公开 `with_client()` 方法 |
|
||||
| `Cargo.toml` | 修改 | 加 `temp_env` dev-dependency |
|
||||
| 测试文件(`provider.rs` 内联或独立) | 新增 | `from_env` 单元测试 + timeout 传导集成测试 |
|
||||
|
||||
#### 数据结构
|
||||
|
||||
```rust
|
||||
/// Provider 构造参数 —— 通用 base_url + api_key + model + timeout/retry 配置。
|
||||
pub struct ProviderConfig {
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub model: String,
|
||||
/// 请求超时秒数(默认 30)。应用于 Provider 的 HTTP Client 级别。
|
||||
pub timeout_secs: u64,
|
||||
/// 最大重试次数(默认 3)。当前此字段仅由 `from_env()` 采集,
|
||||
/// 实际重试逻辑由 `CycleConfig.retry.max_retries` 控制。
|
||||
/// 未来可合并到统一的 retry 配置。
|
||||
pub max_retries: u32,
|
||||
}
|
||||
|
||||
impl Default for ProviderConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_url: String::new(),
|
||||
api_key: String::new(),
|
||||
model: String::new(),
|
||||
timeout_secs: 30,
|
||||
max_retries: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderConfig {
|
||||
/// 从环境变量构造 ProviderConfig。
|
||||
///
|
||||
/// 必填变量:
|
||||
/// - `{prefix}_BASE_URL`
|
||||
/// - `{prefix}_API_KEY`
|
||||
/// - `{prefix}_MODEL`
|
||||
///
|
||||
/// 可选变量(有默认值):
|
||||
/// - `{prefix}_TIMEOUT_SECS`(默认 30)
|
||||
/// - `{prefix}_MAX_RETRIES`(默认 3)
|
||||
pub fn from_env(prefix: &str) -> Result<Self, String> {
|
||||
let base_url = std::env::var(format!("{prefix}_BASE_URL"))
|
||||
.map_err(|_| format!("{prefix}_BASE_URL 环境变量未设置"))?;
|
||||
let api_key = std::env::var(format!("{prefix}_API_KEY"))
|
||||
.map_err(|_| format!("{prefix}_API_KEY 环境变量未设置"))?;
|
||||
let model = std::env::var(format!("{prefix}_MODEL"))
|
||||
.map_err(|_| format!("{prefix}_MODEL 环境变量未设置"))?;
|
||||
let timeout_secs = match std::env::var(format!("{prefix}_TIMEOUT_SECS")) {
|
||||
Ok(v) => v.parse().unwrap_or_else(|_| {
|
||||
tracing::warn!("{prefix}_TIMEOUT_SECS='{v}' 解析失败,使用默认值 30");
|
||||
30
|
||||
}),
|
||||
Err(_) => 30,
|
||||
};
|
||||
let max_retries = match std::env::var(format!("{prefix}_MAX_RETRIES")) {
|
||||
Ok(v) => v.parse().unwrap_or_else(|_| {
|
||||
tracing::warn!("{prefix}_MAX_RETRIES='{v}' 解析失败,使用默认值 3");
|
||||
3
|
||||
}),
|
||||
Err(_) => 3,
|
||||
};
|
||||
|
||||
// ponytail: max_retries 当前仅采集,不传入 Provider。
|
||||
// 实际重试由 CycleConfig.retry.max_retries 控制。
|
||||
// 此 warn 在应用启动时通常只触发一次,多次调用 from_env 时
|
||||
// 重复输出的风险低。如有噪声,可改用 std::sync::Once 控制。
|
||||
if max_retries != 3 {
|
||||
tracing::warn!(
|
||||
"ProviderConfig.max_retries={} 已采集但当前未生效;\
|
||||
重试次数由 CycleConfig.retry.max_retries 控制",
|
||||
max_retries,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
base_url,
|
||||
api_key,
|
||||
model,
|
||||
timeout_secs,
|
||||
max_retries,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Timeout 传导模式
|
||||
|
||||
在 `create_provider()` 中,对基于 `GenericOpenaiProvider` 的 Provider(OpenAI / DeepSeek / Qwen / Ollama),通过同一模式注入 timeout:构造带 timeout 的 `Client` 后调用 `with_client(client)`。
|
||||
|
||||
所有 OpenAI-compatible 分支新增的 `with_client()` 公开方法:
|
||||
|
||||
| Provider | 方法 | 位置 |
|
||||
|----------|------|------|
|
||||
| `OpenaiChatProvider` | 已有 `with_client(Client) -> Self` | `openai.rs` |
|
||||
| `DeepSeekProvider` | 新增 `with_client(Client) -> Self` | `openai_compat.rs` |
|
||||
| `QwenProvider` | 新增 `with_client(Client) -> Self` | `openai_compat.rs` |
|
||||
| `OllamaProvider` | 新增 `with_client(Client) -> Self` | `ollama.rs`(新建文件) |
|
||||
|
||||
**关于 `new_with_client` 的说明**:`DeepSeekProvider` 和 `QwenProvider` 当前已有测试用的 `new_with_client(base_url, api_key, model, client)` 方法(通过 `inner.http_client = client` 直接写字段)。新增 `with_client` 后,`new_with_client` 应重构为 `Self::new(base_url, api_key, model).with_client(client)` 代理,统一走公开 API 路径。
|
||||
|
||||
代码示例(以 DeepSeek 为例,OpenAI/Qwen/Ollama 模式完全一致):
|
||||
|
||||
```rust
|
||||
ProviderType::DeepSeek => {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(config.timeout_secs))
|
||||
.build()
|
||||
.map_err(|e| LlmError::Other(format!("创建 HTTP 客户端失败: {e}")))?;
|
||||
Ok(Box::new(
|
||||
openai_compat::DeepSeekProvider::new(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
)
|
||||
.with_client(client),
|
||||
))
|
||||
}
|
||||
```
|
||||
|
||||
Anthropic 由于需要保留 `default_headers`,使用独立的 `with_timeout` 模式:
|
||||
|
||||
AnthropicProvider 新增 `with_timeout` 方法:
|
||||
|
||||
```rust
|
||||
impl AnthropicProvider {
|
||||
/// 替换默认 HTTP Client 的超时配置。
|
||||
///
|
||||
/// ⚠️ 副作用:此方法**完全重建** `http_client`,调用后原有通过 `with_client`
|
||||
/// 注入的 Client 将被替换。headers 逻辑与 `new()` 中的构造保持一致。
|
||||
pub fn with_timeout(mut self, secs: u64) -> Result<Self, LlmError> {
|
||||
// ponytail: 重建 http_client 时保留已有默认 headers(x-api-key / anthropic-version)。
|
||||
// 如后续 AnthropicProvider 的 headers 变为动态,此方法需同步更新。
|
||||
let key_header = HeaderValue::from_str(&self.api_key)
|
||||
.map_err(|_| LlmError::Other("Anthropic API key 包含无效的 HTTP 头部字符".into()))?;
|
||||
let version_header = HeaderValue::from_static("2023-06-01");
|
||||
|
||||
self.http_client = Client::builder()
|
||||
.timeout(Duration::from_secs(secs))
|
||||
.default_headers({
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-api-key", key_header);
|
||||
headers.insert("anthropic-version", version_header);
|
||||
headers
|
||||
})
|
||||
.build()
|
||||
.map_err(|e| LlmError::Other(format!("创建 Anthropic HTTP 客户端失败: {e}")))?;
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `map_reqwest_error` 中的硬编码超时修复
|
||||
|
||||
`openai.rs` 和 `anthropic.rs` 中的 `map_reqwest_error` 辅助函数当前在超时错误中返回硬编码的 `Duration::from_secs(120)`:
|
||||
|
||||
```rust
|
||||
// 现状 —— 硬编码 120s,与可配置 timeout 脱节
|
||||
LlmError::Timeout { duration: Duration::from_secs(120) }
|
||||
```
|
||||
|
||||
**修复方式**:采用**方案 A**——在 Provider struct 中存储 `timeout_secs` 字段,`map_reqwest_error` 读取该字段的值而非硬编码 120s。
|
||||
|
||||
```rust
|
||||
// 修复后 —— 参数化,从 Provider 存储的 timeout_secs 读取
|
||||
// GenericOpenaiProvider 新增 timeout_secs 字段:
|
||||
pub struct GenericOpenaiProvider {
|
||||
http_client: Client,
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
provider_name: &'static str,
|
||||
extra_headers: Vec<(String, String)>,
|
||||
timeout_secs: u64, // ← 新增,由 new_with_name 的参数传入
|
||||
}
|
||||
|
||||
// map_reqwest_error 使用 self.timeout_secs 而非硬编码 120:
|
||||
LlmError::Timeout { duration: Duration::from_secs(self.timeout_secs) }
|
||||
```
|
||||
|
||||
**方案 B(从 reqwest::Client 提取 timeout)已被否决**:`reqwest::Client` 不提供 timeout getter,无法从已构造的 client 中反向读取超时配置。
|
||||
|
||||
如果漏掉此修复,用户设置 `AG_LLM_TIMEOUT_SECS=60` 后超时,错误消息仍显示 "LLM 请求超时(120s)",与实际配置不符。
|
||||
|
||||
---
|
||||
|
||||
#### max_retries 说明
|
||||
|
||||
`ProviderConfig.max_retries` 当前仅由 `from_env()` 采集存储,**实际重试操作由 `CycleConfig.retry.max_retries` 控制**。两者之间的关系通过文档注释声明:
|
||||
|
||||
```rust
|
||||
/// 最大重试次数(默认 3)。当前此字段仅由 `from_env()` 采集,
|
||||
/// 实际重试逻辑由 `CycleConfig.retry.max_retries` 控制。
|
||||
/// 未来 Phase 6+ 可统一合并此字段到 CycleConfig。
|
||||
```
|
||||
|
||||
#### 测试设计
|
||||
|
||||
使用 `temp_env` 在单元测试中隔离环境变量:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn provider_config_from_env_requires_all_vars() {
|
||||
// 未设置任何变量时应返回 Err
|
||||
let result = ProviderConfig::from_env("TEST_PROVIDER");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_from_env_uses_defaults() {
|
||||
temp_env::with_vars([
|
||||
("TEST_PROVIDER_BASE_URL", Some("http://localhost:11434/v1")),
|
||||
("TEST_PROVIDER_API_KEY", Some("")),
|
||||
("TEST_PROVIDER_MODEL", Some("llama3")),
|
||||
], || {
|
||||
let config = ProviderConfig::from_env("TEST_PROVIDER").unwrap();
|
||||
assert_eq!(config.timeout_secs, 30);
|
||||
assert_eq!(config.max_retries, 3);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_from_env_reads_custom_timeout() {
|
||||
temp_env::with_vars([
|
||||
("TEST_PROVIDER_BASE_URL", Some("http://x")),
|
||||
("TEST_PROVIDER_API_KEY", Some("k")),
|
||||
("TEST_PROVIDER_MODEL", Some("m")),
|
||||
("TEST_PROVIDER_TIMEOUT_SECS", Some("60")),
|
||||
("TEST_PROVIDER_MAX_RETRIES", Some("5")),
|
||||
], || {
|
||||
let config = ProviderConfig::from_env("TEST_PROVIDER").unwrap();
|
||||
assert_eq!(config.timeout_secs, 60);
|
||||
assert_eq!(config.max_retries, 5);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 实现计划
|
||||
|
||||
### Step 5.2 — Ollama Provider(~55 行)
|
||||
|
||||
| 步骤 | 操作 | 验证 |
|
||||
|------|------|------|
|
||||
| 1 | 创建 `src/llm/provider/ollama.rs`,实现 `OllamaProvider` newtype | 编译通过 |
|
||||
| 2 | 在 `provider.rs` 注册 `pub mod ollama` | 编译通过 |
|
||||
| 3 | `ProviderType` 新增 `Ollama` 变体 | 编译通过 |
|
||||
| 4 | `FromStr` 加 `"ollama"` 解析 | 编译通过 |
|
||||
| 5 | `create_provider()` 加 `Ollama =>` 分支 | 编译通过 |
|
||||
| 6 | 运行 `cargo build` | 无错误 |
|
||||
|
||||
### Step 5.3 — `#[non_exhaustive]` 前置标记(~4 行)
|
||||
|
||||
| 步骤 | 操作 | 验证 |
|
||||
|------|------|------|
|
||||
| 1 | `ProviderType`(`provider.rs`)加 `#[non_exhaustive]` | 编译通过 |
|
||||
| 2 | `StopReason`(`response_v2.rs`)加 `#[non_exhaustive]` | 编译通过 |
|
||||
| 3 | `FinishReason`(`shared.rs`)加 `#[non_exhaustive]` | 编译通过 |
|
||||
| 4 | `EvictionPolicy`(`memory/store.rs`)加 `#[non_exhaustive]` | 编译通过 |
|
||||
| 5 | 运行 `cargo build --all-targets` | 无 warning |
|
||||
|
||||
### Step 5.1 — ProviderConfig 扩展(~60 行 + 测试)
|
||||
|
||||
| 步骤 | 操作 | 验证 |
|
||||
|------|------|------|
|
||||
| 1 | `ProviderConfig` 加 `timeout_secs` / `max_retries` 字段 | 编译通过 |
|
||||
| 2 | 实现 `impl Default for ProviderConfig` | 编译通过 |
|
||||
| 3 | 实现 `ProviderConfig::from_env()` | 编译通过 |
|
||||
| 4 | `GenericOpenaiProvider` 和 `AnthropicProvider` 新增 `timeout_secs` 字段,`new_with_name`/`new()` 接受 timeout 参数 | 编译通过 |
|
||||
| 5 | `map_reqwest_error` 在各 Provider 中改为从 `self.timeout_secs` 读取,移除硬编码 120s | 编译通过 |
|
||||
| 6 | `create_provider()` 中各分支注入 timeout(OpenAI-compatible 用 `Client::builder().timeout()` + `with_client`;Anthropic 用 `with_timeout()`) | 编译通过 |
|
||||
| 7 | `DeepSeekProvider`/`QwenProvider` 的 `new_with_client` 重构为 `Self::new(...).with_client(client)` 代理 | 测试通过 |
|
||||
| 8 | `Cargo.toml` 添加 `temp_env` dev-dependency | `cargo build` 通过 |
|
||||
| 8 | 添加 `from_env` 单元测试 + timeout 传导集成测试 | `cargo test` 通过 |
|
||||
| 9 | 完整验证 | 见第 6 节 |
|
||||
|
||||
## 5. 风险评估
|
||||
|
||||
| 风险 | 影响 | 概率 | 缓解措施 |
|
||||
|------|------|------|----------|
|
||||
| `create_provider()` 中 `Client::builder().build()` 返回 `Result`,当前代码使用 `.expect()`,改为 `map_err` 转为 `LlmError` 后需确保所有分支正确转换 | 编译期强制处理,遗漏分支直接报错 | 低 | `create_provider` 返回 `Result<Box<dyn LlmProvider>, LlmError>`,`map_err` 天然适配。新增的 timeout 注入路径逐一检查 |
|
||||
| `AnthropicProvider` 的 `default_headers` 在 `with_timeout` 中重建时与 `new()` 中的 headers 不一致 | Anthropic 认证失败 | 低 | `with_timeout` 方法复制 `new()` 中的 headers 构造逻辑。通过已有测试验证认证通过 |
|
||||
| Ollama 实际运行时行为差异:版本兼容性、API 路径、模型名等 | 运行时才能发现 | 中 | Phase 5 仅做类型级验证(`cargo build`),Phase 8 端到端测试时通过 Ollama mock 或真实实例验证 |
|
||||
| `max_retries` 存储了却未实际使用,造成困惑 | 开发者误以为已生效 | 中 | 通过文档注释明确声明 `max_retries` 当前仅采集,实际重试由 `CycleConfig.retry.max_retries` 控制 |
|
||||
| `temp_env` 测试在多线程并发测试中互相污染环境变量 | 偶发测试失败 | 中(Rust 默认单线程测试用 `--test-threads=1` 可避免) | 将 `from_env` 测试控制在同一测试文件,避免并行执行。必要时在 CI 中确保 `--test-threads=1` |
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
以下条件**全部满足**方可认为 Phase 5 完成:
|
||||
|
||||
- [ ] `cargo build --all-targets` 通过,无错误
|
||||
- [ ] `cargo test --all-targets` 通过,新增测试覆盖 `from_env` 的必填/选填/默认值场景
|
||||
- [ ] `cargo clippy --all-targets -- -D warnings` 通过,无任何 warning
|
||||
- [ ] `cargo doc --no-deps -D warnings` 通过,所有公共 API 有文档注释(`///`)
|
||||
- [ ] 新增文件:1(`ollama.rs`)
|
||||
- [ ] 修改文件:9(`provider.rs`、`openai.rs`、`anthropic.rs`、`openai_compat.rs`、`response_v2.rs`、`shared.rs`、`store.rs`、`Cargo.toml`、测试文件)
|
||||
- [ ] 净代码增量:~160 行
|
||||
- [ ] `ProviderType` 新增 `Ollama` 变体,`"ollama"` 字符串可解析
|
||||
- [ ] 4 个公共枚举带有 `#[non_exhaustive]` 属性
|
||||
- [ ] `ProviderConfig` 可从环境变量构造(`from_env()`),含默认值
|
||||
- [ ] timeout 值已传导到 `create_provider()` 中各 Provider 的 HTTP Client 配置
|
||||
- [ ] timeout 传导验证通过至少一个端到端 wiremock 集成测试(模拟 HTTP 服务在超时后返回 408,验证 Provider 返回 `LlmError::Timeout`)
|
||||
- [ ] `DeepSeekProvider`、`QwenProvider`、`OllamaProvider` 均有公开 `with_client()` 方法,可在 `create_provider` 中注入 timeout Client
|
||||
- [ ] `map_reqwest_error` 中不再硬编码 `Duration::from_secs(120)`,改为参数化读取
|
||||
@@ -0,0 +1,236 @@
|
||||
# Phase 6 — ToolDefinition IR 正式化实施方案
|
||||
|
||||
## 背景与目标
|
||||
|
||||
在 agcore v0.2 路线图中,Phase 6 旨在引入 `ToolDef` 新类型,替换已标记 `#[deprecated(since = "0.1.0")]` 的 `ToolDefinition`(即 `OpenaiToolDefinition` 类型别名),消除 OpenAI wire format 对核心类型系统的泄漏,建立 Provider 无关的工具定义中间表示(IR)。
|
||||
|
||||
预期成果:
|
||||
- 核心类型系统不再直接依赖 `OpenaiToolDefinition`
|
||||
- 所有 Provider 适配层从统一的 `ToolDef` IR 出发,各自转换为对应 wire format
|
||||
- 消除 `#[allow(deprecated)]` 抑制点,恢复 clippy 零警告状态
|
||||
|
||||
## 当前状态分析
|
||||
|
||||
当前代码库中工具定义相关的关键状态如下:
|
||||
|
||||
1. **`OpenaiToolDefinition` 结构体**定义于 `llm/types/tool.rs`,包含 4 个字段:
|
||||
- `name: String`
|
||||
- `description: Option<String>`
|
||||
- `parameters: Value`
|
||||
- `strict: Option<bool>`
|
||||
|
||||
当前存在多处 `#[allow(deprecated)]` 抑制点,分布在 `llm/cycle.rs`、`tools/registry.rs`、`tools/mcp.rs`、`agent/agent.rs` 等文件中。
|
||||
|
||||
2. **`ToolDefinition` 类型别名**定义于 `llm/types/mod.rs:105`,标记为 `#[deprecated]`:
|
||||
```rust
|
||||
#[deprecated(since = "0.1.0", note = "use OpenaiToolDefinition directly")]
|
||||
pub type ToolDefinition = OpenaiToolDefinition;
|
||||
```
|
||||
|
||||
3. **`MessageRequest.tools` 字段**类型为 `Vec<OpenaiToolDefinition>`(直接引用原始类型,而非别名)。
|
||||
|
||||
4. **引用该类型的 4 个源文件**:
|
||||
- `llm/cycle.rs`:4 个方法参数使用 `Vec<ToolDefinition>`
|
||||
- `tools/registry.rs`:`definitions() -> Vec<ToolDefinition>` 返回类型 + struct literal 构造(含 `strict: None`)
|
||||
- `tools/mcp.rs`:`list_tools() -> Vec<ToolDefinition>` 返回类型 + struct literal 构造(含 `strict: None`)
|
||||
- `agent/agent.rs`:`fn tool_definitions() -> Vec<ToolDefinition>` trait 默认实现
|
||||
|
||||
5. **Provider 适配层**:`openai.rs` 和 `anthropic.rs` 从 `MessageRequest.tools` 读取数据并转换为各自的 wire format。`openai_compat.rs` 和 `ollama.rs` 委托给 `GenericOpenaiProvider`,无需直接改动。
|
||||
|
||||
## 需求推演
|
||||
|
||||
### 决策 1:`strict` 字段的处理
|
||||
|
||||
当前所有构造路径均硬编码 `strict: None`(`registry.rs`、`mcp.rs`),`BaseTool` trait 无 `strict` 方法,用户 API 无法设置该值。
|
||||
|
||||
**结论:移除。** `strict` 是 OpenAI 的 Structured Outputs 专属字段,不属于 Provider 无关的 IR。未来如需支持,走 `MessageRequest.extra` 逃生舱,在各 Provider 适配层自行消费。
|
||||
|
||||
### 决策 2:`description` 保持 `Option<String>`
|
||||
|
||||
Anthropic 要求 `description` 为必填(`String`),但 MCP 等来源可能缺失该字段。保持 `Option`,由 Anthropic 适配层以 `unwrap_or_default()` 兜底。
|
||||
|
||||
### 决策 3:`parameters` 保持 `Value`
|
||||
|
||||
所有 Provider 的 wire format 均接受 JSON Schema 格式的 Value。当前不做 typed 方案,保留 `Value`。
|
||||
|
||||
### 决策 4:采用直接切断而非阶段性 deprecation
|
||||
|
||||
v0.1 已标记 `#[deprecated]`,用户已有预期。pre-1.0 阶段的 breaking change 是合理的。`OpenaiToolDefinition` 保留但降级为 `#[doc(hidden)]`。
|
||||
|
||||
## 方案设计
|
||||
|
||||
### ToolDef 结构体
|
||||
|
||||
位置:`src/llm/types/tool.rs`(与 `OpenaiToolDefinition` 同文件,不建独立文件/模块)。
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ToolDef {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub parameters: Value,
|
||||
}
|
||||
```
|
||||
|
||||
实现双向 `From` 转换:
|
||||
|
||||
```rust
|
||||
impl From<ToolDef> for OpenaiToolDefinition {
|
||||
fn from(t: ToolDef) -> Self {
|
||||
Self {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
strict: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OpenaiToolDefinition> for ToolDef {
|
||||
fn from(t: OpenaiToolDefinition) -> Self {
|
||||
Self {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
serde 属性与 `OpenaiToolDefinition` 原有属性一致,保证 JSON 序列化兼容。
|
||||
|
||||
明确不做:
|
||||
- builder 模式(Rust struct literal + `..Default::default()` 已足够)
|
||||
- `#[non_exhaustive]`(IR 类型自有完整控制权,不需要)
|
||||
- 独立文件(7 行 struct 无需独立模块)
|
||||
|
||||
### 4 单元切割计划
|
||||
|
||||
每步设计为可编译的安全 checkpoint。
|
||||
|
||||
#### 单元 6.1 — 新增 ToolDef + From 实现
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 涉及文件 | `llm/types/tool.rs` |
|
||||
| 变更内容 | 新增 `ToolDef` struct(约 7 行)、2 个 `From` impl(约 12 行) |
|
||||
| 验证标准 | `cargo build` 编译通过(旧代码照常编译,零影响) |
|
||||
| 检查点 | 新类型存在但未被消费,安全 checkpoint |
|
||||
|
||||
#### 单元 6.2 — 别名切换 + 构造同步修复
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 涉及文件 | `llm/types/mod.rs`、`llm/types/request_v2.rs`、`llm/types/request.rs`、`tools/registry.rs`、`tools/mcp.rs` |
|
||||
| 变更内容 | 切换别名 `pub type ToolDefinition = ToolDef`;`MessageRequest.tools` 改为 `Vec<ToolDef>`;registry/mcp 构造去掉 `strict: None` |
|
||||
| 验证标准 | `cargo build` 编译通过 |
|
||||
| 风险提示 | `cycle.rs` 方法参数使用别名,自动生效无需修改;`agent/agent.rs` trait 默认实现使用别名,自动适配;暂不移除 `#[allow(deprecated)]` |
|
||||
|
||||
#### 单元 6.3 — Provider 适配
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 涉及文件 | `llm/provider/openai.rs` |
|
||||
| 变更内容 | `convert_request()` 中 `tool_defs.into_iter().map(|t| OpenaiTool::Function { function: t })` → 改为 `.map(|t| OpenaiTool::Function { function: t.into() })`;`t` 类型从 `OpenaiToolDefinition` 变为 `ToolDef`,需 `Into` 转换 |
|
||||
| 验证标准 | `cargo test --all-targets` 全部通过 |
|
||||
| 不修改的文件 | `anthropic.rs`(同名字段访问自动适配)、`openai_compat.rs`/`ollama.rs`(委托给 `GenericOpenaiProvider`) |
|
||||
|
||||
#### 单元 6.4 — 清理
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 涉及文件 | `llm/cycle.rs`、`tools/registry.rs`、`tools/mcp.rs`、`agent/agent.rs`、`llm/types/mod.rs`、`llm/types/tool.rs` |
|
||||
| 变更内容 | 移除所有与 `ToolDefinition` 相关的 `#[allow(deprecated)]`;`llm/types/mod.rs` 移除旧 `#[deprecated]` 别名(仅保留 `pub use tool::ToolDef`);`OpenaiToolDefinition` 降级为 `#[doc(hidden)]`。精确列表由 `cargo clippy -D warnings` 检出——clippy 会标记所有不再需要的 `#[allow]` |
|
||||
| 验证标准 | `cargo clippy --all-targets -- -D warnings` 零警告;`cargo test --all-targets` 全部通过 |
|
||||
| 无需改动 | 测试代码(无一直接引用 `ToolDefinition`) |
|
||||
|
||||
### Provider 适配策略
|
||||
|
||||
Provider 适配层改动最小化,仅在序列化入口处加一层 `From` 转换:
|
||||
|
||||
| Provider | 适配方式 | 改动 |
|
||||
|----------|---------|------|
|
||||
| OpenAI(`GenericOpenaiProvider`) | `MessageRequest.tools: Vec<ToolDef>` → lambda 内改为 `.map(\|t\| OpenaiTool::Function { function: t.into() })`,将 `ToolDef` 通过 `Into` 转为 `OpenaiToolDefinition` | `convert_request` lambda 内 +`.into()` |
|
||||
| Anthropic | `t.name` / `t.description` / `t.parameters` 字段名不变,直接访问 | 零改动 |
|
||||
| OpenAI Compat(DeepSeek、Qwen) | 委托给 `GenericOpenaiProvider` | 零改动 |
|
||||
| Ollama | 委托给 `GenericOpenaiProvider` | 零改动 |
|
||||
|
||||
### 变更清单汇总
|
||||
|
||||
| 文件 | 改动类型 | 估计行数 |
|
||||
|------|---------|---------|
|
||||
| `llm/types/tool.rs` | +`ToolDef` + 2x `From` | +19 |
|
||||
| `llm/types/mod.rs` | 改别名 + re-export | ~3 |
|
||||
| `llm/types/request_v2.rs` | 改 `tools` 字段 + import | ~2 |
|
||||
| `llm/cycle.rs` | 移 `#[allow(deprecated)]` | -1 |
|
||||
| `tools/registry.rs` | 构造去掉 `strict` + 移 `allow` | ~4 |
|
||||
| `tools/mcp.rs` | 构造去掉 `strict` + 移 `allow` | ~4 |
|
||||
| `agent/agent.rs` | 移 `#[allow(deprecated)]` | -1 |
|
||||
| `llm/provider/openai.rs` | `convert_request` 加 `.map(Into::into)` | +2 |
|
||||
| 新增 roundtrip 测试 | `MessageRequest` 序列化 roundtrip 验证 | +15 |
|
||||
| **合计** | | **约 47 行(+ 约 15 行测试)** |
|
||||
|
||||
### 测试策略
|
||||
|
||||
新增一条 `MessageRequest` 序列化 roundtrip 测试,覆盖 `ToolDef` 的 JSON 序列化/反序列化兼容性。该测试验证 `ToolDef` 的 serde 属性与 `OpenaiToolDefinition` 一致,确保 wire format 兼容。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. 新建分支 `phase-6-tooldef-ir`
|
||||
2. 按单元 6.1 → 6.2 → 6.3 → 6.4 顺序执行,每步提交一个 commit
|
||||
3. 每步执行对应的验证标准
|
||||
4. 全量通过后创建 PR
|
||||
|
||||
```
|
||||
git checkout -b phase-6-tooldef-ir
|
||||
# 执行单元 6.1 → commit
|
||||
# 执行单元 6.2 → commit
|
||||
# 执行单元 6.3 → commit
|
||||
# 执行单元 6.4 → commit
|
||||
# 全量验证
|
||||
```
|
||||
|
||||
### 用户迁移指引
|
||||
|
||||
Phase 6 涉及公共 API 类型替换,下游用户升级到 v0.2 时需注意:
|
||||
|
||||
| 旧用法 | 新用法 |
|
||||
|--------|--------|
|
||||
| `use agcore::llm::types::ToolDefinition` | `use agcore::llm::types::ToolDef`(别名已移除) |
|
||||
| `use agcore::llm::types::OpenaiToolDefinition` | `use agcore::llm::types::ToolDef`(`OpenaiToolDefinition` 已降级为 `#[doc(hidden)]`) |
|
||||
| 直接构造 `ToolDefinition { strict: None, .. }` | 构造 `ToolDef { .. }`(去掉 `strict` 字段) |
|
||||
|
||||
`OpenaiToolDefinition` 仍保留但标记 `#[doc(hidden)]`,极端情况仍需使用时可通过全路径访问。
|
||||
|
||||
## 验证标准
|
||||
|
||||
| 阶段 | 验证命令 |
|
||||
|------|---------|
|
||||
| 单元 6.1 | `cargo build` 编译通过 |
|
||||
| 单元 6.2 | `cargo build` 编译通过(新旧代码全量编译) |
|
||||
| 单元 6.3 | `cargo test --all-targets` 全部通过 |
|
||||
| 单元 6.4 | `cargo clippy --all-targets -- -D warnings` 零警告;`cargo test --all-targets` 全部通过 |
|
||||
| 最终 | `cargo build --all-targets` + `cargo test --all-targets` + `cargo clippy --all-targets -- -D warnings` 全绿 |
|
||||
|
||||
## 风险与缓解
|
||||
|
||||
| 风险 | 说明 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| Struct literal 断层 | Step 6.2 切别名与构造修复若不同步,registry/mcp 中使用 `OpenaiToolDefinition` struct literal 的构造代码会编译失败 | 别名切换与构造修复合并在同一单元,原子化提交 |
|
||||
| 遗漏 `#[allow(deprecated)]` | 部分抑制点因 grep 遗漏而未在 6.4 移除 | clippy `-D warnings` 可检出;6.4 前做一次全库 grep 确认无遗漏 |
|
||||
| JSON 兼容性 | `ToolDef` serde 属性与 `OpenaiToolDefinition` 不一致导致 wire format 变化 | `ToolDef` serde 属性与 `OpenaiToolDefinition` 保持一致;roundtrip 测试验证 |
|
||||
| Provider 适配遗漏 | 部分 Provider 分支未经测试覆盖 | `cargo test --all-targets` 包含 Provider 测试 |
|
||||
|
||||
## 否决记录
|
||||
|
||||
| 否决方案 | 原因 |
|
||||
|---------|------|
|
||||
| 保留 `strict` 字段 | OpenAI 专属字段,当前所有构造路径传 `None`。不属于 Provider 无关的 IR。未来支持走 `MessageRequest.extra` 逃生舱 |
|
||||
| 逐步 deprecation 过渡 | pre-1.0 阶段 breaking change 合理,v0.1 已标记 deprecation,用户已有预期 |
|
||||
| `ToolDef` 建独立文件 | 约 7 行的 struct 不需要独立文件,与 `OpenaiToolDefinition` 共享 `types/tool.rs` 即可 |
|
||||
| `ToolDef` 放在 `tools/` 模块 | 会创造 `llm` → `tools` 的逆向依赖,破坏模块分层 |
|
||||
| 添加 builder 模式 | Rust struct literal + `..Default::default()` 已足够覆盖使用场景 |
|
||||
| 添加 `#[non_exhaustive]` | IR 类型自有完整控制权,不需要对外隐藏字段 |
|
||||
| 同 Phase 净化 `parameters` 类型化 | 属于独立工作,留给 v0.3+ 阶段处理 |
|
||||
@@ -0,0 +1,526 @@
|
||||
# Phase 7 — SqliteStore 持久化实现方案
|
||||
|
||||
- **文档编号**:14
|
||||
- **标题**:Phase 7 — SqliteStore 持久化实现方案
|
||||
- **日期**:2026-07-05
|
||||
- **状态**:已定稿
|
||||
- **涉及模块**:memory/store
|
||||
- **关联文档**:roadmap.md, 6-memory-system.md
|
||||
|
||||
---
|
||||
|
||||
## 背景与目标
|
||||
|
||||
Phase 7 的核心任务是完成 MemoryStore trait 的 SQLite 后端实现,使 Agent 进程重启后记忆数据不丢失。这是 v0.2.0 从"内存玩具"走向"可用工具"的关键门槛,也是后续 Phase 8(MVP 出口)和 Phase 10(ContextSlot)的前置依赖。
|
||||
|
||||
**成功标准**:
|
||||
- SqliteStore 完整实现 MemoryStore trait(4 个方法:save/get/delete/list)
|
||||
- 进程关闭后重新打开同一数据库文件,数据完整可读
|
||||
- 与现有 InMemoryStore 通过 MemoryStore trait 可互换,消费者零改动
|
||||
- 所有现有测试保持通过,clippy 0 警告
|
||||
|
||||
### Scope & Non-goals
|
||||
|
||||
| 范围 | 内容 |
|
||||
|------|------|
|
||||
| 包含 | 单表 CRUD + prefix/since/offset/limit 查询 + WAL 并发 + Mutex 串行化 + 错误映射 |
|
||||
| 不包含(Phase 7) | 淘汰策略(EvictionPolicy,仅 InMemoryStore 持有,需 v0.3 纳入 SqliteStore) |
|
||||
| 不包含(Phase 7) | Schema 迁移框架(PRAGMA user_version 足矣,不引入 refinery/sea-query) |
|
||||
| 不包含(Phase 7) | 批量写入 / 事务 API(N+1 clear 延迟可接受,优化后置) |
|
||||
| 不包含(Phase 7) | 跨进程共享同一数据库文件(Mutex 为单进程设计) |
|
||||
|
||||
---
|
||||
|
||||
## 当前状态分析
|
||||
|
||||
### 现有实现
|
||||
- MemoryStore trait 已在 v0.1 Phase 3 就绪,定义 4 个异步方法
|
||||
- InMemoryStore 实现稳定运行,使用 `Mutex<HashMap>` 作为后端
|
||||
- 全量测试 191 个通过,clippy 0 警告
|
||||
- 项目当前无 SQLite 或其他数据库依赖
|
||||
|
||||
### 现有消费者
|
||||
通过 `crate::memory::store::MemoryStore` 路径引用的模块:
|
||||
|
||||
| 模块 | 文件 | 使用方式 |
|
||||
|------|------|----------|
|
||||
| Agent Builder | `agent/builder.rs` | RuntimeBundle 中引用 MemoryStore |
|
||||
| Session Memory | `agent/session_memory.rs` | SessionMemory 实现 |
|
||||
| Agent Runtime | `agent/runtime.rs` | 类型标注 |
|
||||
| Agent Session | `agent/session.rs` | 默认 InMemoryStore 兜底 |
|
||||
| Conversation | `memory/conversation.rs` | ConversationMemory 测试 |
|
||||
| Knowledge | `memory/knowledge.rs` | KnowledgeStore 测试 |
|
||||
| Retriever | `memory/retriever.rs` | MemoryRetriever 测试 |
|
||||
|
||||
所有消费者均通过 `MemoryStore` trait 访问,不依赖具体实现类型,因此新增 SqliteStore 不会产生编译或运行时影响。
|
||||
|
||||
### 目录结构现状
|
||||
|
||||
```
|
||||
src/memory/
|
||||
├── mod.rs
|
||||
├── store.rs ← 包含 MemoryStore trait + InMemoryStore + EvictionPolicy
|
||||
├── conversation.rs
|
||||
├── knowledge.rs
|
||||
├── retriever.rs
|
||||
└── vector.rs
|
||||
```
|
||||
|
||||
`store.rs` 目前是一个单体文件,同时承载 trait 定义和 InMemoryStore 实现。
|
||||
|
||||
---
|
||||
|
||||
## 调研发现
|
||||
|
||||
### MemoryStore trait 定义
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait MemoryStore: Send + Sync {
|
||||
async fn save(&self, item: MemoryItem) -> Result<(), MemoryError>;
|
||||
async fn get(&self, id: &str) -> Result<Option<MemoryItem>, MemoryError>;
|
||||
async fn delete(&self, id: &str) -> Result<(), MemoryError>;
|
||||
async fn list(&self, filter: &MemoryFilter) -> Result<Vec<MemoryItem>, MemoryError>;
|
||||
}
|
||||
```
|
||||
|
||||
### 关键类型
|
||||
|
||||
| 类型 | 定义 |
|
||||
|------|------|
|
||||
| `MemoryItem` | `{ id: String, content: String, metadata: Value, created_at: OffsetDateTime }` |
|
||||
| `MemoryFilter` | `{ prefix: Option<String>, since: Option<OffsetDateTime>, offset: Option<usize>, limit: Option<usize> }` |
|
||||
| `MemoryError` | 变体:`NotFound` / `Storage` / `Serialization` / `InvalidInput` / `RetrievalError` |
|
||||
| `EvictionPolicy` | `None` / `Ttl { ttl_secs }` / `Capacity { max_items }` |
|
||||
| `EvictionConfig` | `{ policy, check_interval }` |
|
||||
|
||||
### 并发模型参考
|
||||
|
||||
InMemoryStore 当前使用 `Mutex<HashMap>` 实现 `Send + Sync`。SqliteStore 将遵循相同模式,使用 `Arc<Mutex<Connection>>` + `spawn_blocking` 满足异步 trait 约束。
|
||||
|
||||
### Schema 设计考虑
|
||||
|
||||
- `created_at` 使用 TEXT(ISO 8601) 存储——`.to_string()` 零转换,字典序与时间序一致(前提:所有时间戳归一化到 UTC;`OffsetDateTime::to_string()` 在 UTC 下输出 `"2026-07-05T12:00:00Z"` 格式,字典序与时间序严格对应)
|
||||
- 初始 schema 即创建 `created_at` 索引,避免后续大数据量全表排序
|
||||
- Schema 版本管理通过 `PRAGMA user_version` 实现,零外部依赖,后续加字段只需追加 `if version < N { ALTER TABLE }`
|
||||
|
||||
---
|
||||
|
||||
## 可选方案
|
||||
|
||||
### A. rusqlite + Mutex\<Connection\>(推荐)
|
||||
|
||||
| 维度 | 评估 |
|
||||
|------|------|
|
||||
| 新增依赖 | 1 个(rusqlite 0.32 + bundled features) |
|
||||
| 实现量 | ~200 行 |
|
||||
| SQL 支持 | 原生支持 prefix LIKE 过滤 + ORDER BY 排序 |
|
||||
| 性能 | 有索引时查询 O(log n),写入串行化 |
|
||||
| 并发 | WAL 模式 + Mutex 串行化写入,适合单进程 Agent |
|
||||
| 事务支持 | 完整 ACID |
|
||||
| 崩溃安全 | WAL 模式,崩溃恢复有保障 |
|
||||
|
||||
**适用场景**:单进程 Agent 本地持久化、嵌入式场景、需要关系查询能力的通用存储。
|
||||
|
||||
**外部依赖评估**:
|
||||
- rusqlite 0.32 — 最新稳定版(2025-12 发布),维护活跃(月均 2+ 次提交),Apache-2.0 许可证
|
||||
- `bundled` feature 编译 SQLite 源码(Public Domain)进二进制,无系统级 SQLite 依赖,零外部 C 库安装步骤
|
||||
- 供应链风险:bundled 模式依赖 crate 发布节奏同步 SQLite 安全更新;SQLite 安全公告频率极低(年均 <3 例),此风险可接受
|
||||
|
||||
### B. JSONL 文件
|
||||
|
||||
| 维度 | 评估 |
|
||||
|------|------|
|
||||
| 新增依赖 | 0 |
|
||||
| 实现量 | ~150 行 |
|
||||
| get() 复杂度 | O(n) 全量扫描 |
|
||||
| delete() 复杂度 | O(n) 全量重写 |
|
||||
| 并发 | 需文件锁(flock) |
|
||||
| 事务支持 | 无 |
|
||||
| 崩溃安全 | 无保障,写入中断可能丢失或损坏数据 |
|
||||
|
||||
**否决理由**:核心的 get() 查询场景不可接受 O(n) 性能;在 Agent 运行时频繁读写记忆的场景下,全量扫描的成本会随着数据积累线性增长,不符合可用性要求。
|
||||
|
||||
### C. sled 嵌入式 KV
|
||||
|
||||
| 维度 | 评估 |
|
||||
|------|------|
|
||||
| 新增依赖 | 1 个(纯 Rust) |
|
||||
| 实现量 | ~150 行 |
|
||||
| prefix scan | 原生支持 |
|
||||
| 排序 | 需手动实现 |
|
||||
| 关系模型 | 不如 SQL 匹配当前查询模式 |
|
||||
| 社区成熟度 | 较新,API 仍在演进 |
|
||||
|
||||
**否决理由**:当前查询模式(prefix 过滤 + 按 created_at 排序)在关系模型中用一条 SQL 即可表达,引入 KV 存储反而需要手动处理排序逻辑。非必要不引入新存储范式。
|
||||
|
||||
---
|
||||
|
||||
## 推荐方案
|
||||
|
||||
### 总体方向:方案 A(rusqlite + Mutex\<Connection\>)
|
||||
|
||||
选择理由:
|
||||
|
||||
1. **最少依赖,最高匹配**:1 个新增依赖即可完整支持 MemoryFilter 的所有查询维度(prefix LIKE、created_at 范围、offset/limit)
|
||||
2. **生产就绪**:rusqlite 是 SQLite 的 Rust 绑定事实标准,bundled 模式免去系统 SQLite 依赖
|
||||
3. **Schema 演进简单**:PRAGMA user_version + 逐版本迁移,零外部迁移工具依赖
|
||||
4. **与 InMemoryStore 语义一致**:Mutex 串行化 + spawn_blocking 适配 async trait,与现有并发模型同构
|
||||
|
||||
### 关键设计决策
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| Schema 版本管理 | PRAGMA user_version | 零外部依赖,~20 行,后续 ALTER TABLE 即可 |
|
||||
| created_at 存储格式 | TEXT(ISO 8601) + UTC 归一化 | 零转换代码;UTC 下输出 `"2026-07-05T12:00:00Z"`,字典序与时间序严格一致 |
|
||||
| Upsert SQL 策略 | `INSERT ... ON CONFLICT(id) DO UPDATE SET ...` | 保留调用方传入的 `created_at`,避免被 `DEFAULT` 覆盖 |
|
||||
| 性能索引 | 初始 schema 加 created_at 索引 | 避免大数据量全表排序 |
|
||||
| 配置参数 | 仅 `path`、`busy_timeout=5s` | 其余内置默认值;5s 超时避免 `SQLITE_BUSY` 快速失败 |
|
||||
| Mutex 中毒恢复 | `.lock().unwrap_or_else(\|e\| e.into_inner())` | 不 panic,恢复执行 |
|
||||
| 批量操作 | 不加 | N+1 clear ~250ms(N=50),可接受,优化后置 |
|
||||
| spawn_blocking 取消安全性 | 短事务模式(auto-commit) | 每个操作独立事务,取消时后台 task 自然完成/panic,不 Cross 操作持有 Mutex |
|
||||
|
||||
**我们放弃了什么**(集中 Trade-off 记录):
|
||||
- **写入串行化**:`Mutex<Connection>` 确保 SQLite 写入安全,代价是同一时刻只能有一个写入者。Agent 场景下写入频率低(每次 LLM 调用触发 1-2 次),串行化不构成瓶颈
|
||||
- **单进程锁**:无法跨进程共享同一数据库文件。多进程场景需要网络后端(PostgreSQL/Redis)
|
||||
- **无横向扩展**:单文件 SQLite 无分片能力。需扩展时切换到分布式后端
|
||||
|
||||
### Schema 定义(初始版本)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS memory_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_items_created_at
|
||||
ON memory_items(created_at);
|
||||
```
|
||||
|
||||
### 数据完整性防御
|
||||
|
||||
| 异常场景 | 防御措施 | 错误映射 |
|
||||
|---------|---------|---------|
|
||||
| 数据库文件损坏 | `migrate()` 中执行 `PRAGMA quick_check`;失败时 `open()` 返回 `MemoryError::Storage` | `Storage` |
|
||||
| created_at 解析失败 | `get()`/`list()` 中 `OffsetDateTime::parse` 失败不 panic,返回 `MemoryError::Serialization` | `Serialization` |
|
||||
| content / metadata 为 NULL | `get()` 中检测 SQLite 返回值,NULL 时返回 `MemoryError::Storage` | `Storage` |
|
||||
| 约束冲突(PRIMARY KEY / NOT NULL) | 映射为 `MemoryError::InvalidInput` | `InvalidInput` |
|
||||
| 序列化/反序列化失败 | `serde_json::to_string`/`from_str` 错误映射为 `MemoryError::Serialization` | `Serialization` |
|
||||
|
||||
### 性能预算(目标延迟,单条操作)
|
||||
|
||||
| 操作 | 目标延迟 | 说明 |
|
||||
|------|---------|------|
|
||||
| `save(1KB item)` | < 5ms | 含 serde_json 序列化 + spawn_blocking + SQLite INSERT |
|
||||
| `get(1KB item)` | < 3ms | 含 SQLite SELECT + 反序列化 |
|
||||
| `list(prefix 匹配 100 行)` | < 20ms | 含索引 B-tree 遍历 + ORDER BY + LIMIT |
|
||||
| 并发 10 writer | p99 < 50ms | Mutex 串行化排队,每 writer 等待 9×5ms 内 |
|
||||
|
||||
实施后通过 Step C 测试验证以上预算。未达标时不阻塞发布,但记录为可观测告警阈值。
|
||||
|
||||
---
|
||||
|
||||
## 实施建议
|
||||
|
||||
### 实施计划
|
||||
|
||||
#### Step A — 目录重构(纯搬移,零行为变化)
|
||||
|
||||
目标:将单体 `store.rs` 拆分为模块目录架构,为新增 SqliteStore 做准备。
|
||||
|
||||
```
|
||||
src/memory/
|
||||
├── store.rs ← 模块根:MemoryStore trait + EvictionPolicy/EvictionConfig
|
||||
│ + pub mod in_memory;
|
||||
│ + pub mod sqlite_store;
|
||||
│ + pub use in_memory::InMemoryStore;
|
||||
├── store/
|
||||
│ ├── in_memory.rs ← InMemoryStore 提取至此(struct + impl + 6 个内联测试)
|
||||
│ └── sqlite_store.rs ← 新增 SqliteStore
|
||||
```
|
||||
|
||||
模式参考:`llm/provider.rs` → `llm/provider/{openai,anthropic,ollama}.rs`
|
||||
|
||||
**外部消费者的导入路径不变**(`crate::memory::store::MemoryStore`),零改动风险。
|
||||
|
||||
重构步骤:
|
||||
1. 创建 `src/memory/store/` 目录
|
||||
2. 创建 `src/memory/store/in_memory.rs`,从原 `store.rs` 提取 InMemoryStore 全部代码(struct + impl + Default + 6 个测试)
|
||||
3. 修改 `src/memory/store.rs`:保留 MemoryStore trait + EvictionPolicy/EvictionConfig,加 `pub mod in_memory;` + `pub use in_memory::InMemoryStore;`
|
||||
4. 验证:`cargo test --all-targets` 全绿,测试数量不变(191 pass)
|
||||
|
||||
#### Step B — SqliteStore 实现
|
||||
|
||||
1. `Cargo.toml` 添加 `rusqlite = { version = "0.32", features = ["bundled"] }`
|
||||
2. 创建 `src/memory/store/sqlite_store.rs`,实现:
|
||||
- `SqliteStore` 结构体:`{ conn: Arc<Mutex<Connection>> }`
|
||||
- `SqliteStore::open(path)` 构造函数,支持 `":memory:"`
|
||||
- `lock_conn()` 辅助方法(Mutex 中毒恢复)
|
||||
- `migrate()` Schema 初始化 + 版本管理
|
||||
- `MemoryStore` trait 的 4 个方法
|
||||
- `From<rusqlite::Error> for MemoryError`
|
||||
3. 修改 `src/memory/store.rs`:加 `pub mod sqlite_store;` + `pub use sqlite_store::SqliteStore;`
|
||||
4. 修改 `src/memory.rs`:加 `pub use store::SqliteStore;`
|
||||
5. 编写测试覆盖:
|
||||
- CRUD 基本操作
|
||||
- Upsert(同 id 重复 save 覆盖)
|
||||
- prefix 过滤
|
||||
- 由于/until 时间范围过滤
|
||||
- 并发 10 个 writer × 10 次操作
|
||||
- 持久化恢复(write → drop → reopen → read)
|
||||
6. 验证:`cargo test --all-targets` 全绿 + `cargo clippy --all-targets -- -D warnings` 0 警告
|
||||
|
||||
#### Step C — 验证确认
|
||||
|
||||
1. 确认现有 memory 模块内测试全部通过
|
||||
2. 确认 agent/llm/tools/prompt 模块不受影响
|
||||
3. 确认 SqliteStore 与 InMemoryStore 通过 MemoryStore trait 可互换
|
||||
4. 确认 clippy 无新增警告
|
||||
|
||||
### Commit 安排
|
||||
|
||||
| 顺序 | 类型 | Scope | 描述 |
|
||||
|------|------|-------|------|
|
||||
| 1 | refactor | memory | 将 store.rs 拆分为模块目录,仅结构搬移 |
|
||||
| 2 | feat | memory | 实现 SqliteStore 持久化 |
|
||||
|
||||
### 风险与缓解
|
||||
|
||||
| 风险 | 严重度 | 缓解措施 |
|
||||
|------|--------|----------|
|
||||
| Mutex 中毒导致后续操作全部失败 | 中 | `lock_conn()` 使用 `.lock().unwrap_or_else(\|e\| e.into_inner())` 恢复模式,不 panic |
|
||||
| spawn_blocking 取消后连接状态不一致 | 中 | 每个操作使用短事务(auto-commit),不跨操作持有 Mutex;取消时遗留 task 自然完成或 panic,Mutex 通过 `.into_inner()` 恢复 |
|
||||
| WAL 文件无限增长 | 低 | 内置 auto-checkpoint 阈值 + 启动时执行 `PRAGMA wal_checkpoint(TRUNCATE)` |
|
||||
| list 无索引导致全表扫描 | 中(大数据量) | 初始 schema 即创建 `idx_memory_items_created_at` 索引 |
|
||||
| 父目录不存在导致 open 失败 | 低 | `open()` 内部调用 `fs::create_dir_all()` 确保目录存在 |
|
||||
| clear() N+1 删除性能 | 低 | 不走 trait 接口的逐条删除,可后续优化为直接 `DELETE FROM memory_items` |
|
||||
| 数据库文件损坏 | 低 | `migrate()` 中执行 `PRAGMA quick_check`;失败时返回 `MemoryError::Storage`,调用方可切换 InMemoryStore |
|
||||
|
||||
### 可观测性(实施时落实)
|
||||
|
||||
- 所有 MemoryStore 方法通过 `tracing::instrument` 记录延迟和结果(`info!` 正常完成,`warn!` 超过性能预算阈值,`error!` 操作失败)
|
||||
- `list()` 返回行数通过 `tracing::debug` 记录(调优参考)
|
||||
- WAL 文件大小在 `migrate()` 后检查一次,超过 100MB 时记录 `warn!`
|
||||
- 操作计数(读写次数、错误率)暂不暴露为独立 metrics,v0.3 按需添加
|
||||
|
||||
---
|
||||
|
||||
## 已知假设
|
||||
|
||||
| 假设 | 验证状态 | Fallback |
|
||||
|------|---------|----------|
|
||||
| 单进程独享 SQLite 文件,无跨进程竞争 | ✅ 设计前提(Mutex 为单进程设计) | 多进程场景使用网络后端(PostgreSQL/Redis,v0.3+) |
|
||||
| ISO 8601 TEXT 字典序等价于时间序 | ✅ 条件成立(需 UTC 归一化) | 若时区异常,切换 INTEGER(unix_timestamp) 存储后重建索引 |
|
||||
| SqliteStore 初始化失败可 fallback 到 InMemoryStore | ✅ 调用方自行控制 | `open()` 返回 `MemoryError`,消费者 catch 后改用 `InMemoryStore::new()` |
|
||||
| N+1 clear ~250ms(N=50) 可接受 | 🟡 未实测(基于 N×5ms 推算) | 若成为瓶颈,SqliteStore 内部加 `delete_by_prefix()` 方法(不走 trait 接口) |
|
||||
| rusqlite bundled SQLite 版本足够新 | ✅ 0.32 版内置 SQLite 3.46 | 如需特定版本,切换 `bundled` 为指定版本或使用系统 SQLite |
|
||||
| busy_timeout=5s 覆盖所有竞争场景 | 🟡 未实测(WAL 下写写冲突概率低) | 若观测到 `SQLITE_BUSY`,增大超时或在重试逻辑中处理 |
|
||||
| spawn_blocking 线程池不会被耗尽 | ✅ 默认 512 线程,Agent 场景占用 ≤10 | 若观测到阻塞任务排队,启动时 `tokio::task::spawn_blocking` 已有兜底排队机制 |
|
||||
|
||||
---
|
||||
|
||||
## 参考来源
|
||||
|
||||
- [rusqlite crate](https://crates.io/crates/rusqlite) — 官方文档
|
||||
- [SQLite PRAGMA user_version](https://www.sqlite.org/pragma.html#pragma_user_version) — Schema 版本管理机制
|
||||
- [SQLite WAL mode](https://www.sqlite.org/wal.html) — 并发读写性能优化
|
||||
- `docs/6-memory-system.md` — Phase 3 MemoryStore trait 原始设计
|
||||
- `docs/roadmap.md` — 项目里程碑规划(Phase 7/8/10 依赖关系)
|
||||
- `src/llm/provider.rs` → `src/llm/provider/` — 目录重构模式参考
|
||||
|
||||
---
|
||||
|
||||
## 实施计划
|
||||
|
||||
### 任务总览
|
||||
|
||||
3 个阶段、8 个任务单元、2 个 Commit。
|
||||
|
||||
### 阶段一:目录重构
|
||||
|
||||
#### Task A1 — 创建 store/ 目录并提取 InMemoryStore
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 任务描述 | 创建 `src/memory/store/` 目录,新建 `store/in_memory.rs`,从 `store.rs` 完整提取 InMemoryStore 结构体、impl MemoryStore、impl Default、6 个内联测试 |
|
||||
| 涉及文件 | `src/memory/store.rs` → 分割到 `src/memory/store/in_memory.rs`(新增) |
|
||||
| 前置依赖 | 无 |
|
||||
| 预估工作量 | S(< 1h) |
|
||||
| 风险等级 | 低 — 纯搬移,编译器可验证 |
|
||||
| 验收条件 | `cargo build` 通过(此时 store.rs 尚未修改,store/in_memory.rs 应被 crate 忽略) |
|
||||
|
||||
注意:需要先在 store.rs 顶部添加 `pub mod in_memory;` 声明,否则子模块不会被编译。或者可以先创建目录和文件,等 Task A2 再统一加声明路径。
|
||||
|
||||
实际做法:先创建文件但不声明,A2 统一声明。这样 A1 和 A2 之间可以有一个无编译的中间状态。
|
||||
|
||||
#### Task A2 — 修改 store.rs 模块根
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 任务描述 | 修改 `store.rs` 为纯模块根:保留 `MemoryStore` trait、`EvictionPolicy`、`EvictionConfig`;添加 `pub mod in_memory;` + `pub use in_memory::InMemoryStore;`;删除已提取到 in_memory.rs 中的代码 |
|
||||
| 涉及文件 | `src/memory/store.rs`(修改) |
|
||||
| 前置依赖 | Task A1(文件已存在) |
|
||||
| 预估工作量 | S(< 1h) |
|
||||
| 风险等级 | 低 — 保留部分不变,提取部分在子模块中 |
|
||||
| 验收条件 | `cargo test --all-targets` 全绿,测试数量不变(191 pass),clippy 0 warning |
|
||||
|
||||
#### Task A3 — 验证阶段一
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 任务描述 | 运行全量测试链确认目录重构零行为变化 |
|
||||
| 涉及文件 | 全量 |
|
||||
| 前置依赖 | Task A2 |
|
||||
| 预估工作量 | XS(验证) |
|
||||
| 风险等级 | 低 |
|
||||
| 验收条件 | `cargo test --all-targets` 191 pass、`cargo clippy --all-targets -- -D warnings` 0 警告、`cargo build` 通过 |
|
||||
|
||||
### 阶段二:SqliteStore 实现
|
||||
|
||||
#### Task B1 — 添加 rusqlite 及 dev-dependencies
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 任务描述 | 在 `Cargo.toml` 中添加依赖:`[dependencies]` 加 `rusqlite = { version = "0.32", features = ["bundled"] }`,`[dev-dependencies]` 加 `tempfile = "3"`(用于测试隔离);运行 `cargo build` 确认编译通过,`cargo test --no-run` 验证 dev-dependencies 可用 |
|
||||
| 涉及文件 | `Cargo.toml`(修改)、`Cargo.lock`(自动更新) |
|
||||
| 前置依赖 | 无(可与阶段一并行) |
|
||||
| 预估工作量 | XS(< 15min) |
|
||||
| 风险等级 | 低 — 标准依赖添加 |
|
||||
| 验收条件 | `cargo build` 成功,`cargo test --no-run` 成功,Cargo.lock 中生成 rusqlite 和 tempfile 条目 |
|
||||
|
||||
#### Task B2 — 实现 SqliteStore 核心
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 任务描述 | 创建 `src/memory/store/sqlite_store.rs`,实现以下 8 个子模块: |
|
||||
| | (1)`SqliteStore` 结构体 `{ conn: Arc<Mutex<Connection>> }` |
|
||||
| | (2)`SqliteStore::open(path)` — 支持 `":memory:"`,内部调用 `fs::create_dir_all` 确保父目录存在 |
|
||||
| | (3)`lock_conn()` — 内部辅助方法,`.lock().unwrap_or_else(\|e\| e.into_inner())` 处理 Mutex 中毒 |
|
||||
| | (4)`migrate()` — 按版本递增执行迁移:`PRAGMA user_version` 检查(初始版本号=1)→ 建表 `memory_items` + 索引 `idx_memory_items_created_at` + 设置 WAL 模式 + `busy_timeout=5s` + `PRAGMA synchronous = NORMAL` + `PRAGMA wal_autocheckpoint=1000` + `PRAGMA quick_check`(检测数据库损坏)+ `PRAGMA wal_checkpoint(TRUNCATE)` |
|
||||
| | (5)`MemoryStore` trait 的 4 个方法实现(save/get/delete/list),全部使用 spawn_blocking 包裹;**生命周期注意**:`spawn_blocking` 闭包前先 `.conn.clone()` 取 `Arc<Connection>`,参数调 `.clone()` 取 owned 值,再传入 `spawn_blocking(move \|{ ... })` |
|
||||
| | — `save`: `INSERT INTO memory_items (id, content, metadata, created_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(id) DO UPDATE SET content=excluded.content, metadata=excluded.metadata, created_at=excluded.created_at`(全字段覆盖 upsert,与 InMemoryStore 行为一致) |
|
||||
| | — `get`: `SELECT content, metadata, created_at FROM memory_items WHERE id = ?1` → Ok(None) 当无结果 |
|
||||
| | — `delete`: `DELETE FROM memory_items WHERE id = ?1`(幂等,不返回 NotFound) |
|
||||
| | — `list`: 根据 filter 字段(prefix/since/offset/limit)组合动态构造 WHERE 子句 + `ORDER BY created_at ASC` + `LIMIT ? OFFSET ?`,全部使用参数化查询防注入 |
|
||||
| | (6)序列化转换层:`time::OffsetDateTime` 存为 TEXT(ISO 8601),通过 `.to_string()` 绑定 `String` 参数;`serde_json::Value` 存为 TEXT,通过 `serde_json::to_string()` 绑定 `String` 参数;读取时通过 `OffsetDateTime::parse` 和 `serde_json::from_str` 反序列化。在 SqliteStore 内部实现 `to_sql_params()` / `from_sql_row()` 辅助方法集中处理 |
|
||||
| | (7)错误映射:不在 blanket impl From 中处理所有 rusqlite Error,而是在每个方法内部按数据完整性防御表的映射规则逐类处理: |
|
||||
| | — 数据库文件损坏 / IO 错误 → `MemoryError::Storage` |
|
||||
| | — created_at 解析失败 → `MemoryError::Serialization`(不 panic) |
|
||||
| | — content/metadata 为 NULL → `MemoryError::Storage` |
|
||||
| | — serde_json 序列化/反序列化失败 → `MemoryError::Serialization` |
|
||||
| | — 约束冲突 → `MemoryError::InvalidInput` |
|
||||
| | (8)可观测性:在 4 个 trait 方法和 `open()` 上添加 `#[tracing::instrument(skip(self))]`;正常完成记录 `trace!`,超过性能预算阈值记录 `warn!`,操作失败记录 `error!` |
|
||||
| 涉及文件 | `src/memory/store/sqlite_store.rs`(新增) |
|
||||
| 前置依赖 | Task B1(rusqlite + tempfile 依赖)、Task A1(store/ 目录存在) |
|
||||
| 预估工作量 | M(1-4h) |
|
||||
| 风险等级 | 高 — 3 个技术点需留意:`time::OffsetDateTime` 无 `rusqlite::ToSql` 实现,需显式处理 String 绑定;`spawn_blocking` + `&self` 生命周期需 clone 后才能传闭包;错误映射需精细匹配 `rusqlite::Error` 嵌套变体(`SqliteFailure` 内含 `ErrorCode`) |
|
||||
| 验收条件 | 单元测试通过(见 Task B4)、`cargo build` 通过 |
|
||||
|
||||
#### Task B3 — 注册模块并重导出
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 任务描述 | 在 `store.rs` 添加 `pub mod sqlite_store;` + `pub use sqlite_store::SqliteStore;`;在 `memory.rs` 添加 `pub use store::SqliteStore;` |
|
||||
| 涉及文件 | `src/memory/store.rs`(修改)、`src/memory.rs`(修改) |
|
||||
| 前置依赖 | Task B2(sqlite_store.rs 文件存在) |
|
||||
| 预估工作量 | XS(< 15min) |
|
||||
| 风险等级 | 低 |
|
||||
| 验收条件 | `cargo build` 通过,`SqliteStore` 可从 `agcore::memory::SqliteStore` 路径访问 |
|
||||
|
||||
#### Task B4 — 编写测试
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 任务描述 | 在 `sqlite_store.rs` 中编写 `#[cfg(test)] mod tests`,覆盖: |
|
||||
| | (1)CRUD 基本操作(save → get → list → delete → get None) |
|
||||
| | (2)Upsert 语义(同 id 重复 save 覆盖内容,created_at 保持调用方传入值) |
|
||||
| | (3)prefix 过滤(MemoryFilter.prefix) |
|
||||
| | (4)时间范围过滤(MemoryFilter.since) |
|
||||
| | (5)offset/limit 分页 |
|
||||
| | (6)并发 10 个 writer × 10 次写入,验证无数据丢失 |
|
||||
| | (7)持久化恢复(write → drop store → reopen 同一文件 → read) |
|
||||
| | (8)错误路径:`open("/nonexistent_dir/ag.db")` 返回 Storage 错误 |
|
||||
| | 辅助函数:`make_item(id)` 创建 MemoryItem,使用 `tempfile::TempDir`(或自定义 tmp 路径)隔离测试数据库文件 |
|
||||
| 涉及文件 | `src/memory/store/sqlite_store.rs`(修改追加 test mod) |
|
||||
| 前置依赖 | Task B2(实现完成) |
|
||||
| 预估工作量 | M(1-4h) |
|
||||
| 风险等级 | 中 — 并发测试的时序控制、持久化恢复测试的 TempDir 管理 |
|
||||
| 验收条件 | 全量测试通过,新增测试 ≥ 8 个 |
|
||||
|
||||
#### Task B5 — 验证阶段二
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 任务描述 | 运行全量测试链确认 SqliteStore 实现正确,不影响已有模块 |
|
||||
| 涉及文件 | 全量 |
|
||||
| 前置依赖 | Task B3、Task B4 |
|
||||
| 预估工作量 | S(< 1h) |
|
||||
| 风险等级 | 低 |
|
||||
| 验收条件 | `cargo test --all-targets` 全绿(191 + 新增测试)、`cargo clippy --all-targets -- -D warnings` 0 警告、`cargo build` 通过 |
|
||||
|
||||
### 阶段三:验证收尾
|
||||
|
||||
#### Task C1 — 跨模块兼容性验证
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 任务描述 | (1)确认 `ConversationMemory` / `KnowledgeStore` / `MemoryRetriever` / `AgentSession` / `SessionMemory` 以 `Arc<dyn MemoryStore>` 接受 SqliteStore 时编译通过且测试全绿 |
|
||||
| | (2)确认 SqliteStore 与 InMemoryStore 可互换——修改一个现有测试将后端从 InMemoryStore 换为 SqliteStore(使用 `":memory:"`),测试全绿 |
|
||||
| | (3)验证性能预算:在测试环境下测量 save(1KB)/get(1KB)/list(100行) 的单次延迟,确认 < 5ms / < 3ms / < 20ms |
|
||||
| 涉及文件 | 测试文件(memory/ 模块内各 test mod) |
|
||||
| 前置依赖 | Task B5 |
|
||||
| 预估工作量 | S(< 1h) |
|
||||
| 风险等级 | 低 |
|
||||
| 验收条件 | 全量测试通过 + 互换测试通过 + 性能预算大致满足(未达标不阻塞发布) |
|
||||
|
||||
### 依赖关系图
|
||||
|
||||
```
|
||||
阶段一(目录重构) 阶段二(SqliteStore 实现)
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ Task A1 │ │ Task B1 │ ← 无依赖,可与 A 并行
|
||||
│ 创建目录+提取 │ │ Cargo.toml │
|
||||
└──────┬───────┘ └──────┬───────┘
|
||||
↓ ↓
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ Task A2 │ │ Task B2 │
|
||||
│ 修改store.rs │← A1 ───→│ 核心实现 │← B1 + A1
|
||||
└──────┬───────┘ └──────┬───────┘
|
||||
↓ ↓
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Task A3 │ │ Task B3 │← B2 ──→│ Task B4 │
|
||||
│ 验证阶段一 │ │ 注册+重导出 │ │ 编写测试 │
|
||||
└──────────────┘ └──────┬───────┘ └──────┬───────┘
|
||||
↓ ↓
|
||||
┌──────────────┐────────────────┘
|
||||
│ Task B5 │← B3 + B4
|
||||
│ 验证阶段二 │
|
||||
└──────┬───────┘
|
||||
↓
|
||||
阶段三(验证收尾)
|
||||
┌──────────────┐
|
||||
│ Task C1 │
|
||||
│ 跨模块兼容性 │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
### Commit 安排
|
||||
|
||||
| 顺序 | Commit 类型 | Scope | 描述 | 包含 Task |
|
||||
|------|------------|-------|------|-----------|
|
||||
| 1 | refactor | memory | 将 store.rs 拆分为模块目录,仅结构搬移 | A1 → A2 → A3 |
|
||||
| 2 | feat | memory | 实现 SqliteStore 持久化(含错误映射、测试、WAL 模式) | B1 → B2 → B3 → B4 → B5 → C1 |
|
||||
|
||||
注意:Task B1 与阶段一无依赖,可以在 Commit 1 合并进行或在 Commit 2 开头。建议在 Commit 2 开头,因为 Cargo.toml 变更属于功能变更而非重构。
|
||||
|
||||
### 验证全链
|
||||
|
||||
实施完毕后整体认证链路:
|
||||
|
||||
1. `cargo test --all-targets` — 全量测试通过
|
||||
2. `cargo clippy --all-targets -- -D warnings` — 0 警告
|
||||
3. `cargo build --release` — release 构建通过
|
||||
4. 确认 `cargo doc --no-deps` 无 warning(新增公开类型文档注释)
|
||||
5. 确认测试数量:191 + (8 个 new sqlite_store tests) = 199+ pass
|
||||
@@ -0,0 +1,543 @@
|
||||
# Phase 8 — MVP 集成出口实现方案
|
||||
|
||||
- **文档编号**:15
|
||||
- **标题**:Phase 8 — MVP 集成出口实现方案
|
||||
- **日期**:2026-07-05
|
||||
- **状态**:已定稿
|
||||
- **涉及模块**:全局(llm/types、agent、tools、memory、prompt、examples)
|
||||
- **关联文档**:roadmap.md(§Phase 8)、14-phase7-sqlite-store.md
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
Phase 5-7 已交付 P0 功能闭环:ProviderConfig `from_env()`(Phase 5)、ToolDef IR 正式化(Phase 6)、SqliteStore 持久化(Phase 7)。当前 200 个测试全绿、clippy 0 警告,但缺乏一个"可被人依赖"的集成出口。
|
||||
|
||||
Phase 8 的目标是完成 API 稳定性扫尾 + Quick Start 示例 + 端到端示例,产出 **v0.2.0-rc.1** 标签。三个 Step 分别对应三类用户群体:
|
||||
|
||||
| Step | 受众 | 交付物 |
|
||||
|------|------|--------|
|
||||
| **8.1** | 存量升级者(v0.1 → v0.2) | API 稳定性扫尾 + CHANGELOG |
|
||||
| **8.2** | 新用户评估者("30 秒决定要不要用") | Quick Start 示例 |
|
||||
| **8.3** | 技术决策者("这框架能跑真实场景吗") | 端到端集成示例 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 当前状态
|
||||
|
||||
| 度量 | 数值 |
|
||||
|------|------|
|
||||
| `cargo test --all-targets` | ✅ 200 passed / 0 failed |
|
||||
| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 |
|
||||
| 已存在 `#[non_exhaustive]` 枚举 | 4 个(StopReason / FinishReason / EvictionPolicy / ProviderType) |
|
||||
| 已存在 `#[deprecated]` 项 | 3 个(ChatResponse / ToolDefinition / task_agent_demo 中旧类型使用) |
|
||||
| 已有示例 | 8 个 |
|
||||
| `StepStatus::Completed` 使用类型 | `ChatResponse`(已 `#[deprecated]`) |
|
||||
|
||||
### 2.1 关键技术债
|
||||
|
||||
```
|
||||
// agent/task.rs —— StepStatus 当前使用已废弃类型
|
||||
#[allow(deprecated)]
|
||||
pub enum StepStatus {
|
||||
Completed(ChatResponse), // ← ChatResponse 已在 0.1.0 标记 #[deprecated]
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`task_agent_demo.rs` 中同时使用了 `ChatResponse` / `OpenaiChatMessage` / `FinishReason` 三个废弃类型,入口处有 `#![allow(deprecated)]`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 实施方案
|
||||
|
||||
### 3.1 Step 8.1 — API 稳定性扫尾
|
||||
|
||||
拆为 4 个增量 commit:
|
||||
|
||||
| Commit | 内容 | 涉及文件 |
|
||||
|--------|------|---------|
|
||||
| **commit 1** | 14 个公开枚举追加 `#[non_exhaustive]` | 各枚举定义文件(详见 §3.1.1) |
|
||||
| **commit 2** | `StepStatus::Completed(ChatResponse)` → `Completed(MessageResponse)` + `task_agent_demo.rs` 清理全部 3 个废弃类型(`ChatResponse` / `OpenaiChatMessage` / `FinishReason`),移除 `#![allow(deprecated)]` | `src/agent/task.rs`、`examples/task_agent_demo.rs` |
|
||||
| **commit 3** | CHANGELOG v0.2 条目 + Cargo.toml version → `0.2.0-rc.1` + README 更新 | `CHANGELOG.md`、`Cargo.toml`、`README.md` |
|
||||
| **commit 4** | 验证:`cargo test + clippy + cargo doc` 零告警 | 无代码改动 |
|
||||
|
||||
#### 3.1.1 `#[non_exhaustive]` 追加清单(14 个枚举)
|
||||
|
||||
按优先级分级:
|
||||
|
||||
| 优先级 | 枚举 | 模块路径 | 理由 |
|
||||
|--------|------|---------|------|
|
||||
| **P0 核心** | `Message` | `llm/types/message.rs` | 核心 IR 类型,未来可能新增变体(MultiModal 扩展) |
|
||||
| | `ContentBlock` | `llm/types/message.rs` | 同上 |
|
||||
| | `ContentBlockType` | `llm/types/message.rs` | 同上 |
|
||||
| | `StreamEvent` | `llm/types/response_v2.rs` | 流式事件集,Provider 扩展可能新增事件 |
|
||||
| | `HookEvent` | `llm/hooks.rs` | 生命周期钩子,框架扩展需要新增事件点 |
|
||||
| **P0 Error** | `AgentError` | `agent/error.rs` | 顶层错误,下游 match 需保护 |
|
||||
| | `LlmError` | `llm/error.rs` | LLM 调用错误 |
|
||||
| | `ToolError` | `tools/error.rs` | 工具系统错误 |
|
||||
| | `MemoryError` | `memory/error.rs` | 记忆系统错误 |
|
||||
| | `PromptError` | `prompt/error.rs` | 提示词工程错误 |
|
||||
| **P1 其他** | `MemoryStrategy` | `memory/conversation.rs` | 对话策略,未来可扩展(如 Summarize) |
|
||||
| | `StepStatus` | `agent/task.rs` | 步骤状态机,可扩展(如 Cancelled) |
|
||||
| | `ToolChoice` | `llm/types/request.rs` | Provider 工具选择策略 |
|
||||
| | `ResponseFormat` | `llm/types/shared.rs` | 响应格式枚举 |
|
||||
|
||||
**明确不加的**:
|
||||
|
||||
| 类别 | 枚举 | 原因 |
|
||||
|------|------|------|
|
||||
| 内部 wire-format | `OpenaiChatMessage` / `OpenaiTool` / `OpenaiToolCall` / `ContentField` / `OpenaiContentPart` / `LegacyStreamEvent` | 内部转换层,不构成公共 API 契约 |
|
||||
| 语义稳定 | `Role` / `ServiceTier` / `Modality` / `ImageDetail` / `AudioFormat` / `StopSequence` | 语义已收敛,协议层无新增变体预期 |
|
||||
| 使用面窄 | `TemplateValue` / `Permission` / `McpTransport` / `ContentBlockBuilder` / `ExtraError` | 内部实现细节或使用频率极低,下游不直接 match |
|
||||
|
||||
> **`#[non_exhaustive]` 的不可逆性**:一旦 v0.2.0-rc.1 发布,以下游代码可能依赖 `_ =>` 通配分支。在 v0.3+ 中移除 `#[non_exhaustive]` 将构成 semver breaking change(新增变体不再触发编译警告,下游 match 可能遗漏新变体),因此当前追加的标记应视为永久 API 契约。
|
||||
|
||||
#### 3.1.2 StepStatus 迁移细节
|
||||
|
||||
```rust
|
||||
// 变更前
|
||||
#[allow(deprecated)]
|
||||
pub enum StepStatus {
|
||||
Completed(ChatResponse), // ChatResponse 已 #[deprecated]
|
||||
...
|
||||
}
|
||||
|
||||
// 变更后
|
||||
#[non_exhaustive]
|
||||
pub enum StepStatus {
|
||||
Completed(MessageResponse),
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**字段映射差异**:`MessageResponse` 不是 `ChatResponse` 的简单改名——两者结构不同,迁移需要做字段适配:
|
||||
|
||||
| ChatResponse 字段 | 类型 | MessageResponse 字段 | 类型 | 映射方式 |
|
||||
|-------------------|------|---------------------|------|---------|
|
||||
| `message` | `OpenaiChatMessage` | `message` | `Message` | 类型替换:`OpenaiChatMessage::assistant_text(t)` → `Message::Assistant { content: vec![ContentBlock::Text { text: t.into() }] }` |
|
||||
| `usage` | `Usage` | `usage` | `Usage` | ✅ 同类型,直接迁移 |
|
||||
| `stop_reason` | `Option<FinishReason>` | `stop_reason` | `StopReason` | 类型替换:`Some(FinishReason::Stop)` → `StopReason::Stop`;无 Option 包裹 |
|
||||
| — | — | `id` | `String` | 新增必填字段,使用空字符串 `""` 占位 |
|
||||
| — | — | `model` | `String` | 新增必填字段,使用 `"mock"` 或空字符串占位 |
|
||||
| — | — | `extra` | `HashMap<String, Value>` | 新增字段,使用 `HashMap::new()` 占位 |
|
||||
|
||||
**迁移示例**(`task_agent_demo.rs` 的构造代码):
|
||||
|
||||
```rust
|
||||
// 旧代码(3 个废弃类型)
|
||||
StepStatus::Completed(ChatResponse {
|
||||
message: OpenaiChatMessage::assistant_text("天气:晴,22°C"),
|
||||
usage: Usage::from_input_output(10, 5),
|
||||
stop_reason: Some(FinishReason::Stop),
|
||||
})
|
||||
|
||||
// 新代码(纯 MessageResponse)
|
||||
StepStatus::Completed(MessageResponse {
|
||||
id: String::new(),
|
||||
model: "mock".into(),
|
||||
message: Message::assistant("天气:晴,22°C"),
|
||||
usage: Usage::from_input_output(10, 5),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: HashMap::new(),
|
||||
})
|
||||
```
|
||||
|
||||
涉及文件:
|
||||
- `src/agent/task.rs`:枚举定义 + `#[allow(deprecated)]` 移除 + `#[non_exhaustive]` 追加
|
||||
- `examples/task_agent_demo.rs`:`ChatResponse{...}` → `MessageResponse{...}` 构造替换,同时替换 `OpenaiChatMessage` / `FinishReason` 引用,移除 `#![allow(deprecated)]`
|
||||
|
||||
### 3.2 Step 8.2 — Quick Start 示例
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 文件 | `examples/quick_start.rs` |
|
||||
| 规模 | ~36 行 |
|
||||
| Provider | `MockProvider`(FIFO 单响应队列) |
|
||||
| 工具 | `EchoTool`(回传 `"收到: {input}"`,完整 JSON Schema 参数声明) |
|
||||
| 执行 | `submit_turn("你好")` → 验证输出包含 `"收到"` |
|
||||
| 验证 | `cargo run --example quick_start` exit 0 |
|
||||
|
||||
设计要点:
|
||||
- 展示四层抽象:Agent trait / BaseTool 自定义 / AgentBuilder 装配 / AgentSession 执行
|
||||
- 无外部依赖、无 API key、零配置
|
||||
|
||||
### 3.3 Step 8.3 — 端到端示例
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 文件 | `examples/end_to_end.rs` |
|
||||
| 规模 | ~160 行(**最小可行边界**:3 工具 + 3 轮 + 持久化验证,防止实施中进一步膨胀) |
|
||||
| Provider | 自动检测 `AG_LLM_*` → `from_env()`,fallback 到 `MockProvider` |
|
||||
| 工具组合 | EchoTool(回显)+ CalcTool(四则运算,本地执行)+ NoteTool(笔记,通过 MemoryStore trait 操作 SessionMemory) |
|
||||
| 持久化 | `tempfile::TempDir` + `SqliteStore`,drop 后重建连接验证数据不丢 |
|
||||
| 对话 | 3 轮:计算 → 记笔记 → 回忆 |
|
||||
| 验证 | `cargo run --example end_to_end` exit 0(无需任何外部配置) |
|
||||
|
||||
**真实 Provider 切换**:示例在文件顶部注释中说明 "设置 `AG_LLM_BASE_URL` / `AG_LLM_API_KEY` / `AG_LLM_MODEL` 环境变量即可使用真实 LLM Provider(支持 OpenAI / Ollama 等);未设置时自动降级为 MockProvider,零配置可运行。"
|
||||
|
||||
**`from_env()` 部分环境变量策略**:`from_env()` 要求完整的三件套(`{prefix}_BASE_URL` + `{prefix}_API_KEY` + `{prefix}_MODEL`)。当环境变量部分设置时,示例**整体降级到 MockProvider**——不在"半配置"状态下尝试部分初始化。日志输出形如 `"AG_LLM_* 环境变量不完整(检测到: {found_vars}),回退到 MockProvider"`。
|
||||
|
||||
架构亮点:
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ AgentSession │
|
||||
│ (submit_turn × 3) │
|
||||
└────┬──────┬──────┬──────┘
|
||||
│ │ │
|
||||
┌────┘ │ └──────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌────────┐ ┌──────────┐
|
||||
│ EchoTool │ │CalcTool│ │ NoteTool │
|
||||
│ (回显) │ │(四则) │ │ (记忆) │
|
||||
└──────────┘ └────────┘ └────┬─────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ SessionMemory│
|
||||
│ (MemoryStore)│
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ SqliteStore │
|
||||
│ (temp dir) │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
NoteTool 展示 `MemoryStore` trait 解耦能力:不绑定 SqliteStore,上层 `AgentSession` 通过 `SessionMemory` 操作,底层可互换。
|
||||
|
||||
---
|
||||
|
||||
## 4. 否决项记录
|
||||
|
||||
| 否决方案 | 否决原因 |
|
||||
|---------|---------|
|
||||
| `#[non_exhaustive]` 仅加 5 个核心类型 | 全面覆盖 Error enums 为零运行时成本,对下游更友好。Error 枚举是下游 match 最密集的地方,漏标会在 v0.3 引入 breakage |
|
||||
| StepStatus::Completed 留到 v0.3 再修 | rc.1 前清理 deprecated 类型污染最划算——越晚 migration cost 越高,且当前仅 1 个示例 + 1 个测试引用 |
|
||||
| Quick Start 纯文本路线(不展示自定义工具) | 含 EchoTool 展示核心差异化,仅多 5 行代码但传递了"可以自定义工具"的关键信息 |
|
||||
| 端到端仅 Echo + Calc(无 NoteTool) | NoteTool 展示 MemoryStore trait 解耦能力是架构亮点,跳过后新用户无法理解 memory 如何集成到 Agent 流程 |
|
||||
| 持久化仅注释说明不实际运行(方案 Y) | 进程内实操验证(create → drop → reopen → assert)比注释更有说服力,增加约 15 行代码 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键假设
|
||||
|
||||
1. **MockProvider FIFO 队列满足 auto-tool-loop 消费顺序**:MockProvider 的 `pop()` 按预设顺序弹出。当 LLM 返回多个 tool call 时队列消费顺序与预设一致,无需额外同步
|
||||
2. **StepStatus 切换需做字段适配**:`ChatResponse`(3 字段) 到 `MessageResponse`(6 字段) 存在字段类型差异(`message` 类型不同、`stop_reason` 类型 + Option 有无不同、`id`/`model`/`extra` 为新增必填字段),消费者需按字段映射表提供占位值。但消费者仅 1 个(`task_agent_demo.rs`)+ 1 个内联测试,手动适配工作量极小。`StepStatus` 的 `is_terminal()` / `is_pending()` 行为不受影响
|
||||
3. **所有 10 个示例零外部配置 exit 0**:已有 8 个示例已验证,新增 2 个(quick_start + end_to_end)均使用 MockProvider fallback,无需 API key
|
||||
4. **`#[non_exhaustive]` × 14 不触发额外 clippy warning**:当前无代码对以上枚举做 exhaustive match(不含 `_`),追加 `#[non_exhaustive]` 是纯安全标记
|
||||
|
||||
---
|
||||
|
||||
## 6. 实施顺序与验证标准
|
||||
|
||||
### 6.1 提交顺序
|
||||
|
||||
```
|
||||
Step 8.1 (4 commits)
|
||||
→ commit 1: #[non_exhaustive] × 14
|
||||
→ commit 2: StepStatus 修复(Completed(ChatResponse) → Completed(MessageResponse))
|
||||
→ commit 3: CHANGELOG v0.2 + Cargo.toml version 0.2.0-rc.1 + README 更新
|
||||
→ commit 4: 验证(test / clippy / doc 零告警)
|
||||
|
||||
Step 8.2
|
||||
→ commit 5: examples/quick_start.rs(~36 行)
|
||||
|
||||
Step 8.3
|
||||
→ commit 6: examples/end_to_end.rs(~160 行)
|
||||
|
||||
最终验证
|
||||
→ cargo test --all-targets
|
||||
→ cargo clippy --all-targets -- -D warnings
|
||||
→ cargo doc --no-deps
|
||||
→ git tag v0.2.0-rc.1
|
||||
```
|
||||
|
||||
### 6.2 验收标准
|
||||
|
||||
| 指标 | 要求 |
|
||||
|------|------|
|
||||
| `cargo test --all-targets` | 全绿 |
|
||||
| `cargo clippy --all-targets -- -D warnings` | 0 警告 |
|
||||
| `cargo doc --no-deps` | 0 warning |
|
||||
| 所有 10 个示例 | `cargo run --example <name>` exit 0 |
|
||||
| Cargo.toml version | `0.2.0-rc.1` |
|
||||
| CHANGELOG | v0.2 条目完整(Added / Changed / Deprecated / Fixed / Removed 各节) |
|
||||
| README | 示例列表 + 版本号更新 |
|
||||
| git tag | `v0.2.0-rc.1` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 参考来源
|
||||
|
||||
- **roadmap.md** — Phase 8 原始定义(Step 8.1/8.2/8.3)、依赖关系(Phase 5/6/7 → Phase 8)
|
||||
- **`src/agent/task.rs`** — `StepStatus` 当前实现,`Completed(ChatResponse)` 类型
|
||||
- **`src/llm/types/message.rs`** — `Message` / `ContentBlock` / `ContentBlockType` 枚举定义
|
||||
- **`src/llm/types/response_v2.rs`** — `StreamEvent` / `StopReason` 枚举定义(StopReason 已有 `#[non_exhaustive]`)
|
||||
- **`src/llm/types/shared.rs`** — `ResponseFormat` / `Role` / `FinishReason` 等枚举(FinishReason 已有 `#[non_exhaustive]`)
|
||||
- **`src/llm/types/request.rs`** — `ToolChoice` 枚举定义
|
||||
- **`src/llm/hooks.rs`** — `HookEvent` 枚举定义
|
||||
- **`src/llm/error.rs`** — `LlmError` 枚举定义
|
||||
- **`src/agent/error.rs`** — `AgentError` 枚举定义
|
||||
- **`src/tools/error.rs`** — `ToolError` 枚举定义
|
||||
- **`src/memory/error.rs`** — `MemoryError` 枚举定义
|
||||
- **`src/memory/conversation.rs`** — `MemoryStrategy` 枚举定义
|
||||
- **`src/prompt/error.rs`** — `PromptError` 枚举定义
|
||||
- **`examples/task_agent_demo.rs`** — 当前使用 `#[allow(deprecated)]` + `ChatResponse` 的示例
|
||||
|
||||
---
|
||||
|
||||
## 8. 实施计划
|
||||
|
||||
### 8.1 实施步骤
|
||||
|
||||
#### Step 8.1 — API 稳定性扫尾
|
||||
|
||||
拆为 4 个增量 commit,依次提交。
|
||||
|
||||
##### commit 1: #[non_exhaustive] × 14
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 涉及文件 | 14 个枚举定义所在文件(见下方清单) |
|
||||
| 前置依赖 | 无 |
|
||||
| 预估工作量 | S(<1h) |
|
||||
| 风险等级 | 低 |
|
||||
|
||||
在每个目标枚举定义处的 `pub enum` 之前加一行 `#[non_exhaustive]`,纯文本属性追加,无逻辑变更。
|
||||
|
||||
| 目标枚举 | 文件路径 | 行号附近 |
|
||||
|---------|---------|---------|
|
||||
| `Message` | `src/llm/types/message.rs` | `pub enum Message` (L22) |
|
||||
| `ContentBlock` | `src/llm/types/message.rs` | `pub enum ContentBlock` (L99) |
|
||||
| `ContentBlockType` | `src/llm/types/message.rs` | `pub enum ContentBlockType` (L134) |
|
||||
| `StreamEvent` | `src/llm/types/response_v2.rs` | `pub enum StreamEvent` (L167) |
|
||||
| `HookEvent` | `src/llm/hooks.rs` | `pub enum HookEvent` (L9) |
|
||||
| `AgentError` | `src/agent/error.rs` | `pub enum AgentError` (L20) |
|
||||
| `LlmError` | `src/llm/error.rs` | `pub enum LlmError` (L10) |
|
||||
| `ToolError` | `src/tools/error.rs` | `pub enum ToolError` (L6) |
|
||||
| `MemoryError` | `src/memory/error.rs` | `pub enum MemoryError` (L8) |
|
||||
| `PromptError` | `src/prompt/error.rs` | `pub enum PromptError` (L3) |
|
||||
| `MemoryStrategy` | `src/memory/conversation.rs` | `pub enum MemoryStrategy` (L14) |
|
||||
| `StepStatus` | `src/agent/task.rs` | `pub enum StepStatus` (L59) |
|
||||
| `ToolChoice` | `src/llm/types/request.rs` | `pub enum ToolChoice` (L14) |
|
||||
| `ResponseFormat` | `src/llm/types/shared.rs` | `pub enum ResponseFormat` (L70) |
|
||||
|
||||
> **注意**:`StepStatus` 在 commit 2 中会同时被修改(variant 类型替换 + 移除 `#[allow(deprecated)]`)。commit 1 仅追加 `#[non_exhaustive]` 属性,commit 2 再处理变体变更和清理。
|
||||
|
||||
**验收条件**:`cargo build --all-targets` 通过
|
||||
|
||||
##### commit 2: StepStatus 修复 + 废弃类型清理
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 涉及文件 | `src/agent/task.rs`,`examples/task_agent_demo.rs` |
|
||||
| 前置依赖 | commit 1(StepStatus 先标记 `#[non_exhaustive]`,此处改 variant 时一并保留,无实际冲突) |
|
||||
| 预估工作量 | S(<1h,约 20 行改动) |
|
||||
| 风险等级 | 低 |
|
||||
|
||||
两步操作:
|
||||
|
||||
1. **`src/agent/task.rs`**(L59-L71):
|
||||
- `StepStatus::Completed(ChatResponse)` → `Completed(MessageResponse)`
|
||||
- 移除 `#[allow(deprecated)]`(第 13、59 行两处)
|
||||
|
||||
2. **`examples/task_agent_demo.rs`**:
|
||||
- 替换 3 个废弃类型:`ChatResponse` → `MessageResponse`,`OpenaiChatMessage::assistant_text(t)` → `Message::assistant(t)`,`FinishReason::Stop` → `StopReason::Stop`
|
||||
- 补充 `id: String::new()`,`model: "mock".into()`,`extra: HashMap::new()` 占位字段
|
||||
- 移除 `#![allow(deprecated)]`(第 26 行)
|
||||
- 移除 `use` 中的 `ChatResponse`、`OpenaiChatMessage`、`FinishReason`
|
||||
- 添加 `use std::collections::HashMap`,`use agcore::llm::types::{Message, MessageResponse, StopReason}`(注意:`Message::assistant_text(t)` 不存在,需使用 `Message::assistant(t)`)
|
||||
|
||||
字段映射参见 §3.1.2 的字段映射表和迁移示例。
|
||||
|
||||
**验收条件**:`cargo build --all-targets` 通过,零 deprecated warning
|
||||
|
||||
##### commit 3: CHANGELOG + 版本号 + README
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 涉及文件 | `CHANGELOG.md`,`Cargo.toml`,`README.md` |
|
||||
| 前置依赖 | commit 1+2(CHANGELOG 需记录实际变更) |
|
||||
| 预估工作量 | S(<1h) |
|
||||
| 风险等级 | 低 |
|
||||
|
||||
1. **`CHANGELOG.md`**:新增 `[0.2.0-rc.1]` 条目,包含:
|
||||
- **Added**:SqliteStore 持久化 / OllamaProvider / ProviderConfig::from_env / ToolDef IR / Quick Start 和 end_to_end 示例
|
||||
- **Changed**:MessageRequest.tools 切换 ToolDef / StepStatus::Completed 类型替换
|
||||
- **Deprecated**:ChatResponse / with_system_prompt() / with_client()
|
||||
- **Non-exhaustive**:14 个枚举标记清单
|
||||
|
||||
2. **`Cargo.toml`**:第 3 行 `version = "0.1.0"` → `version = "0.2.0-rc.1"`
|
||||
|
||||
3. **`README.md`**:更新示例列表从 7 个改为 10 个(含新增 2 个),版本号同步
|
||||
|
||||
**验收条件**:人工 review CHANGELOG + `git diff` 确认版本号
|
||||
|
||||
##### commit 4: 验证
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 涉及文件 | 无代码改动 |
|
||||
| 前置依赖 | commit 3 |
|
||||
| 预估工作量 | S(<1h,主要等待编译) |
|
||||
| 风险等级 | 低 |
|
||||
|
||||
运行三条命令:
|
||||
|
||||
```bash
|
||||
cargo test --all-targets
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo doc --no-deps 2>&1 | grep "^warning:" && echo "WARNINGS FOUND" || echo "0 warnings"
|
||||
```
|
||||
|
||||
**验收条件**:前两条 0 错误,第三条输出 `0 warnings`
|
||||
|
||||
#### Step 8.2 — Quick Start 示例
|
||||
|
||||
##### commit 5: examples/quick_start.rs
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 涉及文件 | `examples/quick_start.rs` |
|
||||
| 前置依赖 | 无(可从 Phase 7 独立创建) |
|
||||
| 预估工作量 | S(<1h) |
|
||||
| 风险等级 | 低 |
|
||||
|
||||
新文件 `examples/quick_start.rs`,~36 行,结构如下:
|
||||
|
||||
```
|
||||
1- 6 use 块(agcore 类型 + Arrow/std 类型)
|
||||
7- 8 struct Greeter + impl Agent(name / system_prompt)
|
||||
9-14 struct EchoTool + #[async_trait] impl BaseTool(完整 JSON Schema 带 text 参数)
|
||||
15-20 fn mock_response() -> MessageResponse 辅助函数(构造纯文本响应)
|
||||
21-33 #[tokio::main] async fn main():
|
||||
- ToolRegistry::new() + register EchoTool
|
||||
- MockProvider 预设 1 条 mock_response
|
||||
- AgentBuilder::new() + provider + tool_registry + hook_executor → build
|
||||
- AgentSession::new + submit_turn("你好")
|
||||
- println!("{}", response.text())
|
||||
```
|
||||
|
||||
**设计约束**:
|
||||
- EchoTool 的 `parameters()` 返回完整 JSON Schema:`{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}`
|
||||
- 无外部依赖、无 API key、零配置
|
||||
- 展示四层抽象:Agent trait / BaseTool 自定义 / AgentBuilder 装配 / AgentSession 执行
|
||||
|
||||
**验收条件**:`cargo run --example quick_start` exit 0,输出包含 `"收到"`
|
||||
|
||||
#### Step 8.3 — 端到端示例
|
||||
|
||||
##### commit 6: examples/end_to_end.rs
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 涉及文件 | `examples/end_to_end.rs` |
|
||||
| 前置依赖 | commit 5(示例编写模式已建立);SqliteStore(Phase 7 已完成) |
|
||||
| 预估工作量 | M(1-4h) |
|
||||
| 风险等级 | 中 |
|
||||
|
||||
新文件 `examples/end_to_end.rs`,~160 行,最小可行边界(3 工具 + 3 轮 + 持久化验证)。
|
||||
|
||||
**Provider 初始化策略**:
|
||||
|
||||
```
|
||||
if env::var("AG_LLM_BASE_URL").is_ok() && env::var("AG_LLM_API_KEY").is_ok() {
|
||||
// 使用真实 Provider(AG_LLM_MODEL 非必填,from_env 内部会处理默认值)
|
||||
let provider: Arc<dyn LlmProvider> = Arc::from(create_provider(
|
||||
ProviderType::OpenaiChat, ProviderConfig::from_env("AG_LLM").unwrap()
|
||||
)?);
|
||||
} else {
|
||||
// MockProvider fallback,预设 4 条响应序列
|
||||
let found = ["AG_LLM_BASE_URL", "AG_LLM_API_KEY"].iter()
|
||||
.filter(|k| env::var(k).is_ok()).collect::<Vec<_>>();
|
||||
eprintln!("AG_LLM_* 环境变量不完整(检测到: {:?}),回退到 MockProvider", found);
|
||||
}
|
||||
```
|
||||
|
||||
**工具定义**:
|
||||
|
||||
| 工具 | 功能 | 关键技术点 |
|
||||
|------|------|-----------|
|
||||
| `EchoTool` | 回显输入 | 基础工具注册模式 |
|
||||
| `CalcTool` | 本地执行四则运算 | 手动解析算术表达式(ponytail:基础 +-*/ 运算无需引入 `rhai` 依赖) |
|
||||
| `NoteTool` | 通过 MemoryStore trait 读写笔记 | 直接持有 `Arc<dyn MemoryStore>`,key 前缀 `"note:"`;save 用 `MemoryStore::save(MemoryItem { id: "note:{key}", content, .. })`,query 用 `MemoryStore::list(MemoryFilter { prefix: Some("note:"), .. })` |
|
||||
|
||||
**持久化验证**:
|
||||
|
||||
```rust
|
||||
let dir = tempfile::TempDir::new()?;
|
||||
let db_path = dir.path().join("agcore.db");
|
||||
let backend = Arc::new(SqliteStore::open(&db_path)?);
|
||||
// ... 构建 RuntimeBundle + AgentSession,写入数据 ...
|
||||
drop(bundle); // 释放所有对 backend 的 Arc 引用
|
||||
drop(session);
|
||||
// 此时 backend 无活跃引用,SQLite 连接自动关闭
|
||||
let backend2 = Arc::new(SqliteStore::open(&db_path)?); // 重建连接
|
||||
// assert 数据仍在
|
||||
```
|
||||
|
||||
**输出示范**:
|
||||
|
||||
```
|
||||
=== agcore 端到端演示 ===
|
||||
🔄 Provider: MockProvider (离线回退模式)
|
||||
💾 SqliteStore: /tmp/agcore_XXXXX/agcore.db
|
||||
🔧 注册工具: echo, calc, note
|
||||
|
||||
第 1 轮 用户: 帮我算 25 * 4
|
||||
→ 调用 calc(...) → 100
|
||||
→ 回答: 25 * 4 = 100
|
||||
|
||||
第 2 轮 用户: 记下来:结果是 100
|
||||
→ 调用 note(save, ...)
|
||||
→ 回答: 已记录
|
||||
|
||||
第 3 轮 用户: 我刚才算了什么?
|
||||
→ 调用 note(query)
|
||||
→ 回答: 您刚才的计算结果是 100
|
||||
|
||||
📊 用量: prompt=XX, completion=XX
|
||||
|
||||
=== 持久化验证 ===
|
||||
✓ 跨连接数据存活验证通过
|
||||
|
||||
✓ 端到端演示完成
|
||||
```
|
||||
|
||||
**设计约束**:
|
||||
- 文件顶部注释说明 `AG_LLM_*` 环境变量切换真实 Provider
|
||||
- 零外部配置可运行(Mock fallback)
|
||||
- 最小可行边界:3 工具 + 3 轮 + 持久化验证,不膨胀
|
||||
|
||||
**验收条件**:`cargo run --example end_to_end` exit 0(零外部配置)
|
||||
|
||||
### 8.2 并行机会
|
||||
|
||||
commit 1 和 commit 5 可以并行执行(零文件重叠)。commit 5 也可与 commit 2 并行。commit 6 实质上也仅依赖「代码库状态稳定」而非某个具体 commit。
|
||||
|
||||
| 并行组 | commit A | commit B | 前提 |
|
||||
|--------|---------|---------|------|
|
||||
| 1 | commit 1(#[non_exhaustive]) | commit 5(Quick Start) | 零文件重叠 |
|
||||
| 2 | commit 2(StepStatus 修复) | commit 5(Quick Start) | 零文件重叠 |
|
||||
| 3 | commit 5(Quick Start) | commit 6(端到端) | 零文件重叠,但存在知识依赖——commit 6 需参考 commit 5 的 `MessageResponse` 构造、`MockProvider` 用法、`AgentBuilder` 装配模式。推荐 commit 5 先行或实施前同步这些模式 |
|
||||
|
||||
### 8.3 风险与应对
|
||||
|
||||
| 风险 | 影响 | 可能性 | 应对 |
|
||||
|------|------|--------|------|
|
||||
| MockProvider 响应序列与 tool-loop 消费顺序不匹配 | commit 6 端到端示例不通过 | 中 | 按 §5 假设 1:设计响应队列时确保每条 Mock 响应的 `stop_reason` 与 ToolUse/Stop 匹配。出现不匹配时改用完整 `MessageResponse` 构造显式控制 |
|
||||
| NoteTool 与 AgentSession 的数据传递路径需要扩展现有 API | commit 6 需要修改 `session.rs` | 低 | ponytail 方案:NoteTool 直接持有 `Arc<dyn MemoryStore>` 引用,在 execute 时直接操作 `MemoryStore::save/get`,绕过 AgentSession 的 session_memory 封装 |
|
||||
| `#[non_exhaustive]` 在某个 enum 上导致 crate 内 match 编译失败 | commit 1 不通过 | 低 | 实施前先运行 `rg "match.*(Message|ContentBlock|ContentBlockType|StreamEvent|HookEvent|AgentError|LlmError|ToolError|MemoryError|PromptError|MemoryStrategy|StepStatus|ToolChoice|ResponseFormat)" src/ --include="*.rs"` 快速扫描 exhaustive match。若某 enum 编译失败,回退该 enum 上的 `#[non_exhaustive]` 属性,标注原因 |
|
||||
|
||||
### 8.4 测试策略
|
||||
|
||||
| commit | 测试 | 方式 |
|
||||
|--------|------|------|
|
||||
| commit 1 | 编译测试 | `cargo build --all-targets` |
|
||||
| commit 2 | 编译 + 单测 + 无 deprecated warning | `cargo build --all-targets && cargo test` |
|
||||
| commit 3 | 人工 review | `git diff` |
|
||||
| commit 4 | 全量自动化 | `cargo test + clippy + doc` |
|
||||
| commit 5 | 示例运行 | `cargo run --example quick_start` |
|
||||
| commit 6 | 示例运行 | `cargo run --example end_to_end` |
|
||||
| 最终 | 全量回归 | 全部三项 + 所有 10 个示例 |
|
||||
@@ -0,0 +1,827 @@
|
||||
# Phase 9 — 流式体验增强实施方案
|
||||
|
||||
- **文档编号**:16
|
||||
- **标题**:Phase 9 — 流式体验增强实施方案
|
||||
- **日期**:2026-07-05
|
||||
- **状态**:已定稿
|
||||
- **涉及模块**:llm/cycle、llm/types/response_v2、agent/session
|
||||
- **关联文档**:roadmap.md(§Phase 9)、15-phase8-mvp-integration.md
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
agcore 已发布 v0.2.0-rc.1,Phase 0-8 全部完成。当前 Agent 会话只有非流式 API(`submit_turn`),开发者无法看到实时 token 输出和工具执行过程。Phase 9 的目标是为 `AgentSession` 新增流式方法 `submit_turn_stream`,让开发者能实时看到 LLM token 生成和工具执行状态。
|
||||
|
||||
### 1.1 现有能力
|
||||
|
||||
| 能力 | 方法 | 流式 | 自动工具循环 | 状态 |
|
||||
|------|------|------|-------------|------|
|
||||
| LLM 流式请求 | `LlmCycle::submit_stream` | ✅ | ❌ | 已就绪 |
|
||||
| LLM 工具循环 | `LlmCycle::submit_with_tools` | ❌ | ✅ | 已就绪 |
|
||||
| Agent 会话 | `AgentSession::submit_turn` | ❌ | ✅ | 已就绪 |
|
||||
| 流事件枚举 | `StreamEvent`(11 变体) | — | — | 缺工具执行事件 |
|
||||
| Mock 流 | `MockProvider::chat_stream` | ✅ | — | 可模拟流事件序列 |
|
||||
|
||||
### 1.2 核心矛盾
|
||||
|
||||
流式能力和工具循环能力分别存在于两个方法中,从未被组合。`submit_stream` 只管将 LLM 流事件原样转发,不理解工具调用;`submit_with_tools` 自动执行工具循环但全程阻塞。Phase 9 就是要组合它们:**在工具循环中,每一轮 LLM 调用都是流式的,并在工具执行前后插入语义事件**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 需求分析
|
||||
|
||||
### 2.1 功能需求
|
||||
|
||||
1. **`AgentSession::submit_turn_stream(user_input)`** — 返回 `StreamEvent` 流,开发者通过 `while let Some(event) = stream.next().await` 逐事件消费
|
||||
2. **流式工具循环** — 多轮工具调用过程中流不卡死,每轮工具执行前后插入 `ToolExecutionStarted` / `ToolExecutionCompleted` 事件
|
||||
3. **`finalize_turn(response)`** — 流消费完成后同步 session 状态(cost 累计 + `OnTurnEnd` hook 触发)
|
||||
4. **新增 `StreamEvent` 变体** — `ToolExecutionStarted` + `ToolExecutionCompleted`,携带工具名称、调用 ID、参数/结果摘要
|
||||
|
||||
### 2.2 非功能需求
|
||||
|
||||
- **零影响**:现有 `submit_turn` 和 `submit_with_tools` 行为不变,存量测试 0 回归
|
||||
- **异步流**:消费者通过 `futures_util::StreamExt::next()` 逐事件消费
|
||||
- **错误事件化**:错误通过 `StreamEvent::Error` 事件表达,不通过 `Result` 通道终止流
|
||||
- **最少代码**:复用现有 `submit_with_tools` 的工具循环逻辑模式和 `submit_stream` 的流管道模式
|
||||
|
||||
### 2.3 不做事项
|
||||
|
||||
| 事项 | 理由 |
|
||||
|------|------|
|
||||
| 新增示例(Phase 9.2 再加) | 缩窄 Phase 9 范围至核心能力 |
|
||||
| `OnTurnEnd` 自动触发 | Rust 所有权约束:流是延迟求值,`&mut self` 无法进入闭包;由消费者收到 `MessageComplete` 后手动调用 `finalize_turn` |
|
||||
| 修复 cost 统计 | 中间轮 cost 丢失是已知限制,与 `submit_turn` 行为一致 |
|
||||
| 跨 turn 消息历史保留 | Phase 10 `ContextSlot` 的职责 |
|
||||
| 并行 tool 调用的事件细化 | 当前工具调用是顺序 `for` 循环,并行化留待后续优化 |
|
||||
| `run_tool_loop` 内消息压缩 | `run_tool_loop` 不接收 `compact_config` 参数,不执行上下文压缩。长工具循环中消息增长可能导致 context window 溢出,这是流式实现的已知限制。后续可通过传递 `compact_config` 给 `run_tool_loop` 支持 |
|
||||
| LLM 请求自动 retry | 流式版本不在 `run_tool_loop` 内部实现 retry(详见 §3.6 说明)。调用方可自行包装 `RetryProvider` 或在 `LlmProvider` 实现层处理 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 方案设计
|
||||
|
||||
### 3.1 架构总览
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ AgentSession │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ submit_turn_stream() │ │
|
||||
│ │ ├─ OnTurnStart hook(同步触发,返回流之前) │ │
|
||||
│ │ ├─ 组装 LlmCycle(system_prompt / compact_config) │ │
|
||||
│ │ ├─ 调用 submit_with_tools_stream() │ │
|
||||
│ │ ├─ turn_index += 1 │ │
|
||||
│ │ └─ 返回流 │ │
|
||||
│ │ │ │
|
||||
│ │ finalize_turn(response) │ │
|
||||
│ │ ├─ cost_so_far.add(&response.usage) │ │
|
||||
│ │ └─ OnTurnEnd hook(turn_index - 1) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ submit_with_tools_stream(prompt, Arc<ToolRegistry>)
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ LlmCycle (tokio::spawn task — run_tool_loop 状态机) │
|
||||
│ │
|
||||
│ max_turns = max_tool_turns.unwrap_or(10) │
|
||||
│ for round in 1..=max_turns { │
|
||||
│ ① build_request(messages, tools) │
|
||||
│ ② provider.chat_stream(request).await │
|
||||
│ 匹配 Err → tx.send(Error{..}) + return(不 panic) │
|
||||
│ ③ 消费 LLM 流,所有事件 → mpsc unbounded tx(全量转发) │
|
||||
│ ④ partial.finalize() → MessageResponse │
|
||||
│ ⑤ if has_tool_use: │
|
||||
│ ├─ tx → ToolExecutionStarted { tool_name, id, args } │
|
||||
│ ├─ registry.invoke_all(calls, timeout).await │
|
||||
│ ├─ for result: tx → ToolExecutionCompleted { ... } │
|
||||
│ ├─ push tool results → messages │
|
||||
│ └─ continue(新一轮) │
|
||||
│ else: break(最终轮,已发出 MessageComplete) │
|
||||
│ } │
|
||||
│ │
|
||||
│ 产出事件序列(通过 mpsc::unbounded_channel): │
|
||||
│ MessageStart → ... → ToolCallEnd → ToolExecutionStarted → │
|
||||
│ ToolExecutionCompleted → MessageStart → TextDelta → ... → │
|
||||
│ CostUpdate → MessageComplete │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
> **`max_tool_turns` 语义**:与非流式 `submit_with_tools` 一致——`None` 退化为 `10`(`unwrap_or(10)`)。默认值 `Some(10)` 已提供安全上限;如需增大限制,手动设置为 `Some(N)`。⚠️ 生产环境建议始终设有限值防止无限循环。
|
||||
|
||||
### 3.2 事件序列约定
|
||||
|
||||
**纯文本流**(无 tool_use):
|
||||
|
||||
```
|
||||
MessageStart → ContentBlockStart → TextDelta* → ContentBlockEnd → CostUpdate → MessageComplete
|
||||
```
|
||||
|
||||
**单轮工具调用**:
|
||||
|
||||
```
|
||||
MessageStart → ContentBlockStart → TextDelta* → ContentBlockEnd
|
||||
→ ContentBlockStart → ToolCallArgumentsDelta* → ToolCallEnd
|
||||
→ CostUpdate → MessageComplete { stop_reason: ToolUse }
|
||||
→ ToolExecutionStarted → [工具执行] → ToolExecutionCompleted
|
||||
→ ContentBlockStart → TextDelta* → ContentBlockEnd
|
||||
→ CostUpdate → MessageComplete { stop_reason: Stop }
|
||||
```
|
||||
|
||||
**多轮工具调用**:
|
||||
|
||||
```
|
||||
... → ToolExecutionCompleted(第 1 轮)
|
||||
→ ToolCallArgumentsDelta* → ToolCallEnd
|
||||
→ ToolExecutionStarted → ToolExecutionCompleted(第 2 轮)
|
||||
→ ... → CostUpdate → MessageComplete(最终轮)
|
||||
```
|
||||
|
||||
**工具不可恢复错误**:
|
||||
|
||||
```
|
||||
... → ToolCallEnd → ToolExecutionStarted
|
||||
→ Error { "tool 'search' 不可恢复错误: ..." } → MessageComplete
|
||||
```
|
||||
|
||||
> **`MessageComplete.full_response` 内容范围**:每轮 LLM 调用独立产生一个 `MessageComplete`,其中 `full_response` 仅包含**该轮 LLM 的单个响应**(不累积前面工具轮次的结果)。中间轮(`stop_reason: ToolUse`)的 `full_response` 通常只包含 `ToolUse` block,无文本。最终轮(`stop_reason: Stop`)的 `full_response` 包含 LLM 的最终输出。消费者如需追踪完整对话历史,应自行累加所有轮次的 `Message`。
|
||||
|
||||
### 3.3 StreamEvent 新增变体
|
||||
|
||||
在 `src/llm/types/response_v2.rs` 的 `StreamEvent` 枚举中追加两个变体:
|
||||
|
||||
```rust
|
||||
/// 工具开始执行 —— 在 ToolCallEnd 之后、registry.invoke 之前发出。
|
||||
/// 让 UI 层可以显示 "正在执行工具:add(1, 2)"。
|
||||
ToolExecutionStarted {
|
||||
tool_name: String,
|
||||
tool_call_id: String,
|
||||
/// 工具参数(JSON 字符串形式)
|
||||
arguments: String,
|
||||
},
|
||||
|
||||
/// 工具执行完成 —— 在工具返回后、新一轮 LLM 流开始之前发出。
|
||||
ToolExecutionCompleted {
|
||||
tool_name: String,
|
||||
tool_call_id: String,
|
||||
/// 结果摘要(前 200 字符)
|
||||
result_summary: String,
|
||||
/// 是否出错
|
||||
is_error: bool,
|
||||
},
|
||||
```
|
||||
|
||||
在 `PartialMessageResponse::apply_to` 中追加:
|
||||
|
||||
```rust
|
||||
StreamEvent::ToolExecutionStarted { .. } | StreamEvent::ToolExecutionCompleted { .. } => true,
|
||||
```
|
||||
|
||||
这两个是**元事件**,不参与内容块累积,`apply_to` 直接返回 `true`。
|
||||
|
||||
### 3.4 新增方法签名
|
||||
|
||||
**`LlmCycle` 层**(`src/llm/cycle.rs`):
|
||||
|
||||
```rust
|
||||
/// 提交消息并自动处理工具调用循环,流式产出所有事件。
|
||||
///
|
||||
/// 与 `submit_with_tools` 的区别:
|
||||
/// - LLM 响应是流式的(全程 `chat_stream` 而非 `chat`)
|
||||
/// - 工具执行前后插入 `ToolExecutionStarted` / `ToolExecutionCompleted` 事件
|
||||
/// - 错误以 `StreamEvent::Error` 形式出现在流中,而非终止 `Result`
|
||||
/// - 消费方需手动 `push_message()` 同步消息历史
|
||||
///
|
||||
/// **运行时要求**:内部使用 `tokio::spawn`,需要 tokio 多线程运行时。
|
||||
pub async fn submit_with_tools_stream(
|
||||
&mut self,
|
||||
prompt: String,
|
||||
tool_registry: Arc<ToolRegistry>,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError>
|
||||
```
|
||||
|
||||
```rust
|
||||
/// 运行工具循环的核心异步状态机。
|
||||
///
|
||||
/// 接收 owned 字段,通过 mpsc::unbounded_channel 产出事件序列。
|
||||
/// 由 `submit_with_tools_stream` 在 tokio::spawn 中调用。
|
||||
///
|
||||
/// **运行时要求**:此函数内部使用 `tokio::spawn`,要求调用方运行在
|
||||
/// tokio 多线程运行时中(`#[tokio::main]` 或 `#[tokio::test(flavor = "multi_thread")]`)。
|
||||
/// 不在 WASM 目标下可用。
|
||||
async fn run_tool_loop(
|
||||
messages: Vec<Message>,
|
||||
provider: Arc<dyn LlmProvider>,
|
||||
config: CycleConfig,
|
||||
tool_registry: Arc<ToolRegistry>,
|
||||
tools: Vec<ToolDef>,
|
||||
tx: mpsc::UnboundedSender<StreamEvent>,
|
||||
hook_executor: Option<Arc<HookExecutor>>,
|
||||
)
|
||||
```
|
||||
|
||||
**`AgentSession` 层**(`src/agent/session.rs`):
|
||||
|
||||
```rust
|
||||
/// 提交一轮对话(流式,含自动 tool 循环),返回 `StreamEvent` 流。
|
||||
///
|
||||
/// 与 `submit_turn` 的区别:
|
||||
/// - 以流事件序列而非 `MessageResponse` 返回
|
||||
/// - 工具执行期间插入 `ToolExecutionStarted` / `ToolExecutionCompleted` 事件
|
||||
/// - 消费方在收到 `MessageComplete` 后需手动调用 `finalize_turn` 同步状态
|
||||
///
|
||||
/// **运行时要求**:内部委托 `submit_with_tools_stream`,需要 tokio 多线程运行时。
|
||||
pub async fn submit_turn_stream(
|
||||
&mut self,
|
||||
user_input: impl Into<String>,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, AgentError>
|
||||
|
||||
/// 完成一轮 turn:累计 cost + 触发 OnTurnEnd hook。
|
||||
///
|
||||
/// 由消费者在收到 `MessageComplete.full_response` 后调用。
|
||||
pub async fn finalize_turn(&mut self, response: &MessageResponse)
|
||||
```
|
||||
|
||||
> **实施偏差(Phase 10 适配)**:实际签名扩展为
|
||||
> `pub async fn finalize_turn(&mut self, response: &MessageResponse, new_messages_from_cycle: Vec<Message>) -> Result<(), AgentError>`。
|
||||
> - `new_messages_from_cycle`:本轮新增消息(`[user_input, ...tool_results, final_response]`),由消费者在流消费完毕后从 `cycle.messages()[input_len..]` 提取并传入;`finalize_turn` 增量追加到当前 slot(不覆盖已有消息)。
|
||||
> - 返回 `Result<(), AgentError>`:错误传播更清晰,与 `submit_turn` 的 slot 边界错误(`SlotReadonly` / `SlotNotFound`)对齐。
|
||||
> - Phase 10 ContextSlot 实施时扩展。Phase 9 消费者若不接入 slot 持久化,可传 `vec![response.message.clone()]` 兜底。
|
||||
|
||||
### 3.5 消费者使用模式
|
||||
|
||||
```rust
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let mut stream = session.submit_turn_stream("计算 1+2").await?;
|
||||
|
||||
let mut final_response = None;
|
||||
while let Some(event) = stream.next().await {
|
||||
match &event {
|
||||
StreamEvent::TextDelta { text } => print!("{}", text),
|
||||
StreamEvent::ToolExecutionStarted { tool_name, arguments, .. } => {
|
||||
println!("\n🔧 [{}({})]", tool_name, arguments);
|
||||
}
|
||||
StreamEvent::ToolExecutionCompleted { result_summary, .. } => {
|
||||
println!(" → {}", result_summary);
|
||||
}
|
||||
StreamEvent::MessageComplete { full_response } => {
|
||||
final_response = Some(full_response.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
std::io::stdout().flush().ok();
|
||||
|
||||
if let Some(response) = final_response {
|
||||
session.finalize_turn(&response).await;
|
||||
}
|
||||
```
|
||||
|
||||
> **⚠️ 消费者注意**:`finalize_turn` 是开发者责任 —— 遗漏调用会导致 `cost_so_far` 不累计、`OnTurnEnd` hook 不触发。session 状态仍然可用,后续 `submit_turn` 也能正常执行,但 cost 信息不完整。`finalize_turn` 无自动补偿机制,建议使用 `Drop` guard 或在 `while` 循环的 `finally` 块中确保调用。
|
||||
|
||||
### 3.6 run_tool_loop 核心逻辑
|
||||
|
||||
`run_tool_loop` 是此方案的核心状态机(约 90 行),其伪代码逻辑如下:
|
||||
|
||||
```
|
||||
1. 接收 owned 字段:messages, provider, config, tool_registry, tools, tx, hook_executor
|
||||
2. max_turns = config.max_tool_turns.unwrap_or(10)
|
||||
// None → 10(退化为默认值),Some(n) → n
|
||||
// 与非流式 submit_with_tools 行为一致
|
||||
3. 工具循环(for round in 1..=max_turns):
|
||||
a. build_request(messages, tools)
|
||||
// 空 tool_registry 时 tools 为空列表,流退化为纯文本流(可安全运行)
|
||||
b. PreRequest hook(如果有 hook_executor)
|
||||
c. 发起流式 LLM 调用:
|
||||
let stream = match provider.chat_stream(request).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
// 第一层错误:chat_stream 自身失败(网络/认证/限流)
|
||||
// 这里不做 retry:retry 逻辑留给上层循环的 submit_request 模式,
|
||||
// 流式场景中 retry 需重新建立 mpsc 通道,复杂度与收益不匹配
|
||||
tx.send(StreamEvent::Error { message: e.to_string() }).ok();
|
||||
return; // 直接结束 task
|
||||
}
|
||||
};
|
||||
d. 消费 LLM 流:
|
||||
- PartialMessageResponse::new()
|
||||
- while let Some(result) = stream.next().await
|
||||
- match result:
|
||||
Ok(event) → apply_to + tx.send(event)
|
||||
Err(e) → tx.send(Error { message }) + break
|
||||
// 第二层错误:stream 内部事件错误(如 chunk 解析失败)
|
||||
e. partial.finalize()? → response
|
||||
f. push response.message → messages
|
||||
g. 检查 has_tool_calls_in_response(&response)
|
||||
h. 如果没有 tool_use: break(最终轮,流已自然结束)
|
||||
i. 如果有 tool_use:
|
||||
- extract_tool_calls_from_response(&response)
|
||||
- tx.send(ToolExecutionStarted { tool_name, tool_call_id, arguments })
|
||||
- registry.invoke_all(calls, tool_timeout).await
|
||||
- for result in results:
|
||||
tx.send(ToolExecutionCompleted { tool_name, tool_call_id, result_summary, is_error })
|
||||
- push tool results → messages
|
||||
- continue(新一轮 LLM 流)
|
||||
4. 流结束(tokio::spawn 自然退出)
|
||||
```
|
||||
|
||||
> **关于 LLM 请求 retry**:非流式 `submit_with_tools` 内部通过 `submit_request` 的 retry 循环处理临时错误。流式版本 `run_tool_loop` **不在内部实现 retry**。原因:(1)retry 需要重新建立 mpsc 通道和事件流上下文,复杂度与收益不匹配;(2)`unbounded_channel` 已发出的事件无法撤回。如果需要 retry 语义,调用方应在上层做 fallback 策略,或在 `llm provider` 实现层完成 retry(如 `RetryProvider` 包装器)。
|
||||
|
||||
**错误处理**:
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| LLM 请求失败(`chat_stream` 返回 `Err`) | `tx.send(Error { message })` + `return` 结束 task。**不做 retry**(见上方说明) |
|
||||
| LLM 流内事件错误(stream Item 的 `Err`) | `tx.send(Error { message })` + `break` 结束当轮流,终止循环 |
|
||||
| 可恢复工具错误(`is_recoverable() == true`) | 作为 tool result 回传 LLM,流继续,不出 Error 事件 |
|
||||
| 不可恢复工具错误(`is_recoverable() == false`) | `tx.send(Error { message })` + 终止循环 |
|
||||
| 工具超时(`tokio::time::timeout`) | 视为不可恢复,`tx.send(Error)` + 终止 |
|
||||
| 最大工具循环轮次超限 | `tx.send(Error { "达到最大工具循环轮次" })` + 终止 |
|
||||
| spawn task 内部 panic | 由于 `JoinHandle` 不保存(detached),panic 由 tokio 运行时静默捕获;消费者看到 stream 直接结束(返回 `None`),无 `Error` 事件。建议在 `run_tool_loop` 内部避免 `unwrap()`,所有可失败路径通过 `Result` + `?` 传播 |
|
||||
|
||||
### 3.7 修改文件清单
|
||||
|
||||
| # | 文件 | 改动 | 估算行数 |
|
||||
|---|------|------|---------|
|
||||
| 1 | `llm/types/response_v2.rs` | +2 `StreamEvent` 变体 +2 `apply_to` arm | ~20 |
|
||||
| 2 | `llm/cycle.rs` | +`submit_with_tools_stream` 方法 + `run_tool_loop` 模块函数 | ~140 |
|
||||
| 3 | `llm/cycle.rs` | `CycleConfig` 加 `#[derive(Clone)]` | ~1 |
|
||||
| 4 | `agent/session.rs` | +`submit_turn_stream` + `finalize_turn` | ~70 |
|
||||
| — | **测试**(内联) | 4 个场景测试(纯度本、单轮、多轮、超限) | ~150 |
|
||||
| | **合计** | | **~380** |
|
||||
|
||||
> 注:`RetryConfig` 已标注 `#[derive(Debug, Clone)]`,无需额外修改。
|
||||
|
||||
---
|
||||
|
||||
## 4. 实现计划
|
||||
|
||||
按 5 个 Step 增量实施,每步可独立编译和测试。
|
||||
|
||||
### Step 1 — 基础设施准备
|
||||
|
||||
**目标**:数据层就绪,为流事件新增变体和配置 Clone 奠基。
|
||||
|
||||
**改动**:
|
||||
|
||||
- `llm/types/response_v2.rs`:
|
||||
- `StreamEvent` 枚举追加 `ToolExecutionStarted` / `ToolExecutionCompleted` 变体
|
||||
- `PartialMessageResponse::apply_to` 追加两个新变体的 arm(均返回 `true`)
|
||||
- `llm/cycle.rs`:
|
||||
- `CycleConfig` 加 `#[derive(Clone)]`(所有字段为基础类型 + `RetryConfig`)
|
||||
|
||||
**验证**:`cargo build` 通过
|
||||
|
||||
### Step 2 — `LlmCycle::submit_with_tools_stream` 核心
|
||||
|
||||
**目标**:实现流式工具循环的核心状态机,这是整个 Phase 9 的技术关键。
|
||||
|
||||
**改动**:
|
||||
|
||||
- `llm/cycle.rs`:
|
||||
- 新增 `run_tool_loop()` 模块函数(约 90 行),基于 `mpsc::unbounded_channel` 通信
|
||||
- 新增 `submit_with_tools_stream()` 公开方法,入口参数为 `prompt` + `Arc<ToolRegistry>`
|
||||
- 内部 `tokio::spawn` 启动 `run_tool_loop`,返回 `rx` 端作为 `dyn Stream`
|
||||
|
||||
**验证**:`cargo build` 通过
|
||||
|
||||
### Step 3 — 单元测试(LlmCycle 层)
|
||||
|
||||
**目标**:验证 `submit_with_tools_stream` 在 8 个核心场景下的行为和事件序列正确性(含 §8 Step 3 扩展的工具错误路径)。
|
||||
|
||||
**新增**(`llm/cycle.rs` 内联测试 `#[cfg(test)]`):
|
||||
|
||||
| 场景 | Mock 响应序列 | 验证点 |
|
||||
|------|---------------|--------|
|
||||
| 1 — 纯文本流 | 1 个 text 响应 | 事件序列与 `submit_stream` 一致;无 `ToolExecutionStarted`/`ToolExecutionCompleted` |
|
||||
| 2 — 单轮工具调用 | 2 个响应:tool_use → text | 包含 `ToolExecutionStarted` + `ToolExecutionCompleted`;最终 `stop_reason` 为 `Stop` |
|
||||
| 3 — 多轮工具调用 | 4 个响应:3 × tool_use → 1 × text | 3 对 `ToolExecutionStarted`/`ToolExecutionCompleted`;消息历史长度正确 |
|
||||
| 4 — 最大轮次超限 | 3 个 tool_use 响应,`max_tool_turns: Some(2)` | 流中出现 `Error` 事件;消息历史停在第 2 轮 |
|
||||
|
||||
**验证**:`cargo test` 全部通过
|
||||
|
||||
### Step 4 — `AgentSession` 层包装
|
||||
|
||||
**目标**:为 `AgentSession` 新增流式会话接口,保持与 `submit_turn` 一致的行为语义。
|
||||
|
||||
**改动**:
|
||||
|
||||
- `agent/session.rs`:
|
||||
- `submit_turn_stream(user_input)` — 触发 `OnTurnStart` hook → 组装 `LlmCycle` → 调用 `submit_with_tools_stream` → `turn_index += 1` → 返回流
|
||||
- `finalize_turn(response)` — `cost_so_far.add(&response.usage)` → 触发 `OnTurnEnd` hook
|
||||
|
||||
**验证**:`cargo build` 通过
|
||||
|
||||
### Step 5 — 集成测试 + 扫尾
|
||||
|
||||
**目标**:端到端验证 `submit_turn_stream` + `finalize_turn` 的完整链路,确保零回归。
|
||||
|
||||
**新增**(`agent/session.rs` 内联测试,2026-07-08 实施审查补全):
|
||||
|
||||
- `submit_turn_stream_end_to_end` — `submit_turn_stream` 跑通 mock provider → 消费流(验证收到 TextDelta + MessageComplete) → `finalize_turn` 后 `cost_so_far` 正确更新(`prompt_tokens=10, completion_tokens=5`) + `turn_index=1` + default slot 包含 user/assistant 消息
|
||||
- `submit_turn_stream_triggers_turn_hooks` — 验证 `OnTurnStart` 在 `submit_turn_stream` 返回流之前已触发(计数=1)+ `OnTurnEnd` 在 `finalize_turn` 之前**不**触发(计数=0)+ `finalize_turn` 后 `OnTurnEnd` 触发(计数=1)
|
||||
|
||||
**验证**:
|
||||
|
||||
```bash
|
||||
cargo test --all-targets # 全绿,存量测试 0 回归
|
||||
cargo clippy --all-targets -- -D warnings # 0 警告
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 运行细节
|
||||
|
||||
### 5.1 `run_tool_loop` 的 spawn 生命周期
|
||||
|
||||
#### 执行模型:立即执行 vs 惰性流
|
||||
|
||||
`submit_with_tools_stream` 采用 **立即执行** 模型(`tokio::spawn` + `mpsc`),这与 `submit_stream` 的 **惰性执行**(`async_stream::stream!` 宏,消费者首次 `next()` 时才触发 LLM 调用)不同。
|
||||
|
||||
**选择理由**:工具循环是 **不确定轮次的** —— 每个工具执行的结果可能影响后续 LLM 调用。惰性流无法表达这种"边消费边控制"的语义。通过 `tokio::spawn` 将工具循环移到独立 task 中运行,使得:
|
||||
- 消费者可以随时开始消费(不丢失事件)
|
||||
- 工具循环在后台独立运行,不受消费者消费节奏影响
|
||||
- `mpsc::unbounded_channel` 作为事件缓冲区,解耦生产者与消费者
|
||||
|
||||
**对消费者的影响**:`submit_with_tools_stream().await?` 返回时,工具循环可能已经开始执行(事件已开始写入 channel)。消费者应尽快开始 `while let Some(event) = stream.next().await`,避免 channel 缓冲过多事件。如果在返回流后长时间不消费,事件会堆积在 mpsc buffer 中(内存开销,无阻塞风险 —— 见 §6 风险表)。
|
||||
|
||||
#### 生命周期
|
||||
|
||||
```
|
||||
submit_with_tools_stream()
|
||||
│
|
||||
├─ mpsc::unbounded_channel() → (tx, rx)
|
||||
├─ messages.push(user_text(prompt))
|
||||
├─ compact check
|
||||
├─ tokio::spawn(run_tool_loop(messages, provider, config, ..., tx))
|
||||
└─ return Box::pin(rx) as dyn Stream
|
||||
|
||||
[用户消费 stream]
|
||||
└─ while let Some(event) = rx.recv().await { yield event }
|
||||
|
||||
[用户 drop rx / 结束循环]
|
||||
└─ rx 被 drop → tx.send() 返回 Err
|
||||
→ run_tool_loop 检测到 tx.closed()
|
||||
→ break → task 自然终止
|
||||
```
|
||||
|
||||
#### JoinHandle 与 panic 处理
|
||||
|
||||
`run_tool_loop` 的 `JoinHandle` 在 spawn 后**不保存**(detached pattern)。panic 由 tokio 运行时捕获并通过 `tracing::error` 记录:
|
||||
|
||||
```rust
|
||||
// submit_with_tools_stream 内部
|
||||
tokio::spawn(async move {
|
||||
run_tool_loop(..., tx).await;
|
||||
});
|
||||
```
|
||||
|
||||
如果 `run_tool_loop` 内部发生 panic(如 `unwrap()`),tokio 的 `spawn` 会静默吞掉 panic 并终止 task。消费者此时看到 stream 直接返回 `None`,不会收到 `StreamEvent::Error`。实际编码中应避免 `unwrap()`,所有 `Result` 使用 `?` 或 `match` 处理。
|
||||
|
||||
Rx 侧实现 `Stream` trait:使用 `tokio_stream::wrappers::UnboundedReceiverStream` 包装 `mpsc::UnboundedReceiver`,因为 `mpsc::UnboundedReceiver` 本身不实现 `Stream`(`tokio-stream = "0.1"` 已在 `Cargo.toml` 中存在)。
|
||||
|
||||
### 5.2 消息历史同步
|
||||
|
||||
`submit_with_tools_stream` 内部由 `run_tool_loop` 管理 `messages` 的拷贝,不会写入 `self.messages`。消费方在收到 `MessageComplete` 后需手动:
|
||||
|
||||
```rust
|
||||
let response = full_response.clone();
|
||||
cycle.push_message(response.message.clone());
|
||||
```
|
||||
|
||||
在 `AgentSession::submit_turn_stream` 中,由于流是延迟求值且 `&mut self` 无法进入 spawn 闭包,消息历史同步交由消费方在 `finalize_turn` 前自行决定。当前方案中 `submit_turn_stream` **不自动同步消息历史**,这与 `submit_stream` 的已有行为一致(ponytail: Phase 2 FIX-E 注释)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险评估
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| `&mut self` 约束导致流内无法访问 session 状态 | 中 | 复用 `submit_stream` 已有模式:方法体内读取 `self` 后构建 owned 数据,spawn 闭包不捕获 `&mut self` |
|
||||
| spawn task 生命周期管理 | 低 | 用户 drop rx → `tx.send` 返回 `Err` → `run_tool_loop` 自然终止 |
|
||||
| spawn task panic 静默丢失 | 中 | `run_tool_loop` 内部使用 `match`/`?` 避免 `unwrap()`;`JoinHandle` 不做 `await`(detached),panic 由 tokio 运行时记录日志。消费者看到 stream 提前结束(收到 `None`)但无 Error 事件 |
|
||||
| 中间轮 cost 不累加到 `cost_so_far` | 低 | 与现有 `submit_turn` 行为一致(仅最终轮计入),标记为已知限制,不在此 Phase 修复 |
|
||||
| 工具循环中 hook 可用性 | 低 | `PreRequest`/`PostRequest` hook 通过 `hook_executor.clone()` 进入 spawn task;hook 在 `run_tool_loop` 循环内触发 |
|
||||
| `run_tool_loop` 不支持消息压缩 | 中 | 长工具循环中消息不断增长,可能超出 context window。当前不传递 `compact_config`,后续可扩展 `run_tool_loop` 签名增添此参数 |
|
||||
| `unbounded_channel` 在消费慢于生产时内存增长 | 低 | LLM 流式输出天然有节流(token 生成速度远慢于 CPU 处理速度),消费者通常快于生产者。后续如需背压可切换为 `mpsc::channel(N)` + backpressure |
|
||||
| 流式版本不做 LLM retry | 低 | 非流式 `submit_with_tools` 通过 `submit_request` 的 retry 循环处理临时错误。流式版本中 retry 需重建 mpsc 通道,复杂度不匹配。调用方可使用 `RetryProvider` 包装器或在 Provider 层实现 retry |
|
||||
| 执行模式与 `submit_stream` 不一致(立即 vs 惰性) | 低 | `submit_stream` 的惰性语义不适配需要后台执行的工具循环。消费者应在 `submit_turn_stream` 返回后尽快消费流事件 |
|
||||
| `tokio::spawn` 要求 tokio 多线程运行时 | 低 | agcore 已依赖 tokio,涉及 IO 的 API 均使用 async。`#[tokio::test]` 单线程运行时不支持 `spawn`,测试中将 `run_tool_loop` 提取为可独立调用的函数,测试不走 spawn 直接调用 |
|
||||
| `CycleConfig` 加 `Clone` 影响现有代码 | 无 | 纯配置 struct,所有字段是基础类型或已 `Clone` 的 `RetryConfig` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
| # | 验收项 | 验证方式 |
|
||||
|---|--------|---------|
|
||||
| 1 | `cargo build --all-targets` 通过 | ✅ 编译器无错误 |
|
||||
| 2 | `submit_with_tools_stream` 纯文本流事件序列正确 | 单元测试验证:事件类型、顺序与 `submit_stream` 一致 |
|
||||
| 3 | `submit_with_tools_stream` 单轮工具调用事件序列正确 | 单元测试验证:含 `ToolExecutionStarted` / `ToolExecutionCompleted` |
|
||||
| 4 | `submit_with_tools_stream` 多轮工具调用事件序列正确 | 单元测试验证:多对 `ToolExecutionStarted`/`ToolExecutionCompleted` |
|
||||
| 5 | `submit_with_tools_stream` 最大轮次超限产生 Error 事件 | 单元测试验证:流中出现 `StreamEvent::Error` |
|
||||
| 6 | `submit_turn_stream` + `finalize_turn` 端到端链路 | 集成测试验证:cost 更新 + hook 触发 |
|
||||
| 7 | `cargo test --all-targets` 全绿,存量测试 0 回归 | ✅ 无回归 |
|
||||
| 8 | `cargo clippy --all-targets -- -D warnings` 0 警告 | ✅ 无警告 |
|
||||
| 9 | 现有 `submit_turn` / `submit_with_tools` / `submit_stream` 行为零影响 | ✅ 存量测试通过 |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 8. 实施计划
|
||||
|
||||
按 5 个 Step 分阶段实施,每步产出独立 commit,可验证后退。
|
||||
|
||||
### 依赖关系
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
S1["Step 1: 基础设施"]:::s1
|
||||
S2["Step 2: 核心状态机"]:::s2
|
||||
S3["Step 3: LlmCycle 单元测试"]:::s3
|
||||
S4["Step 4: AgentSession 包装"]:::s4
|
||||
S5["Step 5: 集成测试 + 扫尾"]:::s5
|
||||
|
||||
S1 --> S2
|
||||
S1 --> S4
|
||||
S2 --> S3
|
||||
S2 --> S4
|
||||
S3 --> S5
|
||||
S4 --> S5
|
||||
|
||||
classDef s1 fill:#e2e8f0,stroke:#94a3b8
|
||||
classDef s2 fill:#fbbf24,stroke:#d97706
|
||||
classDef s3 fill:#93c5fd,stroke:#2563eb
|
||||
classDef s4 fill:#93c5fd,stroke:#2563eb
|
||||
classDef s5 fill:#4ade80,stroke:#16a34a
|
||||
```
|
||||
|
||||
| Step | 依赖 | 并行机会 |
|
||||
|------|------|---------|
|
||||
| S1 | 无 | — |
|
||||
| S2 | S1 | 可与 S4 并行 |
|
||||
| S3 | S2 | 阻塞,需 S2 完成 |
|
||||
| S4 | S1, S2 | 功能依赖 S2(调用 `submit_with_tools_stream`);文件级无重叠但需先编译过 S2 |
|
||||
| S5 | S3 + S4 | 需 S3 和 S4 都完成 |
|
||||
|
||||
### Step 1 — 基础设施准备
|
||||
|
||||
**工作量**:S(< 1h)
|
||||
**风险**:低(纯新增,不影响现有代码逻辑)
|
||||
|
||||
| # | 任务 | 涉及文件 | 前置依赖 | 风险 |
|
||||
|---|------|---------|---------|------|
|
||||
| 1.1 | `StreamEvent` 枚举追加 `ToolExecutionStarted` 变体 | `llm/types/response_v2.rs` | 无 | 低 |
|
||||
| 1.2 | `StreamEvent` 枚举追加 `ToolExecutionCompleted` 变体 | `llm/types/response_v2.rs` | 1.1 | 低 |
|
||||
| 1.3 | `PartialMessageResponse::apply_to` 追加两个元事件 arm(均返回 `true`) | `llm/types/response_v2.rs` | 1.2 | 低 |
|
||||
| 1.4 | `CycleConfig` 加 `#[derive(Clone)]` | `llm/cycle.rs` | 无 | 低 |
|
||||
|
||||
**验收条件**:
|
||||
- `cargo build` 通过,编译器无 warning
|
||||
- 新增的 `StreamEvent` 变体可通过 `serde` roundtrip 序列化/反序列化
|
||||
- `CycleConfig` 可正常 clone
|
||||
|
||||
### Step 2 — `LlmCycle::submit_with_tools_stream` 核心
|
||||
|
||||
**工作量**:M(1-4h)
|
||||
**风险**:中(核心实现,需正确设计 spawn + mpsc 生命周期)
|
||||
**前置依赖**:S1
|
||||
|
||||
| # | 任务 | 涉及文件 | 前置依赖 | 风险 |
|
||||
|---|------|---------|---------|------|
|
||||
| 2.1 | 实现 `run_tool_loop()` 模块函数:消息循环构建请求 → `chat_stream` → 消费流 → 检测 tool_use → 工具执行 → 新一轮 | `llm/cycle.rs` | S1 | 中 |
|
||||
| 2.2 | 实现 `submit_with_tools_stream()` 公开方法:提取字段 → spawn `run_tool_loop` → 返回 `UnboundedReceiverStream` | `llm/cycle.rs` | 2.1 | 中 |
|
||||
| 2.3 | 新增导入:`tokio::sync::mpsc`、`tokio_stream::wrappers::UnboundedReceiverStream` | `llm/cycle.rs` | 2.2 | 低 |
|
||||
|
||||
**关键实现细节**:
|
||||
|
||||
```rust
|
||||
// run_tool_loop 函数签名
|
||||
async fn run_tool_loop(
|
||||
mut messages: Vec<Message>,
|
||||
provider: Arc<dyn LlmProvider>,
|
||||
config: CycleConfig,
|
||||
tool_registry: Arc<ToolRegistry>,
|
||||
tools: Vec<ToolDef>,
|
||||
tx: mpsc::UnboundedSender<StreamEvent>,
|
||||
hook_executor: Option<Arc<HookExecutor>>,
|
||||
) {
|
||||
let max_turns = config.max_tool_turns.unwrap_or(10);
|
||||
let tool_timeout = config.tool_timeout_secs;
|
||||
let max_bytes = config.max_tool_result_bytes;
|
||||
|
||||
let mut round = 0u32;
|
||||
loop {
|
||||
round += 1;
|
||||
if round > max_turns {
|
||||
// §3.6 错误表:最大轮次超限 → Error 事件 + 终止
|
||||
let _ = tx.send(StreamEvent::Error { message: "达到最大工具循环轮次".to_string() });
|
||||
break;
|
||||
}
|
||||
// ① 构建请求
|
||||
let request = MessageRequest {
|
||||
model: config.model.clone(),
|
||||
messages: messages.clone(),
|
||||
tools: tools.clone(),
|
||||
tool_choice: ToolChoice::Auto,
|
||||
max_tokens: config.max_tokens,
|
||||
temperature: config.temperature,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// ② PreRequest hook
|
||||
// ...
|
||||
|
||||
// ③ chat_stream
|
||||
let stream = match provider.chat_stream(request).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = tx.send(StreamEvent::Error { message: e.to_string() });
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// ④ 消费流
|
||||
let mut partial = PartialMessageResponse::new();
|
||||
let mut stream = stream;
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(event) => {
|
||||
partial.apply_to(&event);
|
||||
if tx.send(event).is_err() { return; }
|
||||
}
|
||||
Err(e) => {
|
||||
// ponytail: 流内事件错误后 partial 处于损坏状态,
|
||||
// 不能继续执行 finalize/finalize —— 直接 return 结束 task
|
||||
let _ = tx.send(StreamEvent::Error { message: e.to_string() });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ finalize
|
||||
let response = match partial.finalize() {
|
||||
Ok(r) => r,
|
||||
Err(e) => { let _ = tx.send(StreamEvent::Error { .. }); return; }
|
||||
};
|
||||
messages.push(response.message.clone());
|
||||
|
||||
// ⑥ 检测 tool_use
|
||||
if !has_tool_calls_in_response(&response) {
|
||||
break; // 最终轮
|
||||
}
|
||||
|
||||
// ⑦ 执行工具
|
||||
let tool_calls = extract_tool_calls_from_response(&response);
|
||||
let calls: Vec<_> = tool_calls.into_iter()
|
||||
.map(|(id, name, args)| {
|
||||
let value = serde_json::from_str(&args).unwrap_or(Value::Null);
|
||||
(id, name, value)
|
||||
}).collect();
|
||||
|
||||
for (tool_call_id, tool_name, args_value) in &calls {
|
||||
let args_json = serde_json::to_string(&args_value).unwrap_or_default();
|
||||
if tx.send(StreamEvent::ToolExecutionStarted {
|
||||
tool_name: tool_name.clone(),
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
arguments: args_json,
|
||||
}).is_err() { return; }
|
||||
}
|
||||
|
||||
let results = tool_registry.invoke_all(calls, tool_timeout).await;
|
||||
|
||||
for result in &results {
|
||||
let summary = match &result.output {
|
||||
Ok(v) => serde_json::to_string(v).unwrap_or_default(),
|
||||
Err(e) => e.to_string(),
|
||||
};
|
||||
// ponytail: 复用现有 truncate_tool_result 函数(cycle.rs 末尾),
|
||||
// 确保多字节 UTF-8 字符不被截断破坏。上限 200 字符。
|
||||
let truncated = truncate_tool_result(&summary, 200);
|
||||
if tx.send(StreamEvent::ToolExecutionCompleted {
|
||||
tool_name: result.tool_name.clone(),
|
||||
tool_call_id: result.tool_call_id.clone(),
|
||||
result_summary: truncated,
|
||||
is_error: result.output.is_err(),
|
||||
}).is_err() { return; }
|
||||
}
|
||||
|
||||
for result in results {
|
||||
let is_error = result.output.is_err();
|
||||
let content = match &result.output {
|
||||
Ok(v) => serde_json::to_string(v).unwrap_or_default(),
|
||||
Err(e) if e.is_recoverable() => format!("错误: {}", e),
|
||||
Err(e) => {
|
||||
let _ = tx.send(StreamEvent::Error { .. });
|
||||
return;
|
||||
}
|
||||
};
|
||||
messages.push(Message::tool_result(result.tool_call_id, content, is_error));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**验收条件**:
|
||||
- `cargo build` 通过
|
||||
- 新增方法签名与方案设计一致
|
||||
- 未修改现有 `submit_with_tools`/`submit_stream` 的行为
|
||||
|
||||
### Step 3 — 单元测试(LlmCycle 层)
|
||||
|
||||
**工作量**:M(1-4h)
|
||||
**风险**:低(与现有测试模式一致,使用已有 MockProvider)
|
||||
**前置依赖**:S2
|
||||
|
||||
测试策略:直接使用公开的 `crate::llm::mock::MockProvider`(已完整实现 `chat_stream` + 预设响应队列),避免改造 `cycle.rs` 测试模块内的内联 Stub。测试中调用 `submit_with_tools_stream` 时通过 `#[tokio::test(flavor = "multi_thread")]` 满足 spawn 运行时要求,或在单元级将 `run_tool_loop` 作为独立函数直接测试(不走 spawn)。
|
||||
|
||||
| # | 测试场景 | Mock 响应序列 | 验证点 | 覆盖路径 |
|
||||
|---|---------|---------------|--------|---------|
|
||||
| 3.1 | 纯文本流 | 1 个 text 响应 | 事件序列与 `submit_stream` 一致;无 `ToolExecutionStarted`/`ToolExecutionCompleted` | 正常路径:单轮 LLM → 文本返回 |
|
||||
| 3.2 | 单轮工具调用 | 2 个响应:tool_use + text | 包含一对 `ToolExecutionStarted`/`ToolExecutionCompleted`;最终 `stop_reason` 为 `Stop` | 正常路径:LLM → 工具 → LLM |
|
||||
| 3.3 | 多轮工具调用 | 4 个响应:3×tool_use + 1×text | 3 对 `ToolExecutionStarted`/`ToolExecutionCompleted`;消息历史长度为 8(user + 3×(assistant+tool) + final assistant) | 正常路径:LLM → 工具 → LLM → 工具 → LLM |
|
||||
| 3.4 | 最大轮次超限 | 3 个 tool_use 响应,`max_tool_turns: Some(2)` | 流中出现 `StreamEvent::Error`;消息历史停在第 2 轮 | 边界条件:超出上限 |
|
||||
| 3.5 | `chat_stream` 返回 Err | Mock `chat_stream` 返回 `Err(LlmError::Other(...))` | 流中第一个事件为 `StreamEvent::Error`;随后流结束 | 异常路径:LLM 不可用 |
|
||||
| 3.6 | 空 tool_registry | 1 个 text 响应,registry 中无工具 | 流退化为纯文本流,事件序列与 3.1 一致 | 退化场景:无工具可用 |
|
||||
| 3.7 | 不可恢复工具错误 | 2 个响应:tool_use → text,工具返回 `ToolError::ExecutionFailed`(不可恢复) | 流中出现 `StreamEvent::Error`;消息历史中不含该工具结果(循环终止前未 push) | 异常路径:工具执行失败 |
|
||||
| 3.8 | 可恢复工具错误 | 2 个响应:tool_use → text,工具返回 `ToolError::ExecutionFailed`(可恢复) | 工具结果作为 `ToolResult { is_error: true }` 回传 LLM;流正常结束,无 `Error` 事件 | 异常路径:工具出错但可恢复 |
|
||||
| 3.9 | 工具超时 | 2 个响应:tool_use → text,`tool_timeout_secs: 1`,模拟工具耗时 10 秒 | 流中出现 `StreamEvent::Error`;循环终止前未 push 工具结果 | 异常路径:工具执行超时 |
|
||||
|
||||
**验收条件**:
|
||||
- `cargo test` 新增 8 个测试全部通过
|
||||
- `cargo test` 存量测试 0 回归
|
||||
|
||||
### Step 4 — `AgentSession` 层包装
|
||||
|
||||
**工作量**:S(< 1h)
|
||||
**风险**:低(薄包装层,逻辑简单)
|
||||
**前置依赖**:S1
|
||||
|
||||
| # | 任务 | 涉及文件 | 前置依赖 | 风险 |
|
||||
|---|------|---------|---------|------|
|
||||
| 4.1 | 实现 `submit_turn_stream()`:触发 `OnTurnStart` → 组装 `LlmCycle` → 调用 `submit_with_tools_stream` → `turn_index += 1` → 返回流 | `agent/session.rs` | S1 | 低 |
|
||||
| 4.2 | 实现 `finalize_turn()`:`cost_so_far.add()` → 触发 `OnTurnEnd` hook | `agent/session.rs` | S1 | 低 |
|
||||
|
||||
**验收条件**:
|
||||
- `cargo build` 通过
|
||||
- 新增方法签名与方案设计一致
|
||||
- 与 `submit_turn` 的 system_prompt / compact_config / bundle 使用方式一致
|
||||
|
||||
### Step 5 — 集成测试 + 扫尾
|
||||
|
||||
**工作量**:S(< 1h)
|
||||
**风险**:低(基于现有测试框架)
|
||||
**前置依赖**:S3 + S4
|
||||
|
||||
| # | 任务 | 涉及文件 | 前置依赖 | 风险 |
|
||||
|---|------|---------|---------|------|
|
||||
| 5.1 | `submit_turn_stream` 端到端测试:跑通 mock provider → 消费流验证各事件到达 → `finalize_turn` 后 cost 更新正确 | `agent/session.rs`(内联测试) | S4 | 低 |
|
||||
| 5.2 | Hook 触发验证:`OnTurnStart` 在 `submit_turn_stream` 返回流之前触发;`finalize_turn` 调用后 `OnTurnEnd` 正确触发 | `agent/session.rs`(内联测试) | S4 | 低 |
|
||||
| 5.3 | `cargo test --all-targets` 全绿验证 | 全仓 | S5.1+S5.2 | 低 |
|
||||
| 5.4 | `cargo clippy --all-targets -- -D warnings` 0 警告 | 全仓 | S5.3 | 低 |
|
||||
| 5.5 | `cargo build --all-targets` 发布模式验证 | 全仓 | S5.4 | 低 |
|
||||
|
||||
**验收条件**:
|
||||
- 全量测试通过,存量 0 回归
|
||||
- clippy 0 警告
|
||||
- 发布模式零 warning
|
||||
|
||||
### 实施总览
|
||||
|
||||
| | Step 1 | Step 2 | Step 3 | Step 4 | Step 5 | **合计** |
|
||||
|--|--------|--------|--------|--------|--------|---------|
|
||||
| **工作量** | S | M | M | S | S | **M-L** |
|
||||
| **文件数** | 2 | 1 | 1(内联) | 1 | 1(内联) | **~4** |
|
||||
| **代码行** | ~20 | ~140 | ~150 含测试 | ~70 | ~80 含测试 | **~380** |
|
||||
| **风险** | 低 | 中 | 低 | 低 | 低 | 中 |
|
||||
| **并行** | — | 阻塞(S4 依赖 S2) | 阻塞 | 阻塞(依赖 S2) | 阻塞 | — |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:新增 StreamEvent 变体的 apply_to 语义
|
||||
|
||||
```rust
|
||||
// 在 PartialMessageResponse::apply_to 中追加:
|
||||
StreamEvent::ToolExecutionStarted { .. } | StreamEvent::ToolExecutionCompleted { .. } => {
|
||||
// 元事件:不参与内容块累积,不修改 partial response 状态
|
||||
true
|
||||
}
|
||||
```
|
||||
|
||||
## 附录 B:CycleConfig 的 Clone 推导
|
||||
|
||||
```rust
|
||||
/// LLM 调用周期配置。
|
||||
#[derive(Debug, Clone)] // ← 追加 Clone
|
||||
pub struct CycleConfig {
|
||||
pub model: String,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
pub max_turns: Option<u32>,
|
||||
pub retry: RetryConfig, // 已 #[derive(Clone)]
|
||||
pub max_tool_turns: Option<u32>,
|
||||
pub tool_timeout_secs: u64,
|
||||
pub max_tool_result_bytes: usize,
|
||||
}
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,647 @@
|
||||
# Phase 11: 测试与检索补强
|
||||
|
||||
## 背景与目标
|
||||
|
||||
AG Core 当前(v0.2.0-rc.1)已完成 Phase 0-10,全量测试 254 个,clippy 0 警告,11 个离线示例全部 exit 0。功能性交付物覆盖了 LLM Cycle、Prompt、Tool、Memory、Agent Runtime、流式事件、ContextSlot 上下文管理。但有两个系统性的短板尚未补齐:
|
||||
|
||||
1. **检索抽象缺失**:`memory` 模块只有 `MemoryRetriever`(基于 TextOverlap Dice 系数的关键词检索),缺少语义向量的检索抽象。`docs/roadmap.md` P1 中「VectorRetriever trait」一直未实现。
|
||||
2. **测试覆盖缺口**:Provider 的 roundtrip 测试停留在"基本响应 + 普通 401/500"层面,缺少结构化错误体解析、请求头验证、流式边界、工具调用端到端等关键场景的回归覆盖。多线程并发的 MemoryStore 测试只在 SqliteStore 有一个 10×10 场景,InMemoryStore 完全没有并发压力测试。
|
||||
|
||||
Phase 11 是 v0.2.0 正式版发布前的最后一个功能 Phase,三个 Step 的目标:
|
||||
|
||||
| Step | 内容 | 定位 |
|
||||
|------|------|------|
|
||||
| **11.1** | `VectorRetriever` trait + `InMemoryVectorRetriever` 引用实现 | P1 功能补全 |
|
||||
| **11.2** | wiremock Provider roundtrip 测试(12 个场景) | 测试质量补强 |
|
||||
| **11.3** | 并发测试补强(InMemoryStore + SqliteStore) | 并发安全验证 |
|
||||
|
||||
最终目标:全量测试从 254 → 275+,为 v0.2.0 正式版建立更高的质量基线。
|
||||
|
||||
## 需求推演概要
|
||||
|
||||
### Step 11.1 — VectorRetriever trait
|
||||
|
||||
**核心需求**:定义一个与后端无关的语义检索抽象接口,包含 `index(id, embeddings)` 索引和 `search(query, k)` 检索两个方法。附带一个基于 `HashMap` 全量余弦相似度扫描的参考实现。
|
||||
|
||||
**边界识别**:
|
||||
- 只定义 trait,不绑定任何具体后端(pgvector / qdrant / lancedb 留给社区或下游)
|
||||
- 不引入第三方向量数据库依赖
|
||||
- 引用实现的 `search()` 不做索引加速(O(n) 全量扫描已足够验证 trait 契约)
|
||||
- 不与 `MemoryStore` 耦合——`VectorRetriever` 是独立维度
|
||||
- 不嵌入到 `AgentSession` 或 `ContextSlot`(Phase 11 不承担集成消费端)
|
||||
|
||||
**关键假设**:
|
||||
- `Vec<f32>` 作为 embedding 类型已足够(大部分 embedding 模型输出 f32 向量)
|
||||
- 余弦相似度作为默认评分函数可覆盖主流场景
|
||||
- InMemoryVectorRetriever 的 `Mutex<HashMap>` 在 ~10K 向量内性能可接受
|
||||
|
||||
### Step 11.2 — wiremock Provider roundtrip 测试
|
||||
|
||||
**核心需求**:补充 12 个 wiremock 测试,覆盖目前缺失的关键回归场景——结构化 JSON 错误体解析、请求头验证、429 限流头解析、流式边界、工具调用端到端。
|
||||
|
||||
**边界识别**:
|
||||
- 只做 HTTP mock 层验证,不做端到端 LLM 模型调用
|
||||
- 每个测试自包含(启动自己的 MockServer),不抽共享 helper
|
||||
- 测试集中在 OpenAI(`GenericOpenaiProvider`)和 Anthropic(独立实现)两个核心 Provider 上
|
||||
- DeepSeek/Qwen/Ollama 同属 OpenAI Compat,继承 `GenericOpenaiProvider` 的测试覆盖
|
||||
|
||||
**关键假设**:
|
||||
- wiremock 的 `body_partial_json` matcher 可用且稳定(当前 dev-dependencies 中已有 wiremock)
|
||||
- OpenAI 和 Anthropic 的结构化错误体格式在当前 SDK 版本中未变化
|
||||
|
||||
### Step 11.3 — 并发测试补强
|
||||
|
||||
**核心需求**:验证 `MemoryStore` 两种实现(InMemoryStore + SqliteStore)在多线程并发写和混合读写场景下的正确性。
|
||||
|
||||
**边界识别**:
|
||||
- 不测试 `KnowledgeStore` / `ConversationMemory` 的并发——它们的行为完全由 `MemoryStore` 决定,不引入新 race 条件
|
||||
- 不测试 TTL 淘汰的并发正确性(TTL 淘汰使用 wall clock,非原子,不保证精确)
|
||||
- 混合读写测试只验证"无 panic + 数量正确",不验证"读到的结果恰好与写顺序一致"(后者需要强一致快照,当前 Mutex 模型不提供)
|
||||
|
||||
**关键假设**:
|
||||
- `tokio::spawn` 100 个 task 同时写入 `Mutex<HashMap>`(InMemoryStore)不会死锁
|
||||
- SqliteStore 的 WAL 模式 + `busy_timeout=5000` 足够容忍 100 并发写
|
||||
|
||||
## 当前状态分析
|
||||
|
||||
### 测试覆盖率现状
|
||||
|
||||
| 维度 | 当前值 | Phase 11 目标 |
|
||||
|------|--------|-------------|
|
||||
| 全量测试 | 254 passed | 275+ passed |
|
||||
| InMemoryStore 测试 | 6 个(save/get/list/upsert/eviction/TTL) | +4 个并发 |
|
||||
| SqliteStore 测试 | 9 个(含 1 个 10×10 并发) | +1 个 100 并发 |
|
||||
| OpenAI wiremock 测试 | 4 个(basic/401/500/stream) | +8 个 |
|
||||
| Anthropic wiremock 测试 | 4 个(basic/401/529/stream) | +4 个 |
|
||||
| 请求头验证测试 | 0 个 | +2 个 |
|
||||
| ToolUse 端到端 mock 测试 | 0 个 | +2 个(OpenAI + Anthropic) |
|
||||
|
||||
### Provider 测试缺口
|
||||
|
||||
现有 wiremock 测试仅覆盖最基础的响应路径,以下关键场景缺失回归保护:
|
||||
|
||||
| 场景 | 缺失风险 |
|
||||
|------|---------|
|
||||
| OpenAI 请求体格式验证 | `body_partial_json` 未匹配,请求体结构变化无声 |
|
||||
| Authorization header 验证 | header 注入被修改时不告警 |
|
||||
| 结构化 401 JSON 错误体 | `error.message`/`error.code` 未消费,错误消息丢失 |
|
||||
| 429 + `retry-after` 头 | `RateLimit.retry_after` 字段不准确 |
|
||||
| ToolUse 端到端 mock | tool_flow 解析路径无回归 |
|
||||
| 流式 last chunk usage-only | `{choices:[], usage:{...}}` 可能 panic |
|
||||
|
||||
### MemoryStore 并发测试缺口
|
||||
|
||||
| Store | 当前并发测试 | 覆盖度 | 风险 |
|
||||
|-------|-------------|--------|------|
|
||||
| InMemoryStore | 0 个 | 无 | `Mutex` 锁竞争、deadlock、写入丢失 |
|
||||
| SqliteStore | 1 个(10 写者 × 10 次 = 100 条) | 中等 | `spawn_blocking` 线程池耗尽、WAL 锁等待超时 |
|
||||
|
||||
### 向量检索现状
|
||||
|
||||
`memory` 模块已有 `MemoryRetriever`(关键词检索)和 `retriever.rs` 中的 `RetrievalResult`/`ScoredItem` 类型。但语义向量检索维度完全空缺——无 trait、无引用实现、无测试。`docs/roadmap.md` 将 VectorRetriever 列为 P1,与 ContextSlot(P1,Phase 10)同级。
|
||||
|
||||
## 架构决策记录
|
||||
|
||||
| 决策 | 选择 | 放弃 | 理由 |
|
||||
|------|------|------|------|
|
||||
| 1. VectorRetriever trait 参数类型 | `Vec<f32>` 裸向量 | `Embedding` newtype | 包装类型增加可见复杂度但未提供运行时保护;大部分 embedding 模型输出 f32 向量;下游可自行包装 |
|
||||
| 2. `search()` 返回类型 | `Vec<(String, f32)>` | `ScoredItem`/`RetrievalResult` 命名 struct | `(String, f32)` 是 (id, score) 的最小表达;Phase 3 的 `RetrievalResult` 绑定了 `KnowledgePage` 引用,不适合向量检索场景;tuple 在 consumer 侧模式匹配更简洁 |
|
||||
| 3. 文件归属 | 新文件 `memory/vector.rs` | 合入 `memory/retriever.rs` | `retriever.rs` 已承载 302 行关键词检索代码,语义维度独立不应耦合;`vector.rs` 作为独立模块便于后期扩展(pgvector adapter 等) |
|
||||
| 4. 是否附带引用实现 | `InMemoryVectorRetriever` | trait-only | trait-only 是纯推测代码,无 consumer 验证引用实现作为"编译期测试"验证 trait 方法签名可用 |
|
||||
| 5. InMemoryVectorRetriever 余弦相似度实现方式 | 手动三行点积/范数 | `ndarray`/`approx` 等第三方依赖 | 余弦相似度数学固定,不需要外部依赖;1e-10 防零除;零新依赖原则 |
|
||||
| 5a | 引用实现不做向量维度校验 | 运行时维度检查 | 维度校验是具体后端(pgvector等)的职责;引用实现面向测试/验证场景;调用方负责传入等长向量 |
|
||||
| 6. 错误类型 | 复用 `MemoryError` 现有变体 | 新增 `VecRetrieval` 变体 | 向量检索与关键词检索语义等价于"检索";`RetrievalError` 变体已覆盖索引/评分异常场景 |
|
||||
| 7. wiremock 测试组织 | 自包含(每个测试启动自己的 MockServer) | 共享 helper 函数 | 沿用现有测试模式(openai.rs line 825+、anthropic.rs line 900+);自包含测试可独立运行、定位更直接 |
|
||||
| 8. 请求头验证 | 做(`body_partial_json` + `header` matcher) | 跳过 | 回归防御价值高——Provider 请求体结构变化会直接导致请求被拒绝,头验证是低成本高收益的回归保护 |
|
||||
| 9. 并发测试模式 | 100 并发写 + 混合读写(5 读 + 5 写)双模式 | 只做 100 并发写 | 两种模式互补:纯写入验证数据完整性和无 id 重复;混合读写验证读操作在并发写期间不 panic 且返回有效数据 |
|
||||
| 10. 实施顺序 | 11.1 → 11.2 → 11.3 | 任意顺序 | 与 roadmap 原定的 Step 顺序一致;11.1 是纯新增可独立交付;11.2/11.3 是对既有代码的测试追加,可并行但不优先于 11.1 |
|
||||
|
||||
## 设计方案
|
||||
|
||||
### Step 11.1 — VectorRetriever trait + InMemoryVectorRetriever
|
||||
|
||||
#### 文件位置
|
||||
|
||||
- 新增:`src/memory/vector.rs`
|
||||
- 修改:`src/memory.rs`(+2 行:module 声明 + re-export)
|
||||
|
||||
#### Trait 定义
|
||||
|
||||
```rust
|
||||
/// 语义向量检索器抽象接口。
|
||||
///
|
||||
/// 下游可实现此 trait 以对接向量数据库(pgvector / qdrant / lancedb 等)。
|
||||
/// 默认引用实现 [`InMemoryVectorRetriever`] 基于进程内 HashMap + 余弦相似度。
|
||||
///
|
||||
/// **稳定性**:实验性 API(v0.2.x),方法签名可能在 v0.3 中调整。
|
||||
/// 若未来需要 `remove()` / `clear()` 等方法,将在此 trait 中追加(带默认实现)。
|
||||
#[async_trait]
|
||||
pub trait VectorRetriever: Send + Sync {
|
||||
/// 将 `id` 对应的文本向量 `embeddings` 加入索引。
|
||||
async fn index(&self, id: String, embeddings: Vec<f32>) -> Result<(), MemoryError>;
|
||||
|
||||
/// 检索与 `query` 向量最相似的 `k` 条记录。
|
||||
/// 返回 `Vec<(id, score)>`,按 score 降序排列,score ∈ [0.0, 1.0]。
|
||||
async fn search(&self, query: Vec<f32>, k: usize) -> Result<Vec<(String, f32)>, MemoryError>;
|
||||
}
|
||||
```
|
||||
|
||||
#### InMemoryVectorRetriever 实现要点
|
||||
|
||||
```rust
|
||||
pub struct InMemoryVectorRetriever {
|
||||
vectors: Mutex<HashMap<String, Vec<f32>>>,
|
||||
}
|
||||
|
||||
impl InMemoryVectorRetriever {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
vectors: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VectorRetriever for InMemoryVectorRetriever {
|
||||
async fn index(&self, id: String, embeddings: Vec<f32>) -> Result<(), MemoryError> {
|
||||
let mut vectors = self.vectors.lock().map_err(|e| {
|
||||
MemoryError::RetrievalError(format!("lock poisoned: {e}"))
|
||||
})?;
|
||||
vectors.insert(id, embeddings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn search(&self, query: Vec<f32>, k: usize) -> Result<Vec<(String, f32)>, MemoryError> {
|
||||
let vectors = self.vectors.lock().map_err(|e| {
|
||||
MemoryError::RetrievalError(format!("lock poisoned: {e}"))
|
||||
})?;
|
||||
|
||||
if vectors.is_empty() || k == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let query_norm = dot(&query, &query).sqrt();
|
||||
if query_norm == 0.0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut scored: Vec<(String, f32)> = vectors
|
||||
.iter()
|
||||
.map(|(id, vec)| {
|
||||
let dot_product = dot(&query, vec);
|
||||
let vec_norm = dot(vec, vec).sqrt();
|
||||
let similarity = dot_product / (query_norm * vec_norm + 1e-10);
|
||||
(id.clone(), similarity)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 降序排列
|
||||
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
scored.truncate(k);
|
||||
Ok(scored)
|
||||
}
|
||||
}
|
||||
|
||||
/// 点积(手动循环,零依赖)。
|
||||
///
|
||||
/// 注意:`zip` 对不等长向量静默截断到较短者。引用实现不做维度校验,
|
||||
/// 调用方应确保 `a` 和 `b` 等长——不等长时结果无意义但不 panic。
|
||||
fn dot(a: &[f32], b: &[f32]) -> f32 {
|
||||
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
|
||||
}
|
||||
```
|
||||
|
||||
#### 边界与约束
|
||||
|
||||
- **无维度校验**:不同维度向量传入 `search()` 时点积不报错,但余弦相似度结果无意义。维度校验是具体后端(pgvector等)的职责,引用实现不做运行时检查。
|
||||
- **零向量处理**:query 为零向量时直接返回空结果(`query_norm == 0.0`)。
|
||||
- **1e-10 防零除**:避免空库或全零向量导致除零 panic。
|
||||
|
||||
#### 测试(4 个)
|
||||
|
||||
| # | 测试名 | 验证点 |
|
||||
|---|--------|--------|
|
||||
| 1 | `basic_index_and_search` | index 两条("rust" + "python"),用 "rustacean" 查询应排在首位 |
|
||||
| 2 | `search_empty_store` | 空库返回空 Vec |
|
||||
| 3 | `concurrent_index` | 10 个 task 各 index 1 条,总量 10、id 无重复 |
|
||||
| 4 | `concurrent_index_and_search` | 10 个 writer + 5 个 searcher 并发 2 秒,`search()` 遍历期间 `index()` 写入锁竞争不 panic |
|
||||
|
||||
#### 修改 `src/memory.rs`
|
||||
|
||||
```rust
|
||||
pub mod vector;
|
||||
|
||||
// 在高频 re-export 区追加
|
||||
pub use vector::{InMemoryVectorRetriever, VectorRetriever};
|
||||
```
|
||||
|
||||
### Step 11.2 — wiremock Provider roundtrip 测试
|
||||
|
||||
#### 测试清单
|
||||
|
||||
全部 12 个测试均遵循现有自包含模式:`MockServer::start()` → `Mock::given(...).and(...).respond_with(...)` → `provider.chat_blocking(...)` / `provider.chat_stream_inner(...)` → assert。
|
||||
|
||||
##### P0(7 个)
|
||||
|
||||
| # | 测试名 | 所属文件 | Mock 关键点 | 断言 |
|
||||
|---|--------|---------|------------|------|
|
||||
| 1 | `openai_request_body_format` | `openai.rs` | `body_partial_json` 匹配 `{"model": "gpt-4o", "messages": [{"role": "user"}]}` | 请求体结构正确,响应解析正常 |
|
||||
| 2 | `openai_authorization_header` | `openai.rs` | `header("authorization", "Bearer sk-test")` | header 精确匹配,响应解析正常 |
|
||||
| 3 | `openai_401_structured_error` | `openai.rs` | 返回 401 + `{"error": {"message": "Incorrect API key", "code": "invalid_api_key"}}` | `LlmError::Authentication(msg)` 且 message 包含 "Incorrect API key" |
|
||||
| 4 | `anthropic_401_structured_error` | `anthropic.rs` | 返回 401 + `{"error": {"type": "authentication_error", "message": "Invalid API key provided"}}` | `LlmError::Authentication(msg)` 且 message 包含 "Invalid API key" |
|
||||
| 5 | `openai_429_with_retry_after` | `openai.rs` | 返回 429 + `{"error": {"message": "Rate limit exceeded"}}` + `retry-after: 30` 头 | `LlmError::RateLimit { retry_after: Some(30s) }` |
|
||||
| 6 | `openai_tool_use_response` | `openai.rs` | 返回包含 `tool_calls` 的响应(choices[0].message.tool_calls ≠ null) | `StopReason::ToolUse` + `ContentBlock::ToolUse` 正确解析 |
|
||||
| 7 | `anthropic_tool_use_response` | `anthropic.rs` | 返回含 `type: "tool_use"` content block + `stop_reason: "tool_use"`(Anthropic 独立 wire 格式) | `StopReason::ToolUse` + `ContentBlock::ToolUse` 正确解析 |
|
||||
|
||||
##### P1(5 个)
|
||||
|
||||
| # | 测试名 | 所属文件 | Mock 关键点 | 断言 |
|
||||
|---|--------|---------|------------|------|
|
||||
| 8 | `anthropic_version_header` | `anthropic.rs` | `header("anthropic-version", "2023-06-01")` | header 精确匹配 |
|
||||
| 9 | `openai_stream_usage_only_last_chunk` | `openai.rs` | 流式最后 chunk `{"choices":[],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}` | 不 panic;`MessageComplete` 包含正确 usage |
|
||||
| 10 | `anthropic_529_overloaded_structured` | `anthropic.rs` | 返回 529 + `{"error": {"type": "overloaded_error", "message": "Overloaded"}}` | `LlmError::RateLimit { retry_after: None }` |
|
||||
| 11 | `openai_500_structured_error` | `openai.rs` | 返回 500 + `{"error": {"message": "Internal server error", "type": "server_error"}}` | `LlmError::Request { status: 500, body }` 且 body 包含 "Internal server error" |
|
||||
| 12 | `openai_stream_mid_stream_error` | `openai.rs` | 流式前几个 chunk 正常,中途服务端断开连接(模拟网络中断/限流断开) | `LlmError::Request(_)` — 流式中断映射为请求错误 |
|
||||
|
||||
#### 测试模式说明
|
||||
|
||||
```rust
|
||||
// 每个测试自包含,不抽共享 helper(沿用现有模式)
|
||||
#[tokio::test]
|
||||
async fn openai_authorization_header() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/chat/completions"))
|
||||
.and(header("authorization", "Bearer sk-test"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id": "chatcmpl-hdr",
|
||||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": "gpt-4o",
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": "OK"}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = GenericOpenaiProvider::new_with_name(
|
||||
server.uri(), "sk-test".into(), "gpt-4o".into(), "openai", 30,
|
||||
);
|
||||
let response = provider.chat_blocking(MessageRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![Message::user_text("hi")],
|
||||
..Default::default()
|
||||
}).await.unwrap();
|
||||
assert_eq!(response.text(), "OK");
|
||||
}
|
||||
```
|
||||
|
||||
#### 新增依赖
|
||||
|
||||
dev-dependencies 中 wiremock 已就绪(当前 openai.rs / anthropic.rs 已在测试中使用),无需新增。
|
||||
|
||||
### Step 11.3 — 并发测试补强
|
||||
|
||||
#### 测试清单
|
||||
|
||||
| # | 测试名 | Store | 模式 | 验证标准 |
|
||||
|---|--------|-------|------|---------|
|
||||
| 1 | `concurrent_writers_max_pressure` | InMemoryStore | 100 task × 1 write | 总量 100, id 无重复 |
|
||||
| 2 | `concurrent_writers_max_pressure` | SqliteStore | 100 task × 1 write | 总量 100, id 无重复 |
|
||||
| 3 | `concurrent_mixed_read_write` | InMemoryStore | 预热 20 条, 5 读 + 5 写并发 2 秒 | 无 panic |
|
||||
| 4 | `concurrent_mixed_read_write` | SqliteStore | 预热 20 条, 5 读 + 5 写并发 2 秒 | 无 panic |
|
||||
| 5 | `concurrent_capacity_eviction` | InMemoryStore | 15 写者, max_items=10 | 最终 ≤ 10 |
|
||||
|
||||
#### 关键实现要点
|
||||
|
||||
**100 并发写模式**(InMemoryStore + SqliteStore 各一):
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn concurrent_writers_max_pressure() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for i in 0..100 {
|
||||
let s = Arc::clone(&store);
|
||||
handles.push(tokio::spawn(async move {
|
||||
let id = format!("concurrent_{i}");
|
||||
s.save(make_item(&id)).await.unwrap();
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
h.await.unwrap();
|
||||
}
|
||||
|
||||
let list = store.list(&MemoryFilter::default()).await.unwrap();
|
||||
assert_eq!(list.len(), 100);
|
||||
let mut ids: Vec<String> = list.iter().map(|v| v.id.clone()).collect();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 100);
|
||||
}
|
||||
```
|
||||
|
||||
**混合读写模式**(InMemoryStore + SqliteStore 各一):
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn concurrent_mixed_read_write() {
|
||||
let store = Arc::new(InMemoryStore::new());
|
||||
|
||||
// 预热
|
||||
for i in 0..20 {
|
||||
store.save(make_item(&format!("seed_{i}"))).await.unwrap();
|
||||
}
|
||||
|
||||
let mut handles = Vec::new();
|
||||
// 5 个写者
|
||||
for w in 0..5 {
|
||||
let s = Arc::clone(&store);
|
||||
handles.push(tokio::spawn(async move {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
|
||||
let mut i = 0;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
let id = format!("writer{w}_item{i}");
|
||||
s.save(make_item(&id)).await.unwrap();
|
||||
i += 1;
|
||||
}
|
||||
}));
|
||||
}
|
||||
// 5 个读者
|
||||
for r in 0..5 {
|
||||
let s = Arc::clone(&store);
|
||||
handles.push(tokio::spawn(async move {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
let _ = s.list(&MemoryFilter::default()).await.unwrap();
|
||||
}
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
h.await.unwrap();
|
||||
}
|
||||
// 不 panic 即算通过
|
||||
}
|
||||
```
|
||||
|
||||
**容量淘汰并发模式**(InMemoryStore):
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn concurrent_capacity_eviction() {
|
||||
let eviction = EvictionConfig {
|
||||
policy: EvictionPolicy::Capacity { max_items: 10 },
|
||||
check_interval: 1,
|
||||
};
|
||||
let store = Arc::new(InMemoryStore::with_eviction(eviction));
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for i in 0..15 {
|
||||
let s = Arc::clone(&store);
|
||||
handles.push(tokio::spawn(async move {
|
||||
s.save(make_item(&format!("item_{i}"))).await.unwrap();
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
h.await.unwrap();
|
||||
}
|
||||
|
||||
let list = store.list(&MemoryFilter::default()).await.unwrap();
|
||||
assert!(list.len() <= 10);
|
||||
}
|
||||
```
|
||||
|
||||
**测试归属**:
|
||||
- InMemoryStore 并发测试 → `src/memory/store/in_memory.rs` 的 `mod tests`
|
||||
- SqliteStore 并发测试 → `src/memory/store/sqlite_store.rs` 的 `mod tests`
|
||||
- 混合读写测试中的 `make_item` 辅助函数:直接复用各文件现有 `fn make_item`
|
||||
|
||||
## 已否决的方案
|
||||
|
||||
### 1. 砍掉 11.1(PM 建议)
|
||||
|
||||
**内容**:PM 在讨论中提出 VectorRetriever trait 无消费者,建议整体砍掉,等 Phase 12 或 v0.3 有人用时再做。
|
||||
|
||||
**否决理由**:引用实现作为 trait 契约的编译期验证手段——没有 consumer 不意味着 trait 签名不需要测试。同时社区贡献(pgvector adapter 等)需要稳定的 trait 边界。附带引用实现还可作为"如何在 agcore 中实现一个 VectorRetriever"的示例,降低社区参与门槛。代码量仅 ~80 行,维护成本可忽略。
|
||||
|
||||
### 2. trait-only VectorRetriever(无引用实现)
|
||||
|
||||
**内容**:只定义 `VectorRetriever` trait,不做 `InMemoryVectorRetriever`。
|
||||
|
||||
**否决理由**:trait-only 是纯推测代码——没有运行时验证,无法确认 trait 方法签名在实际调用链中是否可编译。理想情况下每个 trait 至少有一个引用实现来验证"这个 trait 确实可以被实现"。
|
||||
|
||||
### 3. 跳过请求头验证
|
||||
|
||||
**内容**:请求体格式和 Authorization header 验证是"过度保护"。
|
||||
|
||||
**否决理由**:`body_partial_json` + `header` matcher 的回归防御价值高。Provider 适配层的最大风险是请求体结构无声变更(如 `ToolDef` IR 切换时漏改了序列化字段),头验证是低成本(每测试 ~5 行)高收益的回归保护。
|
||||
|
||||
### 4. 先发 v0.2.0 正式版再迭代
|
||||
|
||||
**内容**:当前 rc.1 已经包含所有 P0 功能,建议直接发正式版,Phase 11 推迟到 v0.2.1。
|
||||
|
||||
**否决理由**:测试补强是正式版的信号而非负担。Phase 11 的三个 Step 都是"如果现在不做,以后更不会做"的类型。在正式版前补齐测试基线,避免「发布了再补测试」的经典陷阱。
|
||||
|
||||
### 5. 只做 100 并发写,不做混合读写
|
||||
|
||||
**内容**:并发写验证数据完整性已足够。
|
||||
|
||||
**否决理由**:纯写入和混合读写暴露不同类型的 bug。纯写入验证"数据不丢、id 无重复";混合读写验证"读操作在并发写期间不 panic、返回有效数据"。两种模式互补缺失。
|
||||
|
||||
### 6. (实施后补充)openai_stream_mid_stream_error 的 mock 模式偏差
|
||||
|
||||
**实际实施**:返回 `200 + SSE content-type + 畸形 JSON payload`(`data: {not-valid-json}\n\ndata: [DONE]\n\n`),断言 `ChunkToEventStream` 产出 `StreamEvent::Error`。
|
||||
|
||||
**方案原文**:返回"前几个 chunk 正常,中途服务端断开连接",断言 `LlmError::Request(_)`。
|
||||
|
||||
**偏差原因**:wiremock 0.6 标准 responder 的 `set_delay` / `set_body_string` 行为是「延迟响应 + 发完 body 后关闭连接」,无法精确模拟「send partial body then hang 保持连接」。`read_timeout` 配合 `set_delay` 触发的超时属于 send 阶段(`LlmError::Timeout`),不属于流式中断。
|
||||
|
||||
**采纳方案**:用畸形 JSON payload 替代——同样验证"流中途产生错误事件而不 panic"的回归保护意图,且在 wiremock 0.6 上 100% 可重现。断言改为 `StreamEvent::Error{message}` + "stream 最终结束",保持核心回归价值。
|
||||
|
||||
**影响**:测试意图(流阶段错误检测)完全保留;mock 行为从「TCP 断开」变为「畸形应用层数据」;断言从 `LlmError::Request` 改为 `StreamEvent::Error`(语义等价:客户端发现流异常)。
|
||||
|
||||
### 7. (实施后补充)openai.rs 的 429 retry-after 解析修复
|
||||
|
||||
**实际实施**:`handle_error_response` 新增 `retry-after` header 解析逻辑(与 anthropic.rs 完全对齐)。
|
||||
|
||||
**方案原文**:方案测试 11.2.4 要求 `RateLimit { retry_after: Some(30s) }`,但 ADC 表中未显式列出此修复作为生产代码变更。
|
||||
|
||||
**修复原因**:原 `openai.rs:186-191` 的 429 分支固定 `retry_after: None`,与 `anthropic.rs:339-351` 已有的解析逻辑不一致。原代码注释甚至已写"仅读取 retry-after",但实际未实现——这是隐藏 bug。修复让 OpenAI 兼容层(DeepSeek/Qwen 等)的限流重试信息可用,与 Anthropic 行为统一。
|
||||
|
||||
**影响**:方案测试 11.2.4 从「不可通过的回归保护」变为「可验证的实际行为」。变更 5 行,与 anthropic 实现完全镜像。
|
||||
|
||||
## 实施计划与顺序
|
||||
|
||||
**实施顺序**:11.1 → 11.2 → 11.3(与 roadmap 一致,每步可单独交付验证)。
|
||||
|
||||
| Step | 文件变更 | 测试增量 | 预估代码量 | 验证标准 |
|
||||
|------|---------|---------|-----------|---------|
|
||||
| 11.1 | +`src/memory/vector.rs`(~80 行),~`src/memory.rs`(+2 行) | +4 | ~85 行实现 + 70 行测试 | `cargo build --all-targets` 编译通过,4 个测试通过 |
|
||||
| 11.2 | ~`src/llm/provider/openai.rs`(+6 个测试),~`src/llm/provider/anthropic.rs`(+2 个测试) | +12(7 P0 + 5 P1) | ~290 行(含 test mod 和 mock 数据) | `cargo test --all-targets` 全绿,wiremock 12 场景均绿 |
|
||||
| 11.3 | ~`src/memory/store/in_memory.rs`(+3 个测试),~`src/memory/store/sqlite_store.rs`(+2 个测试) | +5 | ~120 行 | `cargo test --all-targets` 全绿 |
|
||||
| **总计** | 6 个文件(1 新增 + 5 修改) | +21 | ~500 行 | 全量 254 → 275+,`cargo clippy --all-targets -- -D warnings` 0 警告 |
|
||||
|
||||
### 验证通过标准
|
||||
|
||||
1. `cargo build --all-targets` —— 编译通过,无 warning
|
||||
2. `cargo test --all-targets` —— 全部通过(254 + 21 = 275+)
|
||||
3. `cargo clippy --all-targets -- -D warnings` —— 0 警告
|
||||
4. `cargo test --all-targets 2>&1 | grep -E "test result:"` —— 确认新增测试全部出现在执行列表中
|
||||
5. 新增 wiremock 测试单独验证网络隔离(无需 API key,纯本地 mock)
|
||||
|
||||
## 参考来源
|
||||
|
||||
- **讨论收口结论**:Phase 11 讨论,含 PM/SA 双视角输入(2026-07-07)
|
||||
- **现有代码模式**:
|
||||
- `src/llm/provider/openai.rs` line 824-1034——wiremock 测试模式(`MockServer::start` → `Mock::given(...).and(...).respond_with(...)` → `provider.chat_blocking` → assert)
|
||||
- `src/llm/provider/anthropic.rs` line 899-1079——Anthropic provider wiremock 测试
|
||||
- `src/memory/store/sqlite_store.rs` line 458-483——`concurrent_writers_no_data_loss` 10×10 并发模式
|
||||
- `src/memory/store.rs`——`MemoryStore` trait 定义(`#[async_trait]` 风格)
|
||||
- `src/memory/error.rs`——`MemoryError` 枚举(`#[non_exhaustive]` + `RetrievalError` 变体)
|
||||
- `src/memory/retriever.rs`——现有检索模块(`RetrievalResult` / `ScoredItem`)
|
||||
- `src/memory.rs`——模块根与 re-export 模式
|
||||
- **方案文档**:`docs/roadmap.md` Phase 11 章节(line 516-528)
|
||||
- **编译器 pragma**:`#[non_exhaustive]` —— 新增枚举变体需要此标记,公开结构体字段未来变化预留兼容空间
|
||||
|
||||
## 关键假设与风险
|
||||
|
||||
### 关键假设清单
|
||||
|
||||
| # | 假设 | 影响 | 推翻后的应对 |
|
||||
|---|------|------|------------|
|
||||
| 1 | wiremock `body_partial_json` matcher 在 wiremock 0.6+ 中可用 | Step 11.2 测试 #1 的实现方式 | 改用 `body_json`(精确匹配)或 `body_string`(部分串匹配) |
|
||||
| 2 | `tokio::spawn` 100 task 并发写入 `Mutex<HashMap>` 无死锁 | Step 11.3 #1 InMemoryStore 并发 | 降低并发数(50)继续验证,或换 `tokio::sync::Mutex` |
|
||||
| 3 | SqliteStore 的 `busy_timeout=5000` 能容忍 100 并发写 | Step 11.3 #2 SqliteStore 并发 | 增加 `busy_timeout`(10s),或限制最大并发数 |
|
||||
| 4 | 新增测试不使用 wiremock 以外的未列在 dev-dependencies 中的依赖 | Phase 11 零新增外部依赖 | 若需要额外 matcher,评估后加入 dev-dependencies |
|
||||
| 5 | InMemoryVectorRetriever 的 O(n) 全量扫描在测试规模下 (<1000 向量) 性能可接受 | Step 11.1 测试通过 | 如果有竞态问题,改为 read/write lock(`RwLock<HashMap>`) |
|
||||
| 6 | `MemoryError::RetrievalError` 变体足够覆盖向量检索的索引/评分失败场景 | Step 11.1 错误映射 | 如果不够,可增加新的 `MemoryError` 变体 |
|
||||
| 7 | OpenAI 和 Anthropic 的结构化错误体格式在当前 SDK 版本中未变化 | Step 11.2 测试 #3/#4/#7/#10/#11(结构化错误解析断言) | 若 SDK 变更错误体格式,更新 mock body 和断言匹配新格式 |
|
||||
| 8 | 调用方传入 `search()` 的向量与已索引向量维度一致(引用实现不做维度校验,`dot()` 对不等长向量静默截断) | Step 11.1 InMemoryVectorRetriever 正确性 | 若须维度校验,在 `index()` 时记录维度并在 `search()` 时断言;引用实现维持零校验 |
|
||||
|
||||
### 已识别的风险
|
||||
|
||||
| 风险 | 等级 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| wiremock `body_partial_json` matcher 行为在版本升级后变化 | 低 | 限定 wiremock 版本范围(当前已在 Cargo.lock 中锁定);P0 测试不依赖该 matcher |
|
||||
| 100 并发写暴露 SqliteStore 的 `spawn_blocking` 线程池瓶颈 | 中 | 观察 CI 执行时间;如果超时,降低并发到 50 或增加 `max_blocking_threads` |
|
||||
| InMemoryVectorRetriever 的 `Mutex` 锁争用导致测试 flaky | 低 | Mutex 不会死锁(单线程持有不 await),测试不依赖精确时序 |
|
||||
| 新增 wiremock 测试与现有测试冲突(端口占用) | 低 | `MockServer::start()` 自动选择随机端口,不冲突 |
|
||||
| `cargo test --all-targets` 执行时间增加 >30% | 低 | 预估 +21 个测试,增量约 8%(254→275),其中 wiremock 测试有网络 IO 但延迟 <10ms/个 |
|
||||
|
||||
### 非阻塞已知项
|
||||
|
||||
- **Ollama Provider** 是 OpenAI Compat,wiremock 测试继承 `GenericOpenaiProvider`,不单独新增
|
||||
- **DeepSeek / Qwen Provider** 同样通过 `GenericOpenaiProvider` 实现,继承测试
|
||||
- **Step 11.1 不消费到 AgentSession / ContextSlot**,留待 Phase 12 或 v0.3 做消费端集成
|
||||
- **Phase 11 完成后**,全量测试预计 275+,`cargo test --all-targets` 执行时间预计 < 60s
|
||||
|
||||
---
|
||||
|
||||
## 实施计划(附录)
|
||||
|
||||
**实施顺序**:11.1 → 11.2 ‖ 11.3(11.2 与 11.3 无文件冲突,可并行交付;11.2 优先因回归保护价值更高)。每步完成后运行 `cargo test --all-targets` + `cargo clippy --all-targets -- -D warnings` 验证无回归。
|
||||
|
||||
---
|
||||
|
||||
### Step 11.1 — VectorRetriever trait + InMemoryVectorRetriever(~160 行,4 测试)
|
||||
|
||||
**前置**:无。与 Step 11.2/11.3 可并行开发但优先交付。
|
||||
|
||||
| 任务 | 描述 | 文件 | 前置 | 工作量 | 风险 | 验收条件 |
|
||||
|------|------|------|------|--------|------|---------|
|
||||
| **11.1.1** | 创建 `memory/vector.rs`:定义 `VectorRetriever` trait + `InMemoryVectorRetriever` struct + `dot()` 辅助函数 | `src/memory/vector.rs` | 无 | S | 低 | `cargo build --all-targets` 编译通过 |
|
||||
| **11.1.2** | 实现 `InMemoryVectorRetriever`:`index()` — Mutex insert;`search()` — 全量余弦扫描 + 降序排列 + k 截断 | `src/memory/vector.rs` | 11.1.1 | S | 低 | trait 实现编译通过;`Mutex::lock()` 使用 `map_err` 处理 poison,不 panic |
|
||||
| **11.1.3** | 添加 4 个内联测试:`basic_index_and_search`、`search_empty_store`、`concurrent_index`、`concurrent_index_and_search` | `src/memory/vector.rs` (mod tests) | 11.1.2 | S | 低 | 4 测试全部通过 |
|
||||
| **11.1.4** | 修改 `src/memory.rs`:加 `pub mod vector;` + `pub use vector::{VectorRetriever, InMemoryVectorRetriever};` | `src/memory.rs` | 11.1.1 | S | 低 | `cargo build --all-targets` 无 warning |
|
||||
|
||||
**Step 验证**:
|
||||
```
|
||||
cargo test --all-targets # 254 + 4 = 258+ passed
|
||||
cargo clippy --all-targets -- -D warnings # 0 warning
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 11.2 — wiremock Provider roundtrip 测试(12 测试,P0=7 + P1=5)
|
||||
|
||||
**前置**:无。与 Step 11.1 无文件冲突,可并行。
|
||||
|
||||
**`openai.rs` 新增测试(8 个:P0=5 + P1=3)**:
|
||||
|
||||
| 任务 | 测试名 | 优先级 | 前置 | 工作量 | 风险 | Mock 模式 | 验收条件 |
|
||||
|------|--------|--------|------|--------|------|----------|---------|
|
||||
| **11.2.1** | `openai_request_body_format` | P0 | 无 | S | 低 | `body_partial_json` 匹配 model/messages | 请求体结构正确,响应解析正常 |
|
||||
| **11.2.2** | `openai_authorization_header` | P0 | 无 | S | 低 | `header("authorization", "Bearer sk-test")` | header 精确匹配 |
|
||||
| **11.2.3** | `openai_401_structured_error` | P0 | 无 | S | 低 | 401 + `{"error":{"message":"...","code":"invalid_api_key"}}` | `LlmError::Authentication` 含 "Incorrect API key" |
|
||||
| **11.2.4** | `openai_429_with_retry_after` | P0 | 无 | S | 低 | 429 + `retry-after: 30` | `RateLimit { retry_after: Some(30s) }` |
|
||||
| **11.2.5** | `openai_tool_use_response` | P0 | 无 | S | 低 | 响应含 `tool_calls` | `StopReason::ToolUse` + `ContentBlock::ToolUse` 正确解析 |
|
||||
| **11.2.6** | `openai_stream_usage_only_last_chunk` | P1 | 无 | S | 低 | 流式最后 chunk `{choices:[], usage:{...}}` | 不 panic,`MessageComplete` 含正确 usage |
|
||||
| **11.2.7** | `openai_500_structured_error` | P1 | 无 | S | 低 | 500 + `{"error":{"message":"server error"}}` | `Request { status: 500 }` 含 body |
|
||||
| **11.2.8** | `openai_stream_mid_stream_error` | P1 | 无 | M | 中 | 前几个 chunk 正常后连接断开 | `LlmError::Request(_)` 流中断映射 |
|
||||
|
||||
**`anthropic.rs` 新增测试(4 个:P0=2 + P1=2)**:
|
||||
|
||||
| 任务 | 测试名 | 优先级 | 前置 | 工作量 | 风险 | Mock 模式 | 验收条件 |
|
||||
|------|--------|--------|------|--------|------|----------|---------|
|
||||
| **11.2.9** | `anthropic_401_structured_error` | P0 | 无 | S | 低 | 401 + `{"error":{"type":"authentication_error","message":"..."}}` | `LlmError::Authentication` 消息透传 |
|
||||
| **11.2.10** | `anthropic_tool_use_response` | P0 | 无 | S | 低 | 响应含 `type:"tool_use"` content block + `stop_reason:"tool_use"` | `StopReason::ToolUse` + `ContentBlock::ToolUse` 正确解析 |
|
||||
| **11.2.11** | `anthropic_version_header` | P1 | 无 | S | 低 | `header("anthropic-version", "2023-06-01")` | header 精确匹配 |
|
||||
| **11.2.12** | `anthropic_529_overloaded_structured` | P1 | 无 | S | 低 | 529 + `{"error":{"type":"overloaded_error","message":"Overloaded"}}` | `RateLimit { retry_after: None }` |
|
||||
|
||||
**Step 验证**:
|
||||
```
|
||||
cargo test --all-targets # 258 + 12 = 270+ passed
|
||||
cargo clippy --all-targets -- -D warnings # 0 warning
|
||||
```
|
||||
每个测试自包含(`MockServer::start()` → `Mock::given(...)` → `provider.chat_blocking()/chat_stream_inner()` → assert),无需共享 helper。
|
||||
|
||||
---
|
||||
|
||||
### Step 11.3 — 并发测试补强(5 测试)
|
||||
|
||||
**前置**:无。与 Step 11.1/11.2 无文件冲突。
|
||||
|
||||
| 任务 | 测试名 | 文件 | 前置 | 工作量 | 风险 | 模式 | 验收条件 |
|
||||
|------|--------|------|------|--------|------|------|---------|
|
||||
| **11.3.1** | `concurrent_writers_max_pressure` | `in_memory.rs` | 无 | S | 中 | 100 task × 1 write | 总量 100,id 无重复 |
|
||||
| **11.3.2** | `concurrent_writers_max_pressure` | `sqlite_store.rs` | 无 | S | 中 | 100 task × 1 write | 总量 100,id 无重复 |
|
||||
| **11.3.3** | `concurrent_mixed_read_write` | `in_memory.rs` | 无 | S | 中 | 预热 20 条,5 写 + 5 读并发 2 秒 | 无 panic |
|
||||
| **11.3.4** | `concurrent_mixed_read_write` | `sqlite_store.rs` | 无 | S | 中 | 预热 20 条,5 写 + 5 读并发 2 秒 | 无 panic |
|
||||
| **11.3.5** | `concurrent_capacity_eviction` | `in_memory.rs` | 无 | S | 中 | 15 写者,max_items=10 | 最终 ≤ 10(竞争激烈时可能过渡态 >10,主断言 ≤ 10,宽松备选 ≤ 15) |
|
||||
|
||||
**实现要点**:
|
||||
- 沿用现有 `Arc<Store> + tokio::spawn + h.await.unwrap()` 模式(参考 `sqlite_store.rs:458-483`)
|
||||
- `make_item` 辅助函数直接复用各文件现有实现
|
||||
- SqliteStore 测试使用 `:memory:` 数据库(与现有并发测试一致)
|
||||
- 混合读写测试使用 `tokio::time::Instant::now() + Duration` 做时限
|
||||
- 100 并发写是一次性 spawn 100 task(非分批),暴露最大锁竞争压力
|
||||
|
||||
**Step 验证**:
|
||||
```
|
||||
cargo test --all-targets # 270 + 5 = 275+ passed
|
||||
cargo clippy --all-targets -- -D warnings # 0 warning
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 整体发布核查清单
|
||||
|
||||
| # | 检查项 | 验证命令 | 预期结果 |
|
||||
|---|--------|---------|---------|
|
||||
| 1 | 编译 | `cargo build --all-targets` | 通过,0 warning |
|
||||
| 2 | 全量测试 | `cargo test --all-targets` | 275+ passed,0 failed |
|
||||
| 3 | Lint | `cargo clippy --all-targets -- -D warnings` | 0 warning |
|
||||
| 4 | 文档 | `cargo doc --no-deps` | 0 warning(VectorRetriever trait 公共 API doc 完整) |
|
||||
| 5 | 确认新增测试 | `cargo test --all-targets 2>&1 \| grep -E "test result:"` | 所有新增测试名出现在执行列表中 |
|
||||
| 6 | wiremock 隔离 | 新增 wiremock 测试不依赖网络 | 纯本地 mock,无需 API key |
|
||||
| 7 | 并行安全 | 并发测试独立运行时无 flaky | 连续 3 次 `cargo test` 结果一致 |
|
||||
| 8 | 存量零回归 | 已有 254 个测试全部通过 | 与 Phase 10 基线对比无 fail |
|
||||
| 9 | 公共 API doc comment | `grep -r "pub trait VectorRetriever" src/ && rg "^///" -c src/memory/vector.rs` | trait 和方法都有 `///` 注释 |
|
||||
|
||||
**若核查项失败的回退策略**:
|
||||
- **测试失败(P0)**:阻断发布。定位到具体测试名 → 检查 Mock JSON 格式与 Provider 解析逻辑是否匹配(结构化错误体格式变化 → 更新 mock body;流式状态机变化 → 更新 `chat_stream_inner` 路径测试)
|
||||
- **测试失败(P1)**:不阻断发布。标记 `#[ignore]` + file issue,确认无 P0 失败后即可发布
|
||||
- **clippy warning**:修复 lint 后重跑;若为 `#[allow(...)]` 可抑制,在 code review 中申明理由
|
||||
- **flaky 并发测试**:检查 `tokio::spawn` 是否跨 `.await` 持锁;若 SqliteStore 超时,增加 `busy_timeout` 或降低并发数
|
||||
- **11.1 模块发布阻塞**:若 `InMemoryVectorRetriever` 无法按时交付,可临时注释 `src/memory.rs` 中的 `pub mod vector;` 行,跳过整个模块(零消费者,不影响发布)。回退后再补交
|
||||
@@ -0,0 +1,640 @@
|
||||
# Phase 13 — 热身清理 + ContextSlot fork/merge 实施方案
|
||||
|
||||
- **文档编号**:19
|
||||
- **标题**:Phase 13 — 热身清理 + ContextSlot fork/merge 实施方案
|
||||
- **日期**:2026-07-08
|
||||
- **状态**:待实施
|
||||
- **涉及模块**:agent/context、agent/session、llm/types、llm/provider/openai、llm/stream
|
||||
- **关联文档**:roadmap.md(§Phase 13)、17-phase10-contextslot.md
|
||||
- **对应**:Roadmap §Phase 13(v0.3.0 第一阶段)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
v0.3.0 是 agcore 从"LLM 调用工具箱"升级为"多 Agent 基础系统"的关键版本。Phase 13 是 v0.3.0 的第一阶段,定位为"热身",包含两大部分:
|
||||
|
||||
- **技术债清理**:删除 Phase 0 遗留的旧 types 文件(`request.rs`、`response.rs`、`old_stream.rs`),以及已标记 `#[deprecated]` 的 `ChatResponse` 结构体
|
||||
- **ContextSlot fork/merge**:为 ContextSlot 增加分叉和合并能力,为后续 Phase 17 Checkpointer 和 Phase 18 SubAgent Dispatch 打基础
|
||||
|
||||
**依赖关系**:无(独立交付)
|
||||
|
||||
**优先级**:P0
|
||||
|
||||
**预估规模**:净减 ~200 行代码(新增 ~505 行,删除 ~704 行)
|
||||
|
||||
---
|
||||
|
||||
## 2. 需求分析
|
||||
|
||||
### 2.1 功能需求
|
||||
|
||||
1. **技术债清理**:删除 `src/llm/types/request.rs`(187 行)、`response.rs`(177 行)、`old_stream.rs`(45 行),将其中的 OpenAI wire-format 类型移入 `src/llm/provider/openai.rs`;删除 `types/mod.rs` 中的 `ChatResponse` 废弃结构体
|
||||
2. **`ContextSlot::fork`**:从现有 context slot 分支出独立的子 slot
|
||||
3. **`ContextSlot::merge`**:将子 slot 的消息合并回父 slot
|
||||
4. **`MergeStrategy`** 枚举:Append(追加)/ Replace(替换),`#[non_exhaustive]` 预留 Phase 16 Summarize 扩展
|
||||
|
||||
### 2.2 非功能需求
|
||||
|
||||
- **每步可编译**:5 个 Step 按物理文件切割,每步 `cargo build --all-targets + cargo test` 验证
|
||||
- **指定公共 API 路径保持向后兼容**:`agcore::llm::types::ToolChoice`(re-export 不变)、`crate::llm::stream::StreamEvent`(重导出保留);其余 wire-format 类型(`OpenaiChatRequest`、`OpenaiChatResponse/Chunk`、`StreamOptions` 等)移入 `provider/openai.rs` 后属 Breaking Change,详见 §4.3 CHANGELOG
|
||||
- **向后兼容的 StreamEvent 路径**:`crate::llm::stream::StreamEvent` 重导出保留,不修改 `cycle.rs` 和 `session.rs` 的 import
|
||||
|
||||
---
|
||||
|
||||
## 3. 方案设计
|
||||
|
||||
### 3.1 整体架构
|
||||
|
||||
Phase 13 分为 5 个 Step,按执行顺序排列:
|
||||
|
||||
```
|
||||
Step 13.5 (fork/merge) → Step 13.4 (ToolChoice) → Step 13.1 (request types) → Step 13.2 (response types) → Step 13.3 (cleanup)
|
||||
```
|
||||
|
||||
这种顺序的好处:
|
||||
|
||||
- **先交付价值**:13.5 是唯一有用户功能交付的 Step,先做建立节奏
|
||||
- **排序约束**:13.4 必须先于 13.1(ToolChoice 不搬走,request.rs 不能删)
|
||||
- **13.3 收尾**:删除旧文件和 `ChatResponse` 是 breaking change,放在最后
|
||||
|
||||
### 3.2 Step 13.5 — ContextSlot fork/merge
|
||||
|
||||
#### MergeStrategy 枚举
|
||||
|
||||
定义在 `src/agent/context.rs`:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
#[non_exhaustive]
|
||||
pub enum MergeStrategy {
|
||||
/// 子 slot 消息追加到父 slot 末尾。
|
||||
Append,
|
||||
/// 用子 slot 消息替换父 slot 内容。
|
||||
Replace,
|
||||
}
|
||||
```
|
||||
|
||||
- `#[non_exhaustive]` 保证 Phase 16 加入 `Summarize` 变体时不破坏现有代码
|
||||
- 不预埋 `Summarize` 占位变体(YAGNI 原则)
|
||||
|
||||
#### ContextSlot::fork
|
||||
|
||||
```rust
|
||||
impl ContextSlot {
|
||||
pub fn fork(&self, child_id: String, strategy: DeriveStrategy) -> ContextSlot {
|
||||
let messages = match &strategy {
|
||||
DeriveStrategy::Full => self.messages.clone(),
|
||||
DeriveStrategy::Focused(cfg) => Self::filter_focused(&self.messages, cfg),
|
||||
};
|
||||
tracing::debug!(
|
||||
parent_id = %self.id,
|
||||
child_id = %child_id,
|
||||
?strategy,
|
||||
"ContextSlot::fork"
|
||||
);
|
||||
ContextSlot {
|
||||
id: child_id,
|
||||
session_id: self.session_id.clone(),
|
||||
config: SlotConfig {
|
||||
mode: match &strategy {
|
||||
DeriveStrategy::Full => SlotMode::Full,
|
||||
DeriveStrategy::Focused(cfg) => SlotMode::Focused(cfg.clone()),
|
||||
},
|
||||
source: SlotSource::Derived {
|
||||
parent_id: self.id.clone(),
|
||||
strategy,
|
||||
},
|
||||
budget: self.config.budget.clone(),
|
||||
compact: self.config.compact,
|
||||
},
|
||||
messages,
|
||||
meta: SlotMeta::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
设计要点:
|
||||
|
||||
- 纯数据层操作,不持久化
|
||||
- 子 slot 的 `meta` 全新创建(`SlotMeta::new()`),不继承父 slot 的 message_count
|
||||
- 子 slot 的 source 记录 `parent_id`,血缘可追溯
|
||||
- 添加 `tracing::debug!` 日志,支持多 slot 交互场景的审计追踪
|
||||
|
||||
#### ContextSlot::merge
|
||||
|
||||
```rust
|
||||
impl ContextSlot {
|
||||
/// 将子 slot 的消息合并到当前 slot。
|
||||
///
|
||||
/// **注意**:本方法仅操作内存数据,不自动持久化。
|
||||
/// 调用方需在 merge 后自行调用 `self.save(&store)` 将结果写入后端存储。
|
||||
pub fn merge(&mut self, child: ContextSlot, strategy: MergeStrategy) -> Result<(), AgentError> {
|
||||
// 防御性检查
|
||||
if self.id == child.id {
|
||||
return Err(AgentError::Config("不能将 slot 合并到自身".into()));
|
||||
}
|
||||
if self.session_id != child.session_id {
|
||||
return Err(AgentError::Config("不能合并不同 session 的 slot".into()));
|
||||
}
|
||||
if matches!(self.config.mode, SlotMode::Readonly) {
|
||||
return Err(AgentError::SlotReadonly("Readonly slot 不允许合并".into()));
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
self_id = %self.id,
|
||||
child_id = %child.id,
|
||||
?strategy,
|
||||
"ContextSlot::merge"
|
||||
);
|
||||
|
||||
match strategy {
|
||||
MergeStrategy::Append => {
|
||||
let count = child.messages.len();
|
||||
self.messages.extend(child.messages);
|
||||
self.meta.message_count += count;
|
||||
}
|
||||
MergeStrategy::Replace => {
|
||||
self.messages = child.messages;
|
||||
self.meta.message_count = self.messages.len();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### AgentSession::derive_slot 重构
|
||||
|
||||
现有 `derive_slot`(session.rs:213-260)的手工复制代码改为调用 `parent.fork()`:
|
||||
|
||||
```rust
|
||||
pub async fn derive_slot(
|
||||
&mut self,
|
||||
id: impl Into<String>,
|
||||
parent_id: &str,
|
||||
strategy: DeriveStrategy,
|
||||
) -> Result<(), AgentError> {
|
||||
let slot_id = id.into();
|
||||
if self.slots.contains_key(&slot_id) {
|
||||
return Err(AgentError::SlotAlreadyExists(slot_id));
|
||||
}
|
||||
let parent = self
|
||||
.slots
|
||||
.get(parent_id)
|
||||
.ok_or_else(|| AgentError::SlotNotFound(parent_id.to_string()))?;
|
||||
let child = parent.fork(slot_id.clone(), strategy); // ← 用 fork
|
||||
child.save(&*self.resolve_store()).await?;
|
||||
self.slots.insert(slot_id, child);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
重复检查、查找父 slot 的代码不变;消息复制逻辑委托给 `fork()`。
|
||||
|
||||
#### 测试计划(新增 9 个)
|
||||
|
||||
| 测试名 | 验证点 |
|
||||
|--------|--------|
|
||||
| `fork_full_copies_messages` | fork Full 策略复制父 slot 全部消息 |
|
||||
| `fork_focused_filters_messages` | fork Focused 策略按 config 过滤 |
|
||||
| `fork_preserves_independence` | 父 slot 追加消息不影响子 slot |
|
||||
| `fork_sets_derived_source` | 子 slot source 正确记录 parent_id |
|
||||
| `merge_append_appends_messages` | Append 追加到父 slot 末尾,message_count 正确 |
|
||||
| `merge_replace_replaces_messages` | Replace 替换父 slot 消息,message_count 正确 |
|
||||
| `merge_self_rejected` | self-merge 返回 `Err` |
|
||||
| `merge_readonly_rejected` | 合并到 Readonly slot 返回 `Err` |
|
||||
| `merge_cross_session_rejected` | 跨 session 合并返回 `Err` |
|
||||
|
||||
### 3.3 Step 13.4 — ToolChoice 移入 tool.rs
|
||||
|
||||
#### 变更文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `src/llm/types/request.rs` | 删除 `ToolChoice` 枚举 + serde impl(~28-99 行) |
|
||||
| `src/llm/types/tool.rs` | 新增 `ToolChoice` 枚举 + serde impl(原样搬入) |
|
||||
| `src/llm/types/mod.rs` | `pub use request::{..., ToolChoice}` → `pub use tool::ToolChoice` |
|
||||
| `src/llm/types/request_v2.rs` | import 路径 `request::ToolChoice` → `tool::ToolChoice` |
|
||||
|
||||
**import 路径变化**:
|
||||
|
||||
| 当前 | 移动后 |
|
||||
|------|--------|
|
||||
| `crate::llm::types::request::ToolChoice` | `crate::llm::types::tool::ToolChoice` |
|
||||
| `crate::llm::types::ToolChoice`(通过 re-export) | `crate::llm::types::ToolChoice`(通过 tool.rs re-export,保持不变) |
|
||||
|
||||
**验证**:`cargo build --all-targets` + `cargo test` + `cargo clippy`
|
||||
|
||||
### 3.4 Step 13.1 — request.rs 类型移入 openai.rs
|
||||
|
||||
#### 变更文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `src/llm/types/request.rs` | **整文件删除**(187 行) |
|
||||
| `src/llm/provider/openai.rs` | 新增 `StreamOptions`、`OpenaiTool`、`AudioParam`、`PredictionContent`、`UserLocation`、`Approximate`、`WebSearchOptions`、`OpenaiChatRequest` 等类型定义 |
|
||||
| `src/llm/types/mod.rs` | 删除 `pub use request::{OpenaiChatRequest, OpenaiTool, StreamOptions}`;删除 `pub mod request;` |
|
||||
| `src/llm/provider/openai.rs` import 调整 | 原 `use crate::llm::types::request::{...}` 改为从同级 `use super::super::types::...` 或直接使用本文件内类型 |
|
||||
|
||||
**注意**:`OpenaiTool` 引用 `OpenaiToolDefinition`(定义在 `tool.rs`),移入 `openai.rs` 后需通过 `crate::llm::types::tool::OpenaiToolDefinition` 引用。`OpenaiChatRequest.messages` 字段引用 `OpenaiChatMessage`(定义在 `openai_message.rs`),路径不变。
|
||||
|
||||
**设计决策**:搬入 `openai.rs` 后的类型可见性可降级为 `pub(crate)`。它们是与 OpenAI wire-format 绑定的内部序列化类型,公共 API 消费者不应直接接触。
|
||||
|
||||
**验证**:`cargo build --all-targets` + `cargo test` + `cargo clippy`
|
||||
|
||||
### 3.5 Step 13.2 — response.rs 类型移入 openai.rs
|
||||
|
||||
#### 变更文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `src/llm/types/response.rs` | **整文件删除**(177 行) |
|
||||
| `src/llm/provider/openai.rs` | 新增 `TokenLogprob`、`TopLogprob`、`Logprobs`、`URLCitation`、`Annotation`、`OpenaiAudio`、`Choice`、`OpenaiChatResponse`、`Delta`、`ChunkChoice`、`OpenaiChatChunk` + `From<OpenaiChatMessage> for Delta` + `From<OpenaiChatResponse> for OpenaiChatChunk` |
|
||||
| `src/llm/types/mod.rs` | 删除 `pub use response::{...}`;删除 `pub mod response;` |
|
||||
| `src/llm/stream.rs:26` | 将 `use crate::llm::types::{OpenaiChatChunk, OpenaiToolCall}` 中的 `OpenaiChatChunk` 路径改为 `crate::llm::provider::openai::OpenaiChatChunk`(`OpenaiToolCall` 保持从 `tool.rs`) |
|
||||
|
||||
**验证**:`cargo build --all-targets` + `cargo test` + `cargo clippy`
|
||||
|
||||
### 3.6 Step 13.3 — 旧文件清理 + ChatResponse 删除
|
||||
|
||||
#### 13.3a — 删除 `old_stream.rs`
|
||||
|
||||
> **前置验证**:实施前执行 `grep -rn 'parse_chunk_stream\|map_legacy_to_ir\|LegacyToIrEventStream\|ChunkToLegacyEventStream' src/` 确认零外部调用方,记录结果到实施 commit。
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `src/llm/types/old_stream.rs` | **整文件删除**(45 行,`LegacyStreamEvent`) |
|
||||
| `src/llm/types/mod.rs` | 删除 `pub mod old_stream;` |
|
||||
| `src/llm/stream.rs` | 删除 `use crate::llm::types::old_stream::LegacyStreamEvent`;删除 `parse_chunk_stream`、`parse_chunk_stream_legacy`、`ChunkToLegacyEventStream`、`LegacyToIrEventStream`、`map_legacy_to_ir`、`empty_message_response`(~160 行死代码) |
|
||||
|
||||
**stream.rs 最终形态**:
|
||||
|
||||
```rust
|
||||
//! 流式事件系统 —— 重导出 StreamEvent 供向后兼容。
|
||||
pub use crate::llm::types::response_v2::StreamEvent;
|
||||
```
|
||||
|
||||
**为什么不全删 stream.rs**:`cycle.rs` 和 `session.rs` 的 `use crate::llm::stream::StreamEvent` 路径保持不变。全删 + 改所有 import 路径的改动量 > 收益。保留 1 行重导出就够。
|
||||
|
||||
#### 13.3b — 删除 `ChatResponse`
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `src/llm/types/mod.rs` | 删除 `ChatResponse` 结构体定义 + 两个 `#[allow(deprecated)]` `From` impl(`From<OpenaiChatResponse> for ChatResponse` 和 `From<ChatResponse> for OpenaiChatChunk`) |
|
||||
|
||||
`ChatResponse` 自 v0.1.0 起标记 `#[deprecated]`,v0.2.0-rc.1 阶段直接删除即可。删除前运行 `cargo doc --no-deps 2>&1 | grep -i 'ChatResponse'` 确认零文档引用。
|
||||
|
||||
**验证**:`cargo build --all-targets` + `cargo test` + `cargo clippy` + `cargo doc --no-deps`
|
||||
|
||||
---
|
||||
|
||||
## 4. 实现计划
|
||||
|
||||
### 4.1 实施顺序总览
|
||||
|
||||
```
|
||||
Step 13.5 ──→ Step 13.4 ──→ Step 13.1 ──→ Step 13.2 ──→ Step 13.3
|
||||
(fork/merge) (ToolChoice) (request) (response) (cleanup)
|
||||
│ │ │ │ │
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
+60 行净增 -0 净增 -0 净增 -0 净增 -260 删除
|
||||
+9 个测试 import 路径 纯类型搬移 纯类型搬移 +1 行重导出
|
||||
变更
|
||||
```
|
||||
|
||||
### 4.2 各 Step 文件变更清单
|
||||
|
||||
#### Step 13.5 — ContextSlot fork/merge
|
||||
|
||||
| 操作 | 文件 | 变更说明 |
|
||||
|------|------|---------|
|
||||
| 新增 | `src/agent/context.rs` | `MergeStrategy` 枚举 + `ContextSlot::fork()` + `ContextSlot::merge()` |
|
||||
| 重构 | `src/agent/session.rs` | `derive_slot` 改为调用 `parent.fork()` |
|
||||
| 新增 | 内联测试 | 9 个新测试(fork, merge, 边界) |
|
||||
|
||||
#### Step 13.4 — ToolChoice 移动
|
||||
|
||||
| 操作 | 文件 | 变更说明 |
|
||||
|------|------|---------|
|
||||
| 删除 | `src/llm/types/request.rs` | 移除 `ToolChoice` 枚举 + serde impl |
|
||||
| 新增 | `src/llm/types/tool.rs` | 增加 `ToolChoice` 枚举 + serde impl |
|
||||
| 修改 | `src/llm/types/mod.rs` | 更新 re-export 路径 |
|
||||
| 修改 | `src/llm/types/request_v2.rs` | 更新 import 路径 |
|
||||
|
||||
#### Step 13.1 — request 类型搬移
|
||||
|
||||
| 操作 | 文件 | 变更说明 |
|
||||
|------|------|---------|
|
||||
| 删除 | `src/llm/types/request.rs` | 整文件删除(187 行) |
|
||||
| 新增 | `src/llm/provider/openai.rs` | 增加所有 OpenAI wire-format 类型 |
|
||||
| 修改 | `src/llm/types/mod.rs` | 删除 re-export + mod 声明 |
|
||||
|
||||
#### Step 13.2 — response 类型搬移
|
||||
|
||||
| 操作 | 文件 | 变更说明 |
|
||||
|------|------|---------|
|
||||
| 删除 | `src/llm/types/response.rs` | 整文件删除(177 行) |
|
||||
| 新增 | `src/llm/provider/openai.rs` | 增加所有 OpenAI wire-format 类型 + From impl |
|
||||
| 修改 | `src/llm/types/mod.rs` | 删除 re-export + mod 声明 |
|
||||
| 修改 | `src/llm/stream.rs` | 更新 `OpenaiChatChunk` import 路径 |
|
||||
|
||||
#### Step 13.3 — 旧文件清理
|
||||
|
||||
| 操作 | 文件 | 变更说明 |
|
||||
|------|------|---------|
|
||||
| 删除 | `src/llm/types/old_stream.rs` | 整文件删除(45 行) |
|
||||
| 修改 | `src/llm/types/mod.rs` | 删除 `pub mod old_stream;` + 删除 `ChatResponse` 结构体 + `From` impl |
|
||||
| 修改 | `src/llm/stream.rs` | 删除所有死代码,仅保留 `pub use` 重导出 |
|
||||
|
||||
### 4.3 回滚策略
|
||||
|
||||
所有 Step 通过 git commit 管理,回退时 `git revert <commit>` 即可。每个 Step 独立编译,回滚不会级联依赖。若 Step 13.3(`ChatResponse` 删除)导致外部编译失败,单独 revert 该 commit 即可恢复 `ChatResponse` + `old_stream.rs`。
|
||||
|
||||
### 4.4 CHANGELOG 条目
|
||||
|
||||
```markdown
|
||||
## [0.3.0] - 未发布
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
**类型路径变更(0.3.0):**
|
||||
- `agcore::llm::types::request::ToolChoice` → `agcore::llm::types::tool::ToolChoice`(公共 re-export 路径 `agcore::llm::types::ToolChoice` 保持不变)
|
||||
- `agcore::llm::types::request::StreamOptions` → `agcore::llm::provider::openai::StreamOptions`
|
||||
- `agcore::llm::types::request::OpenaiChatRequest` → `agcore::llm::provider::openai::OpenaiChatRequest`
|
||||
- `agcore::llm::types::response::OpenaiChatResponse` → `agcore::llm::provider::openai::OpenaiChatResponse`
|
||||
- `agcore::llm::types::response::OpenaiChatChunk` → `agcore::llm::provider::openai::OpenaiChatChunk`
|
||||
- 其余 `request.rs`/`response.rs` 中的 wire-format 类型(`OpenaiTool`、`AudioParam`、`Choice`、`Delta` 等)同步移入 `agcore::llm::provider::openai` 模块
|
||||
|
||||
**类型删除:**
|
||||
- `agcore::llm::types::ChatResponse` 已删除(自 v0.1.0 标记 `#[deprecated]`,请改用 `MessageResponse`)
|
||||
- `agcore::llm::types::old_stream::LegacyStreamEvent` 已删除(内部死代码)
|
||||
|
||||
### Features
|
||||
- `ContextSlot::fork(child_id, strategy)` — 从父槽派生独立的子槽(数据层操作)
|
||||
- `ContextSlot::merge(child, strategy)` — 将子槽消息合并回父槽(支持 Append/Replace)
|
||||
- `MergeStrategy` 枚举(`#[non_exhaustive]`,Phase 16 可扩展 Summarize)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 风险评估
|
||||
|
||||
| 风险 | 影响 | 概率 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| `ChatResponse` 被外部 crate 引用 | 编译 break | 中 — `#[deprecated]` 仅产生编译警告,外部 crate 可能通过 `#[allow(deprecated)]` 静默依赖 | CHANGELOG 明确标注语义版本(0.3.0)和迁移指引;Step 13.3 验收加入 `cargo doc --no-deps \| grep ChatResponse` 确认零引用 |
|
||||
| `StreamOptions` 等 wire-format 类型路径变更影响直接引用消费者 | 编译 break | 低(v0.2.0-rc.1,极少外部消费者使用内部类型) | CHANGELOG 完整列出所有路径变更;编译错误立即可发现 |
|
||||
| `parse_chunk_stream` 有隐藏调用方 | 编译 break | 极低(实施前执行 `grep -rn 'parse_chunk_stream\|map_legacy_to_ir\|LegacyToIrEventStream' src/` 前置验证) | Step 13.3 前运行 grep 验证并记录结果;`cargo build --all-targets` 可 100% 捕获 |
|
||||
| `#[allow(deprecated)]` 遗漏 | clippy 警告 | 低 | `cargo clippy --all-targets -- -D warnings` 验证 |
|
||||
| Step 顺序错误导致编译中间态 | 开发者体验差 | 中 | 严格按 13.5→13.4→13.1→13.2→13.3 执行;每步 `cargo build` 验证 |
|
||||
| `stream.rs` 简化后 import 断链 | 编译 break | 极低 | 保留 `pub use` 重导出路径,`cycle.rs`/`session.rs` import 不变 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
### M9 里程碑(Phase 13 完成条件)
|
||||
|
||||
| # | 条件 | 验证方法 |
|
||||
|---|------|---------|
|
||||
| 1 | `request.rs`、`response.rs`、`old_stream.rs` 三个旧文件不存在 | `ls src/llm/types/` 确认 |
|
||||
| 2 | `ChatResponse` 结构体不存在 | 全局搜索 `ChatResponse` 仅保留 `openai.rs` 中 `OpenaiChatResponse` 引用 |
|
||||
| 3 | `ToolChoice` 在 `tool.rs` 中定义,公共路径 `agcore::llm::types::ToolChoice` 保持不变 | `cargo doc --no-deps` 确认类型文档 |
|
||||
| 4 | `OpenaiChatRequest`/`Response`/`Chunk` 在 `provider/openai.rs` 中定义 | 编译通过 |
|
||||
| 5 | `ContextSlot::fork()` 单元测试通过(P0 条件全部满足) | `cargo test` |
|
||||
| 6 | `ContextSlot::merge()` 单元测试通过(P0 条件全部满足) | `cargo test` |
|
||||
| 7 | `stream.rs` 只保留 `pub use` 重导出 | 文件内容确认 |
|
||||
| 8 | `cargo build --all-targets` 编译通过 | 编译验证 |
|
||||
| 9 | `cargo test --all-targets` 全绿(预期 283~285 测试) | 测试验证 |
|
||||
| 10 | `cargo clippy --all-targets -- -D warnings` 0 警告 | clippy 验证 |
|
||||
| 11 | CHANGELOG 包含 Phase 13 的 Breaking Changes 和 Features 条目 | 文件确认 |
|
||||
|
||||
### fork/merge 详细验收 P0 项
|
||||
|
||||
**fork 的 5 项 P0 条件:**
|
||||
|
||||
| # | 条件 | 优先级 |
|
||||
|---|------|--------|
|
||||
| 1 | `fork("child", Full)` 创建新 slot,消息在 fork 时刻 == 父 slot | P0 |
|
||||
| 2 | 子 slot 获得独立消息列表——父 slot 后续追加不影响子 slot | P0 |
|
||||
| 3 | 子 slot 的 source 标记为 `Derived { parent_id, strategy }` | P0 |
|
||||
| 4 | 子 slot 可独立持久化(fork + save + load roundtrip) | P0 |
|
||||
| 5 | fork 不允许重复 id(返回 `SlotAlreadyExists`)(由 `derive_slot` 编排层保证) | P0 |
|
||||
|
||||
**merge 的 5 项 P0 条件:**
|
||||
|
||||
| # | 条件 | 优先级 |
|
||||
|---|------|--------|
|
||||
| 1 | `parent.merge(child, Append)` 子消息追加到父末尾 | P0 |
|
||||
| 2 | `parent.merge(child, Replace)` 子消息替换父全量消息 | P0 |
|
||||
| 3 | merge 后父 slot 的 `meta.message_count` 正确更新 | P0 |
|
||||
| 4 | merge 不允许合并到 Readonly 目标 slot | P0 |
|
||||
| 5 | merge 不允许 self-merge(child.id == parent.id) | P0 |
|
||||
|
||||
---
|
||||
|
||||
## 参考来源
|
||||
|
||||
- Roadmap:`docs/roadmap.md` §Phase 13
|
||||
- ContextSlot 设计:`docs/17-phase10-contextslot.md`
|
||||
- 旧 StreamEvent 设计:`src/llm/stream.rs` 文件注释
|
||||
- 当前代码库:`src/llm/types/request.rs`、`src/llm/types/response.rs`、`src/llm/types/old_stream.rs`、`src/llm/types/mod.rs`、`src/llm/provider/openai.rs`、`src/agent/context.rs`、`src/agent/session.rs`
|
||||
|
||||
---
|
||||
|
||||
## 7. 实施计划
|
||||
|
||||
### 全局说明
|
||||
|
||||
**commit 策略**:每个 Step 一个独立 commit。commit message 格式:
|
||||
```
|
||||
<type>(<scope>): <中文描述>
|
||||
```
|
||||
- Step 13.5 → `feat(agent): 实现 ContextSlot fork/merge`
|
||||
- Step 13.4 → `refactor(types): ToolChoice 移入 tool.rs`
|
||||
- Step 13.1 → `refactor(types): request.rs 类型移入 provider/openai.rs`
|
||||
- Step 13.2 → `refactor(types): response.rs 类型移入 provider/openai.rs`
|
||||
- Step 13.3 → `refactor(types): 删除旧类型文件和 ChatResponse`
|
||||
|
||||
**验证命令(每步通用)**:
|
||||
```bash
|
||||
cargo build --all-targets && cargo test && cargo clippy --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
**预计测试数量变化**:
|
||||
- 当前基线:277 测试(每个 Step 开始时 `cargo test` 确认)
|
||||
- Step 13.5 后:286(+9)
|
||||
- Step 13.4-13.2 后:286(无变化)
|
||||
- Step 13.3 后:285(-1,`ChatResponse` 的 `From` impl 无测试直接引用,删除后仅 `types/mod.rs` 中的 `deprecated` 注释行减少,不影响测试计数。实施前执行 `grep -rn 'ChatResponse' src/ --include='*test*' --include='*tests*'` 确认零测试引用)
|
||||
- 最终范围:285 测试
|
||||
|
||||
### Step 13.5 — ContextSlot fork/merge
|
||||
|
||||
**前置依赖**:无(纯新增,不依赖前序 Step)
|
||||
|
||||
**任务描述**:在 `agent/context.rs` 中新增 `MergeStrategy` 枚举、`ContextSlot::fork()` 方法和 `ContextSlot::merge()` 方法;重构 `agent/session.rs` 中的 `derive_slot` 改为调用 `parent.fork()`;新增 9 个内联测试覆盖 fork/merge 的 happy path 和 error path。
|
||||
|
||||
**涉及文件**:
|
||||
- `src/agent/context.rs` — 新增枚举和方法
|
||||
- `src/agent/session.rs` — 重构 derive_slot
|
||||
- `src/agent.rs` — 追加 `MergeStrategy` re-export
|
||||
|
||||
**具体操作**:
|
||||
1. 在 `context.rs` 中新增 `MergeStrategy` 枚举(Append / Replace,`#[non_exhaustive]`)
|
||||
2. 在 `context.rs` 中 `impl ContextSlot` 块内新增 `fork(&self, child_id: String, strategy: DeriveStrategy) -> ContextSlot` 方法
|
||||
3. 在 `context.rs` 中 `impl ContextSlot` 块内新增 `merge(&mut self, child: ContextSlot, strategy: MergeStrategy) -> Result<(), AgentError>` 方法(含 self-merge/cross-session/Readonly 三项防御检查 + `tracing::debug!` 日志)
|
||||
4. 在 `session.rs` 的 `derive_slot` 方法中将手工消息复制代码替换为 `parent.fork(slot_id, strategy)`
|
||||
5. 在 `agent.rs` 的 `pub use context::{...}` 列表中追加 `MergeStrategy`
|
||||
6. 在 `context.rs` 的 `#[cfg(test)] mod tests` 中新增 9 个测试用例
|
||||
|
||||
**注意**:重构后 `derive_slot` 的子 slot `budget` 从 `ContextBudget::default()` 变为继承父 slot,`compact` 从 `true` 变为继承父 slot。由于 `ContextBudget` 在 v0.2 无消费逻辑且父 slot 的 `compact` 默认也为 `true`,此变化无实际影响。验收条件中"行为不变"指对外功能行为不变(slot 消息内容、血缘关系不变)。
|
||||
|
||||
**预估工作量**:M(1-4h)
|
||||
|
||||
**风险等级**:低(纯新增,不修改已有逻辑路径)
|
||||
|
||||
**验收条件**:
|
||||
- `MergeStrategy` 枚举存在,`Append` 和 `Replace` 两个变体可用,且通过 `agcore::agent::MergeStrategy` 路径可访问
|
||||
- `ContextSlot::fork` 返回的 child 在 fork 时刻消息等于父 slot
|
||||
- fork Focused 策略按 `FocusedConfig` 过滤消息
|
||||
- 父 slot 后续追加消息不影响子 slot
|
||||
- 子 slot 的 source 正确记录 `Derived { parent_id, strategy }`
|
||||
- `parent.merge(child, Append)` 追加到父末尾,message_count 正确
|
||||
- `parent.merge(child, Replace)` 替换父全量消息,message_count 正确
|
||||
- self-merge 返回 `Err(AgentError::Config)`
|
||||
- merge 到 Readonly slot 返回 `Err(AgentError::SlotReadonly)`
|
||||
- 跨 session merge 返回 `Err(AgentError::Config)`
|
||||
- `derive_slot` 对外行为不变(slot 消息内容、血缘关系、持久化行为均不变;内部 budget/compact 继承差异无实际影响),测试全绿
|
||||
- `cargo doc --no-deps` 无 warning(验证新增公开 API 的文档注释完整)
|
||||
|
||||
**回退方式**:`git revert` 该 commit
|
||||
|
||||
### Step 13.4 — ToolChoice 移入 tool.rs
|
||||
|
||||
**前置依赖**:Step 13.5(顺序约束:必须早于 Step 13.1——若 Step 13.1 先执行会将 `ToolChoice` 与 `request.rs` 一同删除,导致本 Step 无可搬移的源)
|
||||
|
||||
**任务描述**:将 `ToolChoice` 枚举及其 serde 实现从 `types/request.rs` 搬移到 `types/tool.rs`,更新所有 import/path 引用。公共 re-export 路径 `agcore::llm::types::ToolChoice` 保持不变。
|
||||
|
||||
**涉及文件**:
|
||||
- `src/llm/types/request.rs` — 删除 ToolChoice(~28-99 行)
|
||||
- `src/llm/types/tool.rs` — 新增 ToolChoice 枚举 + serde impl
|
||||
- `src/llm/types/mod.rs` — re-export 路径从 `request` 改为 `tool`
|
||||
- `src/llm/types/request_v2.rs` — import 路径从 `request::` 改为 `tool::`
|
||||
|
||||
**具体操作**:
|
||||
1. 从 `request.rs` 复制 `ToolChoice` 枚举 + `Serialize`/`Deserialize` impl 到 `tool.rs`
|
||||
2. 从 `request.rs` 中删除 `ToolChoice` 定义
|
||||
3. 在 `mod.rs` 中将 `pub use request::{..., ToolChoice}` 改为 `pub use tool::ToolChoice`
|
||||
4. 在 `request_v2.rs` 中将 `use crate::llm::types::request::ToolChoice` 改为 `use crate::llm::types::tool::ToolChoice`
|
||||
5. 验证 `cycle.rs` 的 `use crate::llm::types::ToolChoice`(通过 re-export)路径不变
|
||||
|
||||
**预估工作量**:S(<1h)
|
||||
|
||||
**风险等级**:低(有限的 import 路径变更,编译立即可发现)
|
||||
|
||||
**验收条件**:
|
||||
- `ToolChoice` 在 `tool.rs` 中定义
|
||||
- `pub use tool::ToolChoice` 在 `mod.rs` 中
|
||||
- `request_v2.rs` 编译通过
|
||||
- `cycle.rs` 路径不变
|
||||
- `cargo build --all-targets` + `cargo test` + `cargo clippy` 全绿
|
||||
|
||||
**回退方式**:`git revert` 该 commit
|
||||
|
||||
### Step 13.1 — request.rs 类型移入 openai.rs
|
||||
|
||||
**前置依赖**:Step 13.4(ToolChoice 已移走,request.rs 剩余内容全是 OpenAI wire-format 专有类型)
|
||||
|
||||
**任务描述**:删除 `types/request.rs` 整文件,将所有剩余类型(`OpenaiChatRequest`、`StreamOptions`、`OpenaiTool`、`AudioParam`、`PredictionContent`、`UserLocation`、`Approximate`、`WebSearchOptions`)搬入 `provider/openai.rs`,更新 `mod.rs` re-export。
|
||||
|
||||
**涉及文件**:
|
||||
- `src/llm/types/request.rs` — 整文件删除
|
||||
- `src/llm/provider/openai.rs` — 新增所有类型定义
|
||||
- `src/llm/types/mod.rs` — 删除 re-export + mod 声明
|
||||
|
||||
**具体操作**:
|
||||
1. 从 `request.rs` 复制所有剩余类型定义到 `openai.rs`,可见性设为 `pub(crate)`
|
||||
2. `OpenaiTool` 内引用 `OpenaiToolDefinition`(定义在 `tool.rs`),路径改为 `crate::llm::types::tool::OpenaiToolDefinition`
|
||||
3. 删除 `openai.rs` 中原 `use crate::llm::types::request::{...}` import
|
||||
4. 从 `mod.rs` 删除 `pub use request::{OpenaiChatRequest, OpenaiTool, StreamOptions}` 和 `pub mod request;`
|
||||
5. 删除 `types/request.rs` 文件
|
||||
|
||||
**预估工作量**:M(1-4h)
|
||||
|
||||
**风险等级**:低(纯搬移 + 删除,文件内无逻辑变更)
|
||||
|
||||
**验收条件**:
|
||||
- `request.rs` 文件不存在
|
||||
- `OpenaiChatRequest` 等类型在 `openai.rs` 中定义,编译通过
|
||||
- `OpenaiTool` 通过 `crate::llm::types::tool::OpenaiToolDefinition` 正确引用
|
||||
- `cargo build --all-targets` + `cargo test` + `cargo clippy` 全绿
|
||||
|
||||
**回退方式**:`git revert` 该 commit。若 Step 13.2 也已提交,单独 revert 本 Step 可能因 `provider/openai.rs` 并发修改产生合并冲突。安全回退顺序为逆序:先 revert 13.2,再 revert 13.1。
|
||||
|
||||
### Step 13.2 — response.rs 类型移入 openai.rs
|
||||
|
||||
**前置依赖**:无(与 Step 13.1 共享 `provider/openai.rs` 和 `types/mod.rs`,但本 Step 仅追加类型定义,无覆盖操作;建议在 13.1 之后顺序执行以避免并行时的合并冲突)
|
||||
|
||||
**任务描述**:删除 `types/response.rs` 整文件,将所有类型(`OpenaiChatResponse`、`OpenaiChatChunk`、`Choice`、`Delta`、`ChunkChoice` 等 + 两个 `From` impl)搬入 `provider/openai.rs`,更新 `mod.rs` 和 `stream.rs` 的 import 路径。
|
||||
|
||||
**涉及文件**:
|
||||
- `src/llm/types/response.rs` — 整文件删除
|
||||
- `src/llm/provider/openai.rs` — 新增所有类型定义 + From impl
|
||||
- `src/llm/types/mod.rs` — 删除 re-export + mod 声明
|
||||
- `src/llm/stream.rs` — `OpenaiChatChunk` import 路径改为 `provider::openai`
|
||||
|
||||
**具体操作**:
|
||||
1. 从 `response.rs` 复制所有类型定义(含 `From` impl)到 `openai.rs`,可见性设为 `pub(crate)`
|
||||
2. 删除 `openai.rs` 中原 `use crate::llm::types::response::{...}` import
|
||||
3. 从 `mod.rs` 删除 `pub use response::{...}` 和 `pub mod response;`
|
||||
4. 在 `stream.rs:26` 将 `OpenaiChatChunk` 的 import 路径改为 `crate::llm::provider::openai::OpenaiChatChunk`(`OpenaiToolCall` 路径不变)
|
||||
5. 删除 `types/response.rs` 文件
|
||||
|
||||
**预估工作量**:M(1-4h)
|
||||
|
||||
**风险等级**:低(与 Step 13.1 模式完全相同)
|
||||
|
||||
**验收条件**:
|
||||
- `response.rs` 文件不存在
|
||||
- `OpenaiChatResponse`/`Chunk` 等类型在 `openai.rs` 中定义,编译通过
|
||||
- `stream.rs` import 路径正确
|
||||
- `cargo build --all-targets` + `cargo test` + `cargo clippy` 全绿
|
||||
|
||||
**回退方式**:`git revert` 该 commit。若 Step 13.1 和本 Step 均已提交,安全回退顺序为逆序:先 revert 本 Step,再 revert 13.1。
|
||||
|
||||
### Step 13.3 — 旧文件清理 + ChatResponse 删除
|
||||
|
||||
**前置依赖**:Step 13.1(`request.rs` 已删)、Step 13.2(`response.rs` 已删)
|
||||
|
||||
**任务描述**:删除 `old_stream.rs` 和 `ChatResponse`,简化 `stream.rs` 为仅保留 `pub use` 重导出。这是 Phase 13 技术风险最高的 Step。
|
||||
|
||||
**涉及文件**:
|
||||
- `src/llm/types/old_stream.rs` — 整文件删除
|
||||
- `src/llm/types/mod.rs` — 删除 `pub mod old_stream;` + 删除 `ChatResponse` 结构体和两个 `From` impl
|
||||
- `src/llm/stream.rs` — 删除死代码(约 160 行),仅保留 `pub use` 重导出
|
||||
|
||||
**具体操作**:
|
||||
1. **前置验证 A**:执行 `grep -rn 'parse_chunk_stream\|map_legacy_to_ir\|LegacyToIrEventStream\|ChunkToLegacyEventStream' src/` 确认零外部调用方,记录结果到 commit message
|
||||
2. **前置验证 B**:执行 `cargo doc --no-deps 2>&1 | grep -i 'ChatResponse'` 确认零文档引用,记录结果
|
||||
3. 从 `mod.rs` 删除 `pub mod old_stream;`
|
||||
4. 从 `mod.rs` 删除 `ChatResponse` 结构体定义 + `#[allow(deprecated)]` `From<OpenaiChatResponse> for ChatResponse` + `From<ChatResponse> for OpenaiChatChunk`
|
||||
5. 删除 `old_stream.rs` 文件
|
||||
6. 从 `stream.rs` 删除:`use crate::llm::types::old_stream::LegacyStreamEvent`、`parse_chunk_stream`、`parse_chunk_stream_legacy`、`ChunkToLegacyEventStream`、`LegacyToIrEventStream`、`map_legacy_to_ir`、`empty_message_response`
|
||||
7. `stream.rs` 最终只保留 module doc comment + `pub use crate::llm::types::response_v2::StreamEvent;`
|
||||
8. 检查 `cycle.rs:88` 的 `#[allow(deprecated)]` 属性是否仍与 `ChatResponse` 相关——若不相关则无需改动;若因 `ChatResponse` 删除而变脏,清理该属性
|
||||
|
||||
**预估工作量**:S(<1h,cleanup)+ M(需验证过程)
|
||||
|
||||
**风险等级**:中(`ChatResponse` 删除是 Breaking Change,外部可能静默依赖)
|
||||
|
||||
**验收条件**:
|
||||
- `old_stream.rs` 文件不存在
|
||||
- `ChatResponse` 结构体不存在(全局搜索仅保留 `OpenaiChatResponse` 引用)
|
||||
- `stream.rs` 只保留 `pub use` 重导出
|
||||
- `cargo build --all-targets` 编译通过
|
||||
- `cargo test --all-targets` 全绿(预期 285 测试)
|
||||
- `cargo clippy --all-targets -- -D warnings` 0 警告
|
||||
- `cargo doc --no-deps` 无 warning
|
||||
|
||||
**回退方式**:`git revert` 该 commit(单独 revert 即可恢复 `ChatResponse` + `old_stream.rs`)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,471 @@
|
||||
# Phase 16 — 摘要自动生成
|
||||
|
||||
## 背景与目标
|
||||
|
||||
### 问题
|
||||
|
||||
长对话场景中,用户与 Agent 交互 30+ 轮后,消息历史长度远超模型上下文窗口,导致:
|
||||
|
||||
- LLM 被迫丢弃早期上下文,对话丧失连贯性
|
||||
- 开发者需要手动管理摘要逻辑(调 LLM → 写 SessionMemory → 注入 FocusedConfig)
|
||||
- v0.2 的 `FocusedConfig.summary_override` 消费端已就绪,但生产端是空的——用户只能手动设字符串
|
||||
|
||||
### 目标
|
||||
|
||||
闭环长对话的"上下文压缩"链路:
|
||||
|
||||
```
|
||||
[消费端 v0.2 已就绪] FocusedConfig.summary_override → filter_focused() 注入摘要
|
||||
[生产端 v0.3 补齐] token 水位检测 → LLM 摘要生成 → 自动写入 summary_override
|
||||
```
|
||||
|
||||
### 成功标准
|
||||
|
||||
1. 开发者只需在 `AgentBuilder` 中链式调用 `.summary_config(cfg)` 即可启用
|
||||
2. 长对话(如 30+ 轮或 token 水位超过 `max_context_tokens * trigger_token_ratio`)自动触发摘要,下轮 `load_messages()` 返回值包含 `[上下文摘要] {summary}`
|
||||
3. 摘要生成不改变 `submit_turn` 行为(opt-in、静默失败、不阻断主流程)
|
||||
4. 零新外部依赖
|
||||
|
||||
---
|
||||
|
||||
## 需求分析
|
||||
|
||||
### 功能需求
|
||||
|
||||
| # | 需求 | 优先级 | 说明 |
|
||||
|---|------|--------|------|
|
||||
| F1 | `SummaryConfig` 配置结构体 | P0 | `trigger_token_ratio` / `max_context_tokens` / `summary_prompt` / `debounce_turns` / `summary_model` / `max_tool_result_chars` |
|
||||
| F2 | Token 水位自动检测 | P0 | 每轮 OnTurnEnd 之后检查 `cost_so_far` 是否超过 `max * ratio` |
|
||||
| F3 | LLM 摘要生成 | P0 | 复用 `self.bundle.provider`,单次无工具 LLM 调用 |
|
||||
| F4 | 摘要写入 FocusedConfig | P0 | 更新 `summary_override` + `slot.save()` 持久化 |
|
||||
| F5 | 摘要全局快照 | P0 | 同步写入 `SessionMemory::set("conversation_summary", summary)` |
|
||||
| F6 | 防抖机制 | P0 | 两次摘要之间至少间隔 `debounce_turns` 轮(默认 3) |
|
||||
| F7 | 流式路径对称支持 | P0 | `finalize_turn` 中插入相同检查点 |
|
||||
| F8 | 公开 API:`get_conversation_summary()` | P1 | 读取 SessionMemory 中最新的摘要 |
|
||||
|
||||
### 非功能需求
|
||||
|
||||
| # | 需求 | 指标 |
|
||||
|---|------|------|
|
||||
| N1 | 零外部依赖 | 不修改 `Cargo.toml` |
|
||||
| N2 | 向后兼容 | 未设置 `SummaryConfig` 时行为零变化 |
|
||||
| N3 | 静默失败 | 摘要 LLM 调用失败不阻断 `submit_turn` |
|
||||
| N4 | 摘要延迟 | 首次摘要 LLM 调用 ≤ 3s(依赖 provider 响应速度) |
|
||||
|
||||
---
|
||||
|
||||
## 当前状态分析
|
||||
|
||||
### 消费端已就绪
|
||||
|
||||
`FocusedConfig.summary_override`(`src/agent/context.rs`)已在 Phase 10 实现,当前消费逻辑:
|
||||
|
||||
```
|
||||
filter_focused() → 若 cfg.summary_override = Some(text) → 在消息列表末尾插入
|
||||
Message::system("[上下文摘要] {text}")
|
||||
```
|
||||
|
||||
文档注释明确标注:`// v0.3 将支持 Hook 驱动的自动摘要生成`
|
||||
|
||||
### 代码上下文
|
||||
|
||||
| 模块 | 文件 | 状态 | 与 Phase 16 的关系 |
|
||||
|------|------|------|-------------------|
|
||||
| FocusedConfig | `agent/context.rs` | ✅ 消费端 | 摘要写入 `summary_override` 即生效 |
|
||||
| OnTurnEnd | `agent/session.rs:345` | ✅ 触发点 | 摘要检查点插在此之后 |
|
||||
| CostTracker | `llm/cycle/usage.rs` | ✅ 累计 token | 水位检测的数据源 |
|
||||
| SessionMemory | `agent/session_memory.rs` | ✅ set/get | 摘要全局快照存储 |
|
||||
| AgentBuilder | `agent/builder.rs` | ✅ 链式构造 | 新增 `.summary_config()` |
|
||||
| AgentConfig | `agent/runtime.rs` | ✅ 配置结构 | 新增 `summary_config` 字段 |
|
||||
| LlmProvider | `llm/provider.rs` | ✅ Trait | 摘要 LLM 调用复用 provider |
|
||||
| ContextSlot.save | `agent/context.rs:251` | ✅ 持久化 | 更新 config 后写回 |
|
||||
|
||||
---
|
||||
|
||||
## 可选方案推演
|
||||
|
||||
### 方案 A(推荐):内联检查点
|
||||
|
||||
**做法**:在 `submit_turn` 和 `finalize_turn` 中,OnTurnEnd 触发之后、`turn_index` 递增之前,插入以下逻辑:
|
||||
|
||||
```rust
|
||||
if let Some(ref sc) = self.bundle.config.summary_config
|
||||
&& self.should_summarize(sc)
|
||||
{
|
||||
// clone 所需数据(释放 &self 借用)
|
||||
let provider = Arc::clone(&self.bundle.provider);
|
||||
let messages = self.slots.get(&self.current_slot_id)
|
||||
.map(|s| s.messages.clone()).unwrap_or_default();
|
||||
let prompt = sc.summary_prompt.clone();
|
||||
let model = sc.summary_model.clone();
|
||||
|
||||
// 调关联函数(不持有 &self)
|
||||
match Self::generate_summary(&provider, &messages, &prompt, model.as_deref(), sc.max_tool_result_chars).await {
|
||||
Ok(text) => {
|
||||
// 更新 FocusedConfig + 持久化
|
||||
if let Some(slot) = self.slots.get_mut(&self.current_slot_id) {
|
||||
if let SlotMode::Focused(ref mut cfg) = slot.config.mode {
|
||||
cfg.summary_override = Some(text.clone());
|
||||
}
|
||||
let _ = slot.save(&*self.resolve_store()).await;
|
||||
}
|
||||
// 全局快照
|
||||
let _ = self.session_memory.set("conversation_summary", &text).await;
|
||||
self.last_summary_turn = self.turn_index;
|
||||
}
|
||||
Err(e) => tracing::error!("摘要自动生成失败 (turn={}): {}", self.turn_index, e),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 代码路径最短最清晰(~50 行核心逻辑)
|
||||
- 直接访问所有需要的数据(`cost_so_far`、`slots`、`provider`、`session_memory`)
|
||||
- 流式和同步版本统一处理
|
||||
- `Option<SummaryConfig>` 本身已提供 opt-in/opt-out
|
||||
- 不改变 Hook 系统签名
|
||||
|
||||
**缺点**:
|
||||
- 摘要 LLM 调用延长了 `submit_turn` 的延迟(约 1-3s)
|
||||
- 违反"Hook 哲学"(但 `Option` 配置已足够提供可插拔性)
|
||||
|
||||
### 方案 B(否决):扩展 HookContext
|
||||
|
||||
**做法**:在 `HookContext` 中增加 `messages: &[Message]`、`usage: &Usage`、`provider: Arc<dyn LlmProvider>` 字段,让 OnTurnEnd Hook 实现者自行做摘要。
|
||||
|
||||
**否决原因**:
|
||||
1. **生命周期冲突**:`&[Message]` 要求 Hook 调用点消息已就绪但未被 `&mut self` 借用——在 `submit_turn` 第 7 步(slot.save)后消息已就绪,但 to pass `&[Message]` 到 HookContext 需要与 `slot.messages` 的不可变引用共存,而 `submit_turn` 流程中后续步骤需要 `&mut self`
|
||||
2. **流式路径不可行**:`finalize_turn` 触发 OnTurnEnd 时 cycle 已销毁,消息只能从 slot 获取,但 slot 在 `append_messages` 后已被 `&mut` 借用
|
||||
3. **`Arc<dyn LlmProvider>` 的 `'static` 需求**与 `HookContext<'a>` 的设计冲突
|
||||
|
||||
### 方案 C(否决):后台 spawn 异步摘要
|
||||
|
||||
**做法**:token 检测通过后,`tokio::spawn` 后台任务做摘要生成和写入。
|
||||
|
||||
**否决原因**:
|
||||
1. **写入冲突**:后台任务无法获取 `&mut AgentSession` 来更新 slot config
|
||||
2. **绕过方式增加复杂度**:后台任务需要直接操作 `Arc<dyn MemoryStore>` 的原始 key(`slot_config:{session_id}:{slot_id}`),绕过了 `ContextSlot::save()` 的封装
|
||||
3. **并发风险**:如果前一轮摘要尚未完成而下一轮 `finalize_turn` 又触发,可能导致覆盖写
|
||||
|
||||
---
|
||||
|
||||
## 推荐方案(内联检查点)
|
||||
|
||||
### 架构图
|
||||
|
||||
```
|
||||
submit_turn(user_input)
|
||||
│
|
||||
├─ 1. Readonly 检查
|
||||
├─ 2. OnTurnStart hook
|
||||
├─ 3. slot.load_messages() ← 历史摘要已注入(如有)
|
||||
├─ 4. LlmCycle.submit_with_tools
|
||||
├─ 5. cost_so_far.add(usage)
|
||||
├─ 6. slot.append_messages + save
|
||||
├─ 7. OnTurnEnd hook ← 纯通知,不做摘要
|
||||
│
|
||||
├─ [8.5] 摘要检查点 ──────────────────────────────┐
|
||||
│ ├─ should_summarize(cfg) │
|
||||
│ │ ├─ cost_so_far >= max * ratio? │
|
||||
│ │ └─ turn - last_summary >= debounce? │
|
||||
│ │ │
|
||||
│ ├─ generate_summary() ← 新 LlmCycle │
|
||||
│ │ ├─ format_messages_as_text() │
|
||||
│ │ ├─ replace {messages} │
|
||||
│ │ └─ submit_messages(无 tools) │
|
||||
│ │ │
|
||||
│ └─ 成功 → 更新 summary_override + save │
|
||||
│ → SessionMemory.set() │
|
||||
│ → last_summary_turn = turn_index │
|
||||
│ (流式路径用 saturating_sub(1) 修正) │
|
||||
│ 失败 → tracing::error! 静默 │
|
||||
│ │
|
||||
├─ 9. turn_index++
|
||||
└─ 10. return Ok(response)
|
||||
```
|
||||
|
||||
### 模块划分
|
||||
|
||||
**新增文件**:`src/agent/summary.rs`
|
||||
|
||||
```
|
||||
src/agent/summary.rs
|
||||
├── SummaryConfig // 摘要自动生成配置
|
||||
├── format_messages_as_text() // 消息 → 纯文本(简洁版)
|
||||
└── DEFAULT_SUMMARY_PROMPT // 默认 prompt 模板
|
||||
```
|
||||
|
||||
**修改文件**:
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `agent/runtime.rs` | `AgentConfig` 新增 `summary_config: Option<SummaryConfig>` |
|
||||
| `agent/builder.rs` | 新增 `summary_config(cfg)` 方法 |
|
||||
| `agent/session.rs` | 新增 `last_summary_turn` 字段;`submit_turn` / `finalize_turn` 插入检查点;关联函数 `generate_summary`;`get_conversation_summary()` |
|
||||
| `agent.rs` | `pub mod summary` + re-export |
|
||||
|
||||
**不变的文件**(无需改动):
|
||||
|
||||
| 文件 | 原因 |
|
||||
|------|------|
|
||||
| `llm/hooks.rs` | 内联方案不扩展 HookContext |
|
||||
| `llm/cycle.rs` | 摘要调用通过 `submit_messages` 独立使用 |
|
||||
| `agent/context.rs` | `FocusedConfig` 消费端已在 Phase 10 就绪 |
|
||||
| `Cargo.toml` | 零新外部依赖 |
|
||||
|
||||
### 核心接口定义
|
||||
|
||||
**`SummaryConfig`**(`agent/summary.rs`):
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SummaryConfig {
|
||||
/// Token 水位触发比例(0.0 ~ 1.0)。默认 0.75。
|
||||
pub trigger_token_ratio: f64,
|
||||
/// 模型上下文窗口大小(token)。默认 32_000,覆盖大部分开源模型。
|
||||
/// 修改为匹配实际使用模型的上下文窗口。
|
||||
/// ⚠️ 设置为超过模型窗口的值会导致摘要永远不触发。
|
||||
pub max_context_tokens: u32,
|
||||
/// 摘要 prompt 模板。`{messages}` 将被替换为对话历史文本。
|
||||
pub summary_prompt: String,
|
||||
/// 防抖轮次。默认 3。
|
||||
pub debounce_turns: u32,
|
||||
/// 摘要生成模型(None = 沿用主 provider 默认模型)。
|
||||
/// 默认 None。推荐设为便宜模型(如 "gpt-4o-mini")以节省成本。
|
||||
pub summary_model: Option<String>,
|
||||
/// 单个 ToolResult 在格式化时保留的最大字符数。默认 500。
|
||||
/// 超过此值从尾部截断。字符级安全(`chars().take()`)。
|
||||
pub max_tool_result_chars: usize,
|
||||
}
|
||||
```
|
||||
|
||||
**`generate_summary`**(`AgentSession` 关联函数):
|
||||
|
||||
```rust
|
||||
impl AgentSession {
|
||||
async fn generate_summary(
|
||||
provider: &Arc<dyn LlmProvider>,
|
||||
messages: &[Message],
|
||||
prompt_template: &str,
|
||||
summary_model: Option<&str>,
|
||||
max_tool_result_chars: usize,
|
||||
) -> Result<String, LlmError> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**`should_summarize`**(`AgentSession` 方法):
|
||||
|
||||
```rust
|
||||
fn should_summarize(&self, cfg: &SummaryConfig) -> bool {
|
||||
self.turn_index - self.last_summary_turn >= cfg.debounce_turns
|
||||
&& self.cost_so_far.total().total_tokens as f64
|
||||
>= cfg.max_context_tokens as f64 * cfg.trigger_token_ratio
|
||||
}
|
||||
```
|
||||
|
||||
### 消息格式化(简洁版)
|
||||
|
||||
`format_messages_as_text` 输出格式:
|
||||
|
||||
```
|
||||
System: 你是一个翻译助手
|
||||
User: 把这段英文翻译成中文
|
||||
Assistant: 请提供英文文本 [Tool: translate]
|
||||
Tool Result: 这是中文翻译
|
||||
User: 谢谢
|
||||
Assistant: 不客气
|
||||
```
|
||||
|
||||
处理规则:
|
||||
- `ContentBlock::Text { text }` → 直接拼接
|
||||
- `ContentBlock::ToolUse { name, .. }` → `[Tool: {name}]`(不显示参数 JSON)
|
||||
- `Message::ToolResult { content, is_error, tool_call_id }` → `Tool Result [{tool_call_id}]:` / `Tool Error [{tool_call_id}]:`,便于多工具场景下关联调用的返回
|
||||
- ToolResult 文本截断到前 `max_tool_result_chars` 个 Unicode 字符(`chars().take(n)`,字符级安全,避免多字节截断)
|
||||
- 整段对话若超过 30K 字符,从前面截断(优先保留最新消息)
|
||||
- `Message::UserImage { .. }` → `User: [image]`
|
||||
- 非 Text block(Image / Audio / File 等)统一标记为 `[{kind}]`
|
||||
- 每条消息一行,空行分隔
|
||||
|
||||
---
|
||||
|
||||
## 实现计划
|
||||
|
||||
### Step 16.1 — `SummaryConfig` 结构体
|
||||
|
||||
**文件**:新增 `src/agent/summary.rs`
|
||||
|
||||
**内容**:
|
||||
- `SummaryConfig` 结构体定义(6 个字段 + doc comments)
|
||||
- `DEFAULT_SUMMARY_PROMPT` 常量(约 100 字中文 prompt,含 `{messages}` 占位符)
|
||||
- `impl Default for SummaryConfig`
|
||||
- `format_messages_as_text(messages: &[Message]) -> String` 辅助函数
|
||||
|
||||
**验证**:`cargo build`
|
||||
|
||||
### Step 16.2 — `AgentConfig` 扩展 + `AgentBuilder` 方法
|
||||
|
||||
**文件**:`src/agent/runtime.rs` + `src/agent/builder.rs`
|
||||
|
||||
**改动**:
|
||||
- `AgentConfig` 新增字段:`pub summary_config: Option<SummaryConfig>`
|
||||
- `AgentBuilder` 新增方法:
|
||||
```rust
|
||||
pub fn summary_config(mut self, cfg: SummaryConfig) -> Self {
|
||||
let mut config = self.config.take().unwrap_or_default();
|
||||
config.summary_config = Some(cfg);
|
||||
self.config = Some(config);
|
||||
self
|
||||
}
|
||||
```
|
||||
|
||||
**验证**:`AgentBuilder` 单元测试 + `cargo test`
|
||||
|
||||
### Step 16.3 — `AgentSession` 新字段 + 检查点
|
||||
|
||||
**文件**:`src/agent/session.rs`
|
||||
|
||||
**改动**:
|
||||
|
||||
1. `AgentSession` 新增字段:`last_summary_turn: u32`(初始化 0)
|
||||
2. `submit_turn` 中 OnTurnEnd 之后、turn_index 之前插入检查点
|
||||
3. `finalize_turn` 中 OnTurnEnd 之后插入对称检查点。注意:流式路径中 `turn_index` 已在 `submit_turn_stream` 中递增,检查点赋值使用 `self.turn_index.saturating_sub(1)`(与 `OnTurnEnd` hook 保持一致)。
|
||||
4. 关联函数 `generate_summary`:
|
||||
- 接收 `provider`、`messages`、`prompt_template`、`summary_model`、`max_tool_result_chars`
|
||||
- 入口守卫:`messages.is_empty()` 时直接返回 `Ok(String::new())`
|
||||
- 构造 `LlmCycle`(`max_tokens = Some(1024)`)
|
||||
- 调 `cycle.submit_messages(vec![Message::user_text(prompt)], vec![])`
|
||||
- 提取 text 返回
|
||||
5. 公开 API:`get_conversation_summary()` → `self.session_memory.get("conversation_summary")`
|
||||
|
||||
**验证**:`cargo build --all-targets`
|
||||
|
||||
### Step 16.4 — re-export
|
||||
|
||||
**文件**:`src/agent.rs`
|
||||
|
||||
**改动**:
|
||||
```rust
|
||||
pub mod summary;
|
||||
pub use summary::SummaryConfig;
|
||||
```
|
||||
|
||||
**验证**:`cargo test --all-targets`
|
||||
|
||||
### Step 16.5 — 测试
|
||||
|
||||
| 测试 | 验证点 | 方式 |
|
||||
|------|--------|------|
|
||||
| `summary_config_defaults` | 默认值正确 | 单元测试 |
|
||||
| `summary_not_generated_below_threshold` | token < 阈值时不触发 | `MockProvider` + `Usage::from_input_output(10, 5)` |
|
||||
| `summary_generated_above_threshold` | token ≥ 阈值时触发 | 设置 `max_context_tokens=20` + `trigger_token_ratio=0.5` |
|
||||
| `summary_debounce_works` | debounce 内不重复 | 强行触发摘要后验证 3 轮内不触发 |
|
||||
| `summary_injected_into_focused` | Focused 模式 `load_messages()` 含 `[上下文摘要]` | 检查 Message 内容 |
|
||||
| `summary_written_to_session_memory` | `get_session_data("conversation_summary")` 有值 | 集成测试 |
|
||||
| `summary_not_injected_in_full_mode` | Full 模式不改 slot config | 验证 `summary_override` 为 None |
|
||||
| `summary_failure_does_not_block` | LLM error 不阻断 `submit_turn` | MockProvider 返回错误 |
|
||||
| `summary_stream_path` | 流式路径 `finalize_turn` 正确触发 | `submit_turn_stream` 端到端 |
|
||||
| `summary_format_messages` | 格式化输出结构正确 | 单元测试验证格式 |
|
||||
| `summary_skipped_for_empty_messages` | 空消息不调用 LLM | `generate_summary` 直接返回 `""` |
|
||||
| `summary_not_generated_if_max_context_unreachable` | `max_context_tokens` 过大时不触发 | 验证条件不满足 |
|
||||
|
||||
**验证**:`cargo test --all-targets` 全绿
|
||||
|
||||
---
|
||||
|
||||
## 规模估算
|
||||
|
||||
| 组件 | 纯实现 | 测试 | 合计 |
|
||||
|------|--------|------|------|
|
||||
| `agent/summary.rs`(SummaryConfig + format_messages + 默认 prompt + 截断守卫) | 60 | 10 | 70 |
|
||||
| `agent/runtime.rs`(1 个字段) | 3 | — | 3 |
|
||||
| `agent/builder.rs`(1 个方法) | 8 | 3 | 11 |
|
||||
| `agent/session.rs`(检查点 + generate_summary + get_conversation_summary) | 40 | 100 | 140 |
|
||||
| `agent.rs`(module 声明 + re-export) | 3 | — | 3 |
|
||||
| **合计** | **109** | **113** | **~222** |
|
||||
|
||||
---
|
||||
|
||||
## 风险评估
|
||||
|
||||
### 已知风险
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| **同步阻塞**:摘要 LLM 调用延长 submit_turn 延迟 | 高 | 长对话用户多等 1-3s | 对于已达 75% 水位的长对话,用户感知可接受;所有错误静默处理 |
|
||||
| **默认模型不兼容**:非 OpenAI 用户未设置 `summary_model` 但默认 `None` 沿用主模型 | 低 | 无影响 | `summary_model` 默认 `None`,沿用主 provider 默认模型,零兼容问题 |
|
||||
| **无限循环**:摘要不减少 cost_so_far,每轮都超阈值 | 中 | 频繁 LLM 调用浪费 token | `debounce_turns=3` 强制隔断;`last_summary_turn` 记录确保了间隔。注意:摘要 token 不计入 `cost_so_far`(独立 LlmCycle),阈值不会因摘要本身加速膨胀 |
|
||||
| **Focusd 模式摘要位置**:注入为 `system` 消息排在列表末尾 | 低 | LLM 近因效应,摘要可能过度受关注 | 这是 v0.2 消费端的设计选择,Phase 16 不改变 |
|
||||
| **SessionMemory key 冲突**:用户手动写入 `"conversation_summary"` 会被覆盖 | 低 | 数据被摘要覆盖 | 文档建议用户自定义 key;或未来使用 namespaced key |
|
||||
| **可观测性盲区**:`tracing::warn!` 依赖用户配置了 tracing subscriber | 中 | 失败静默不可见 | 提升到 `tracing::error!` 级别,或加 `eprintln!` fallback |
|
||||
|
||||
### 不做的事
|
||||
|
||||
- ❌ 不扩展 `HookContext`
|
||||
- ❌ 不引入 `tokio::spawn` 后台摘要
|
||||
- ❌ 不做增量摘要(`SummaryStrategy::Incremental` 留待 v0.4)
|
||||
- ❌ 不改 `filter_focused()` 的摘要注入位置
|
||||
- ❌ 不追踪摘要 token 消耗(`summary_cost_so_far`)
|
||||
- ❌ 不添加运行时 prompt 校验(不检查 `{messages}` 是否存在)
|
||||
- ❌ 不添加 `MergeStrategy::Summarize` 变体(`context.rs:108` 预占注释将在实施时同步移除或更新)
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
### 编译与测试
|
||||
|
||||
| 检查项 | 指标 |
|
||||
|--------|------|
|
||||
| `cargo build --all-targets` | ✅ 通过 |
|
||||
| `cargo test --all-targets` | ✅ 全量通过(预计 335 → ~345,新增 ~10 测试) |
|
||||
| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 |
|
||||
| 测试覆盖范围 | F1-F8、N1-N4 |
|
||||
|
||||
### 功能验收场景
|
||||
|
||||
**场景 1:启用摘要后的长对话**
|
||||
|
||||
```rust
|
||||
let session = AgentSession::new(agent, "session-1", Arc::new(
|
||||
AgentBuilder::new()
|
||||
.provider(provider)
|
||||
.tool_registry(registry)
|
||||
.hook_executor(executor)
|
||||
.summary_config(SummaryConfig {
|
||||
max_context_tokens: 100,
|
||||
trigger_token_ratio: 0.5,
|
||||
debounce_turns: 2,
|
||||
..Default::default()
|
||||
})
|
||||
.build()?
|
||||
));
|
||||
session.submit_turn("msg 1").await?;
|
||||
// ... submit_turn 多次直到 token 超 50 ...
|
||||
// 第 N 轮:摘要自动生成
|
||||
let summary = session.get_session_data("conversation_summary").await?;
|
||||
assert!(summary.is_some());
|
||||
// Focused 模式下 load_messages 包含摘要
|
||||
```
|
||||
|
||||
**场景 2:不启用时零影响**
|
||||
|
||||
```rust
|
||||
let session = AgentSession::new(agent, "session-2", bundle); // 无 summary_config
|
||||
for i in 0..50 {
|
||||
session.submit_turn(&format!("msg {}", i)).await?;
|
||||
}
|
||||
// 没有摘要产生,没有额外的 LLM 调用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 参考来源
|
||||
|
||||
- Phase 10 方案文档:`docs/17-phase10-contextslot.md`(§5 FocusedConfig 消费端设计)
|
||||
- Phase 14 方案文档:`docs/20-phase14-document-and-embedding.md`(Provider 复用模式)
|
||||
- 当前代码:`src/agent/session.rs`(submit_turn 流程,OnTurnEnd 位置)
|
||||
- 当前代码:`src/llm/cycle.rs`(submit_messages 签名)
|
||||
- 当前代码:`src/agent/context.rs`(FocusedConfig.summary_override + filter_focused 消费逻辑)
|
||||
- 当前代码:`src/agent/runtime.rs`(AgentConfig 结构)
|
||||
- 当前代码:`src/agent/builder.rs`(Builder 链式模式)
|
||||
- 当前代码:`src/agent/session_memory.rs`(set/get API)
|
||||
@@ -0,0 +1,775 @@
|
||||
# Phase 17 — Agent 执行引擎
|
||||
|
||||
- **文档编号**:23
|
||||
- **标题**:Phase 17 — Agent 执行引擎(Engine)
|
||||
- **日期**:2026-07-15
|
||||
- **状态**:**审查修复完成,待第二轮复审**
|
||||
- **涉及模块**:`engine/`(新建,含 `session_manager` / `checkpointer` / `snapshot` / `error`)、`agent/session`、`agent/context`、`llm/types/usage`
|
||||
- **关联文档**:`docs/17-phase10-contextslot.md`、`docs/22-phase16-summary-auto-generation.md`、`docs/roadmap.md`
|
||||
- **审查记录**:第 1 轮 PM Director + SA Director 审查 → 6 🔴 阻塞问题,全部修复。详见 §变更记录。
|
||||
|
||||
---
|
||||
|
||||
## 背景与目标
|
||||
|
||||
### 问题
|
||||
|
||||
agcore v0.3.0 开发中,已完成 Phase 13-16(Phase 0-12 全部完成)。当前测试 353 个,全部通过,clippy 0 警告。
|
||||
|
||||
当前 `AgentSession` 存在以下空白:
|
||||
|
||||
1. **Session 在变量中**:`AgentSession` 实例仅在内存中存在,无法通过 session ID 从存储恢复
|
||||
2. **无父子关系**:session 之间相互独立,无法表达"子会话继承父会话"的树形关系
|
||||
3. **无检查点**:无法在任意时刻给 session 拍快照,出错后无法回滚到历史状态
|
||||
4. **不可序列化**:`AgentSession` 持有 `Arc<dyn Agent>` 和 `Arc<RuntimeBundle>`,无法直接序列化持久化
|
||||
|
||||
### 目标
|
||||
|
||||
建立 `engine/` 模块,补齐 session 生命周期的管理能力。具体包括:
|
||||
|
||||
1. **Session 工厂 + 按 ID 恢复**:`SessionManager::create()` / `get()`,session 创建后可通过 ID 从存储重建
|
||||
2. **父子 session 树形关系**:`create_child()` / `children()` / `parent()`,支持树形会话拓扑
|
||||
3. **生命周期管理**:`destroy()` 清理 session 及其存储记录
|
||||
4. **Time-travel Checkpointer**:`checkpoint()` / `rollback()` / `list_checkpoints()`,支持任意时刻状态快照与回滚
|
||||
5. **序列化支持**:通过 `SessionSnapshot` 独立 struct 间接实现 `AgentSession` 的快照持久化
|
||||
|
||||
### 成功标准
|
||||
|
||||
1. Session 创建后可通 ID 从存储恢复(`get()` 返回完整状态的 `AgentSession`)
|
||||
2. 父子 session 关系可查询(`children()` / `parent()`),数据正确隔离
|
||||
3. Checkpoint 拍快照后可完全恢复到该时刻状态(turn_index、cost_so_far、slots 一致)
|
||||
4. 零新外部依赖,全量测试 353 → ~385-390
|
||||
5. `cargo test --all-targets` 全绿,`cargo clippy` 0 警告
|
||||
|
||||
---
|
||||
|
||||
## 当前状态分析
|
||||
|
||||
### 模块现状
|
||||
|
||||
| 模块 | 文件 | 状态 | 与 Phase 17 的关系 |
|
||||
|------|------|------|-------------------|
|
||||
| `AgentSession` | `agent/session.rs` | ✅ 已实现 | 需扩展 `to_snapshot()` / `from_snapshot()` |
|
||||
| `ContextSlot` | `agent/context.rs` | ✅ 已实现(持久化、fork/merge/save/load) | 需加 `Serialize` / `Deserialize` derive |
|
||||
| `CostTracker` | `llm/types/usage.rs` | ✅ 已实现 | 需加 `Clone` + `Serialize` / `Deserialize` derive |
|
||||
| `MergeStrategy` | `agent/context.rs` | ✅ 已实现 | 需加 `Serialize` / `Deserialize` derive |
|
||||
| `MemoryStore` trait | `memory/store.rs` | ✅ 已实现 | Checkpointer 的存储后端 |
|
||||
| `RuntimeBundle` | `agent/runtime.rs` | ✅ 已实现(依赖注入容器) | `from_snapshot()` 需注入 `agent` 和 `bundle` |
|
||||
| `InMemoryStore` | `memory/store.rs` | ✅ 已实现 | 测试用存储后端 |
|
||||
| `SqliteStore` | `memory/sqlite_store.rs` | ✅ 已实现(Phase 7) | 生产环境存储后端 |
|
||||
| `Message` | `llm/types/message.rs` | ✅ 已有 `Serialize` / `Deserialize` | 可直接序列化 |
|
||||
|
||||
### AgentSession 关键字段
|
||||
|
||||
```rust
|
||||
pub struct AgentSession {
|
||||
pub session_id: String,
|
||||
pub agent: Arc<dyn Agent>, // ❌ 不可序列化
|
||||
bundle: Arc<RuntimeBundle>, // ❌ 不可序列化
|
||||
turn_index: u32, // ✅ 可序列化
|
||||
cost_so_far: CostTracker, // ⚠️ 需加 derive
|
||||
pub session_memory: SessionMemory, // ⚠️ 间接序列化
|
||||
slots: HashMap<String, ContextSlot>, // ⚠️ 需加 derive
|
||||
current_slot_id: String, // ✅ 可序列化
|
||||
last_summary_turn: Option<u32>, // ✅ 可序列化
|
||||
}
|
||||
```
|
||||
|
||||
核心制约:`Arc<dyn Agent>` 和 `Arc<RuntimeBundle>` 无法 `Serialize` / `Deserialize`,必须通过独立 snapshot struct + 外部注入重建。
|
||||
|
||||
### 关键假设(设计分析 — 需实施后验证)
|
||||
|
||||
以下假设在方案设计中做出,标注验证方式。实施 Step 1-3 后应逐项确认。
|
||||
|
||||
| # | 假设 | 验证方式 |
|
||||
|---|------|---------|
|
||||
| 1 | `submit_turn_stream` 内部 `tokio::spawn` 不持有 `&mut self` → 可通过 `Arc<Mutex<AgentSession>>` 安全共享 | 代码审查覆盖 `submit_with_tools_stream` → `run_tool_loop` 的 spawn 捕获列表;确认所有捕获变量为 owned 数据 |
|
||||
| 2 | `CostTracker` 加 `Clone` 不破坏现有代码 | 编译验证(`cargo build --all-targets`);检查 `CostTracker` 的所有消费方(`session.rs` 中只读引用) |
|
||||
| 3 | `ContextSlot` 加 `Serialize` / `Deserialize` 不影响现有 `save` / `load` 路径 | 现有 `save()` 直接序列化 `self.messages` / `self.meta` / `self.config`,不走 `ContextSlot` 整体 serde → 两组路径可共存 |
|
||||
| 4 | `Message` 已有 `Serialize` / `Deserialize` → 可直接嵌套序列化 | 代码确认(`message.rs` L21 已有 derive) |
|
||||
| 5 | `EngineError` 不需要 `derive Serialize` → 纯运行时错误类型 | Checkpoint 只存 `SessionSnapshot`,不存错误枚举 |
|
||||
| 6 | `MemoryStore` 操作是可靠的——失败时返回 `EngineError::Memory` 透传错误 | 当前不内置 store 重试逻辑;调用方负责 retry 或 failover |
|
||||
| 7 | session_id 使用 UUID v4 自动生成,冲突概率可忽略 | 实施确定 ID 生成方案(`uuid::Uuid::new_v4()` 或 时间戳+计数器无依赖方案) |
|
||||
| 8 | session_memory 当前只支持字符串值;未来支持复杂类型时 `SessionMemoryEntry` 的 `value` 字段需改用 `serde_json::Value` | 已预留在注释中 |
|
||||
|
||||
---
|
||||
|
||||
## 调研发现
|
||||
|
||||
### 可选方案对比
|
||||
|
||||
#### 方案 A(推荐):SessionSnapshot + 组合式架构
|
||||
|
||||
**做法**:用一个独立 `SessionSnapshot` struct 存储可序列化状态,避开 `Arc<dyn Agent>` 的序列化限制。`Checkpointer` 作为独立 struct,`SessionManager` 组合持有 `Checkpointer`。
|
||||
|
||||
**优点**:
|
||||
- 不污染 `AgentSession` 主类型,序列化逻辑与运行逻辑分离
|
||||
- `Checkpointer` 独立可测,不依赖 `SessionManager`
|
||||
- 组合关系清晰:`SessionManager` 持有 `Checkpointer`
|
||||
- 所有字段使用 `#[serde(default)]` 宽松反序列化,前向兼容
|
||||
|
||||
**缺点**:
|
||||
- 需要额外同步逻辑:`to_snapshot()` / `from_snapshot()` 双向转换
|
||||
|
||||
#### 方案 B(已否决):直接给 AgentSession derive Serialize
|
||||
|
||||
**做法**:给 `AgentSession` 加 `#[derive(Serialize)]`,用 `#[serde(skip)]` 跳过 `agent` 和 `bundle`。
|
||||
|
||||
**否决原因**:
|
||||
1. `#[serde(skip)]` 跳过了 2 个核心字段,序列化后的结果名不副实
|
||||
2. 技术债重:主类型获得"跳过一半字段"的诡异 serde 行为,未来维护者可能误以为 `AgentSession` 可整体序列化/反序列化
|
||||
3. 反序列化时 `agent` 和 `bundle` 缺失,仍需外部注入 → 不如直接使用独立的 snapshot struct
|
||||
|
||||
#### 方案 C(已否决):Checkpointer 作为 SessionManager 内部方法
|
||||
|
||||
**做法**:将 `checkpoint` / `rollback` 直接作为 `SessionManager` 的方法。
|
||||
|
||||
**否决原因**:
|
||||
1. 违反单一职责原则(SRP):`SessionManager` 承担 session 生命周期 + 检查点管理双重责任
|
||||
2. 破坏独立可测试性:检查点逻辑与 `SessionManager` 耦合
|
||||
3. `rollback` 返回后自动注册到 `SessionManager`,但调用方可能不需要注册
|
||||
4. 应返回 `AgentSession` 让调用方决定如何处理
|
||||
|
||||
### 技术决策清单
|
||||
|
||||
| 编号 | 决策项 | 选择 | 理由 |
|
||||
|------|--------|------|------|
|
||||
| D1 | 序列化方式 | `SessionSnapshot` 独立 struct | 不污染 `AgentSession`,序列化逻辑与运行逻辑分离 |
|
||||
| D2 | 并发模型 | `tokio::sync::Mutex` | 安全跨 `.await`,与 `AgentSession` 现有模式一致 |
|
||||
| D3 | 模块拆分 | `Checkpointer` 独立 + `SessionManager` 组合 | 独立可测,SRP 合规 |
|
||||
| D4 | 存储格式 | 全量 JSON | 简洁可靠,ponytail:>500 轮再优化为增量 |
|
||||
| D5 | Key 命名 | `session:{id}:meta` / `ckpt:{id}:{ckpt_id}` | 与 `slot_data:` 风格一致,prefix 查询友好 |
|
||||
| D6 | Checkpoint 触发 | `SessionManager` 封装方法中自动;同步写入 + `tracing::error!` 记录失败 | `AgentSession` 保持纯净;不提供强持久化保证(显式调 `checkpointer.checkpoint()` 确认) |
|
||||
| D7 | 序列化兼容 | `#[serde(default)]` 宽松 | 防前向破坏,新增字段自动兼容旧快照 |
|
||||
| D8 | 流式 checkpoint 时序 | 仅在 `finalize_turn` 时创建 checkpoint | `submit_turn_stream` 返回流时不做 checkpoint;客户端断开后不留下半成品 checkpoint 污染 |
|
||||
| D9 | `SessionManager` trait | 不需要 | YAGNI,无多后端需求 |
|
||||
| D10 | `CostTracker` / `ContextSlot` / `MergeStrategy` derive | 加 `Clone` + `Serialize` / `Deserialize` | 共约 7 行改动,支持快照序列化 |
|
||||
|
||||
### MVP 范围
|
||||
|
||||
| 做(Phase 17 首批) | 推迟 |
|
||||
|---------------------|------|
|
||||
| ① `SessionManager`: `create` / `get` / `create_child` / `children` / `parent` / `destroy` / `replace` / `recover` | ① `destroy_subtree` — 首次只做单节点 `destroy`。父被销毁后子 session 的 `parent()` 返回 `None`(允许孤儿)。调用方如需级联删除应自行遍历。 |
|
||||
| ② `Checkpointer`: `checkpoint` / `rollback` / `list_checkpoints` / `delete_all` | ② `tree()` — `children()` + `parent()` 组合查询在 v0.3 够用;Phase 18 SubAgent Dispatch 需要全量树快照时再补。 |
|
||||
| ③ `SessionSnapshot` + `to_snapshot()` / `from_snapshot()`(位于 `engine/snapshot.rs`)+ `restore_memory()` | ③ `Checkpointer::fork` — 推迟理由:`fork` 底层可拆解为 `rollback` + `create_child`,当前 Checkpointer + SessionManager 已提供原始能力。`fork` 作为高层 API 等价于约 30 行组合代码,风险可控延后到 Phase 18。若产品认为 fork 是 time-travel MVP 的必要项,可重新划入 Phase 17。 |
|
||||
| ④ `EngineError`(含 `MemoryError` 透传) | |
|
||||
| ⑤ 涉及的 derive 改动(`CostTracker` + `ContextSlot` + `MergeStrategy`) | |
|
||||
|
||||
**变更记录**(审查修复):
|
||||
- `create()` / `create_child()` 返回类型改为 `Result<String, EngineError>`
|
||||
- `get()` 改为仅内存查询,新增 `recover()` 显式恢复方法
|
||||
- 新增 `replace()` 方法支持 rollback 后无缝切换
|
||||
- MVP 推迟列补充 `tree()`(含推迟理由)、完善 `destroy_subtree`(定义孤儿语义)、
|
||||
补充 `fork` 推迟理由(含技术拆解和产品权衡)
|
||||
|
||||
---
|
||||
|
||||
## 推荐方案
|
||||
|
||||
### 架构概览
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Engine │
|
||||
│ ┌────────────────┐ ┌──────────────────┐ │
|
||||
│ │ SessionManager │──│ Checkpointer │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ create() │ │ checkpoint() │ │
|
||||
│ │ get() │ │ rollback() │ │
|
||||
│ │ create_child() │ │ list_checkpoints│ │
|
||||
│ │ children() │ │ │ │
|
||||
│ │ parent() │ └──────────────────┘ │
|
||||
│ │ destroy() │ │
|
||||
│ └────────┬───────┘ │
|
||||
│ │ 组合 │
|
||||
│ │ 持有 │
|
||||
│ ▼ │
|
||||
│ ┌────────────────┐ │
|
||||
│ │ MemoryStore │ ── 存储后端 │
|
||||
│ └────────────────┘ │
|
||||
└──────────────────────────────────────────────┘
|
||||
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ SessionSnapshot │ ── 可序列化的状态快照
|
||||
│ (to/from │
|
||||
│ AgentSession) │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
### 模块划分
|
||||
|
||||
**新增文件**(5 个):
|
||||
|
||||
```
|
||||
src/engine/
|
||||
├── mod.rs # 约 30 行:模块根 + pub use 重导出
|
||||
├── session_manager.rs # 约 300 行:SessionManager 实现(含 replace/recover)
|
||||
├── checkpointer.rs # 约 220 行:Checkpointer 实现
|
||||
├── snapshot.rs # 约 50 行:SessionSnapshot + SessionMemoryEntry 定义
|
||||
└── error.rs # 约 70 行:EngineError 枚举
|
||||
```
|
||||
|
||||
**修改文件**(5 个):
|
||||
|
||||
| 文件 | 改动量 | 内容 |
|
||||
|------|--------|------|
|
||||
| `src/agent/session.rs` | +~80 行 | `to_snapshot()` / `from_snapshot()` / `restore_memory()` |
|
||||
| `src/agent/context.rs` | +4 行 | `ContextSlot` + `MergeStrategy` 加 `Serialize` / `Deserialize` |
|
||||
| `src/llm/types/usage.rs` | +3 行 | `CostTracker` 加 `Clone` + `Serialize` / `Deserialize` |
|
||||
| `src/lib.rs` | +2 行 | `pub mod engine` 声明 |
|
||||
| `examples/engine_demo.rs` | +~100 行(新增) | 端到端示例(含 rollback + replace 流程) |
|
||||
|
||||
### SessionSnapshot(位于 `engine/snapshot.rs`)
|
||||
|
||||
设计决策:`SessionSnapshot` 是 engine 层为持久化引入的序列化 DTO,定义在 `engine/snapshot.rs` 而非 `agent/session.rs`,保持依赖方向为 `engine → agent`。
|
||||
|
||||
```rust
|
||||
/// SessionMemory 条目的可序列化形式(保留元数据与时间戳)。
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct SessionMemoryEntry {
|
||||
pub value: String,
|
||||
#[serde(default)]
|
||||
pub metadata: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<i64>, // Unix 时间戳秒;Option 兼容旧快照
|
||||
}
|
||||
|
||||
/// AgentSession 的可序列化快照。
|
||||
///
|
||||
/// 不持有 `Arc<dyn Agent>` 和 `Arc<RuntimeBundle>` —— 这两个由调用方在
|
||||
/// `from_snapshot()` 时注入。所有字段使用 `#[serde(default)]` 确保前向兼容。
|
||||
///
|
||||
/// **变更记录**(审查修复):
|
||||
/// - 位置从 `agent/session.rs` 移至 `engine/snapshot.rs`
|
||||
/// - `session_memory_data` 从 `HashMap<String, String>` 改为 `HashMap<String, SessionMemoryEntry>`
|
||||
/// 保留 metadata 和 created_at,避免恢复后时间戳丢失
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub(crate) struct SessionSnapshot {
|
||||
pub session_id: String,
|
||||
pub agent_name: String,
|
||||
pub turn_index: u32,
|
||||
#[serde(default)]
|
||||
pub cost_so_far: CostTracker,
|
||||
#[serde(default)]
|
||||
pub slots: HashMap<String, ContextSlot>,
|
||||
pub current_slot_id: String,
|
||||
pub last_summary_turn: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub session_memory_data: HashMap<String, SessionMemoryEntry>,
|
||||
}
|
||||
```
|
||||
|
||||
### AgentSession 扩展方法
|
||||
|
||||
```rust
|
||||
impl AgentSession {
|
||||
/// 将当前状态拍平为 SessionSnapshot。
|
||||
///
|
||||
/// **需要 async**:因为 session_memory 的数据存储在 `MemoryStore` 中,读取需要异步 I/O。
|
||||
/// 可通过 `SessionMemory::list_entries()` 获取完整条目(含 metadata/created_at):
|
||||
///
|
||||
/// ```ignore
|
||||
/// let entries = self.session_memory.list_entries().await?;
|
||||
/// for (key, value, metadata, created_at) in entries {
|
||||
/// map.insert(key, SessionMemoryEntry { value, metadata, created_at: Some(created_at) });
|
||||
/// }
|
||||
/// ```
|
||||
/// `from_snapshot` 保持同步(构造器不应做 I/O),`to_snapshot` 做 async(快照输出可 I/O)—
|
||||
/// 两个方向不矛盾,设计上各自成立。
|
||||
pub async fn to_snapshot(&self) -> SessionSnapshot {
|
||||
// 拍平 session_memory → HashMap<String, SessionMemoryEntry>(通过 list_entries)
|
||||
// 复制 slots / cost_so_far / turn_index 等可序列化字段
|
||||
}
|
||||
|
||||
/// 从 SessionSnapshot + agent + bundle 重建 AgentSession。
|
||||
///
|
||||
/// **纯同步重建**:只做内存数据结构恢复(slots/turn_index/cost_so_far 等),
|
||||
/// 不执行任何 I/O。session_memory 的持久层恢复由 `restore_memory()` 完成。
|
||||
///
|
||||
/// 调用方负责:
|
||||
/// - 提供与 `agent_name` 对应的 `Arc<dyn Agent>`
|
||||
/// - 提供合法的 `Arc<RuntimeBundle>`
|
||||
///
|
||||
/// 返回 `Result` 以传播序列化反序列化错误(如 JSON 格式不兼容)。
|
||||
pub fn from_snapshot(
|
||||
snapshot: SessionSnapshot,
|
||||
agent: Arc<dyn Agent>,
|
||||
bundle: Arc<RuntimeBundle>,
|
||||
) -> Result<Self, EngineError> {
|
||||
// session_memory_data 存入临时字段(不写 store)
|
||||
// 重建 slots HashMap
|
||||
// 恢复 turn_index / cost_so_far / last_summary_turn
|
||||
}
|
||||
|
||||
/// 将 snapshot 中的 session_memory_data 写回持久层。
|
||||
/// 从 `from_snapshot()` 中剥离的异步操作,调用方显式 await。
|
||||
/// 放置在 `restore_memory` 而非构造函数中,确保构造函数是纯同步的。
|
||||
///
|
||||
/// **错误处理**:逐条写入,某条失败时返回 Err 但不回滚已写入的条目。
|
||||
/// 调用方可选择重试或忽略(不影响 AgentSession 内存状态)。
|
||||
pub async fn restore_memory(&self) -> Result<(), EngineError>;
|
||||
}
|
||||
```
|
||||
|
||||
**标准使用流程**:
|
||||
```rust
|
||||
// rollback:四步走
|
||||
let snapshot = cp.rollback_load(session_id, ckpt_id).await?; // ① 从存储读
|
||||
let session = AgentSession::from_snapshot(snapshot, agent, bundle)?; // ② 同步重建
|
||||
session.restore_memory().await?; // ③ 恢复持久层
|
||||
sm.replace(session_id, session).await?; // ④ 注册到 Manager
|
||||
```
|
||||
|
||||
**checkpoint 流程**(自动或显式调用):
|
||||
```rust
|
||||
// checkpoint 内部:
|
||||
let snapshot = session.to_snapshot().await; // async:从 MemoryStore 读取 session_memory
|
||||
cp.save(snapshot).await?;
|
||||
```
|
||||
|
||||
### Checkpointer 公开 API
|
||||
|
||||
```rust
|
||||
/// 检查点元数据。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CkptMeta {
|
||||
pub ckpt_id: String,
|
||||
pub session_id: String,
|
||||
pub turn_index: u32,
|
||||
pub created_at: u64, // Unix 时间戳,秒
|
||||
}
|
||||
|
||||
/// Time-travel 检查点管理器。
|
||||
///
|
||||
/// **不依赖 SessionManager**,可独立使用。直接操作 MemoryStore。
|
||||
/// 存储 key 格式:`ckpt:{session_id}:{ckpt_id}` → SessionSnapshot JSON
|
||||
pub struct Checkpointer {
|
||||
store: Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
impl Checkpointer {
|
||||
/// 创建新检查点。返回 ckpt_id。
|
||||
pub async fn checkpoint(&self, session: &AgentSession) -> Result<String, EngineError>;
|
||||
|
||||
/// 回滚到指定检查点。返回恢复后的 AgentSession。
|
||||
///
|
||||
/// 调用方需提供 `agent` 和 `bundle`(与 SessionSnapshot 反序列化的要求一致)。
|
||||
/// rollback 不自动注册到任何 SessionManager——调用方决定如何处理返回的 session。
|
||||
pub async fn rollback(
|
||||
&self,
|
||||
session_id: &str,
|
||||
ckpt_id: &str,
|
||||
agent: Arc<dyn Agent>,
|
||||
bundle: Arc<RuntimeBundle>,
|
||||
) -> Result<AgentSession, EngineError>;
|
||||
|
||||
/// 列出某 session 的所有检查点(按创建时间降序)。
|
||||
pub async fn list_checkpoints(&self, session_id: &str)
|
||||
-> Result<Vec<CkptMeta>, EngineError>;
|
||||
|
||||
/// 删除某 session 的所有检查点(session 被 destroy 时调用)。
|
||||
pub async fn delete_all(&self, session_id: &str) -> Result<(), EngineError>;
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:`Checkpointer::fork()` 推迟到 Phase 18(详见 MVP 范围表)。
|
||||
|
||||
**关于 Checkpointer 的独立可用性**:Snapshot 数据的读写(`checkpoint` / `list_checkpoints`)不依赖 SessionManager,可直接用 `Checkpointer` 操作 MemoryStore。但 `rollback()` 重建 AgentSession 需要调用方提供与 session_id 匹配的 `Arc<dyn Agent>` 和 `Arc<RuntimeBundle>`——调用方需自行管理 agent→session 的映射(或通过 `SessionMeta.agent_name` 查询注册表)。
|
||||
|
||||
### SessionManager 公开 API
|
||||
|
||||
```rust
|
||||
/// SessionManager 配置。
|
||||
pub struct SessionManagerConfig {
|
||||
/// 每次 submit_turn 后是否自动 checkpoint(默认 true)。
|
||||
pub auto_checkpoint: bool,
|
||||
/// 默认 RuntimeBundle,用于从存储重建 session 时的 bundle 注入。
|
||||
/// 如果为 None,`recover()` 需要调用方手动传入 bundle。
|
||||
pub default_bundle: Option<Arc<RuntimeBundle>>,
|
||||
}
|
||||
|
||||
impl Default for SessionManagerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_checkpoint: true,
|
||||
default_bundle: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session 生命周期管理器。
|
||||
///
|
||||
/// 组合持有 Checkpointer,提供 session 的 CRUD、树形关系查询和自动检查点。
|
||||
/// 内部用 `HashMap<String, Arc<tokio::sync::Mutex<AgentSession>>>` 管理活跃 session。
|
||||
/// 存储 key 格式:`session:{session_id}:meta` → SessionMeta JSON
|
||||
///
|
||||
/// **锁契约**:
|
||||
/// - 所有写操作(create/destroy/replace)内部先完成 HashMap 操作,释放 RwLock 后再调用
|
||||
/// Checkpointer/MemoryStore 的异步 I/O。调用方不应假设某个操作持有跨 .await 点的锁。
|
||||
/// - `get()` 返回 `Arc<Mutex<AgentSession>>` 后立即释放 RwLock 读锁,调用方持有的是
|
||||
/// session 级别的 Mutex 锁而非管理器级别的锁。
|
||||
pub struct SessionManager {
|
||||
sessions: RwLock<HashMap<String, Arc<tokio::sync::Mutex<AgentSession>>>>,
|
||||
checkpointer: Checkpointer,
|
||||
store: Arc<dyn MemoryStore>,
|
||||
config: SessionManagerConfig,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
/// 创建新 session。session_id 由内部自动生成(UUID v4)。
|
||||
/// 持久化 SessionMeta 后注册到 sessions HashMap。
|
||||
pub async fn create(
|
||||
&self,
|
||||
agent: Arc<dyn Agent>,
|
||||
bundle: Arc<RuntimeBundle>,
|
||||
) -> Result<String, EngineError>;
|
||||
|
||||
/// 从父 session 创建子 session(继承父的 RuntimeBundle,Arc::clone 共享引用)。
|
||||
/// session_id 由内部自动生成(UUID v4)。
|
||||
/// 如果 `parent_id` 不存在,返回 `EngineError::SessionNotFound(parent_id)`。
|
||||
pub async fn create_child(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
agent: Arc<dyn Agent>,
|
||||
) -> Result<String, EngineError>;
|
||||
|
||||
/// 按 ID 获取 session(仅查内存,不自动从存储恢复)。
|
||||
/// 冷启动时 `get()` 未命中返回 `EngineError::SessionNotFound`。
|
||||
/// 如需从存储恢复,使用 `recover()` 方法。
|
||||
pub async fn get(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<Arc<tokio::sync::Mutex<AgentSession>>, EngineError>;
|
||||
|
||||
/// 从存储恢复 session。需要调用方提供 agent 和 bundle(与 SessionSnapshot
|
||||
/// 反序列化的要求一致)。
|
||||
/// 恢复后自动注册到 sessions HashMap(与 create 的行为一致)。
|
||||
pub async fn recover(
|
||||
&self,
|
||||
session_id: &str,
|
||||
agent: Arc<dyn Agent>,
|
||||
bundle: Arc<RuntimeBundle>,
|
||||
) -> Result<Arc<tokio::sync::Mutex<AgentSession>>, EngineError>;
|
||||
|
||||
/// 替换 SessionManager 中指定 session_id 的 AgentSession 实例。
|
||||
/// 用于 Checkpointer::rollback() 后的无缝切换:
|
||||
/// ```ignore
|
||||
/// let rolled_back = cp.rollback(sid, ckpt_id, agent.clone(), bundle.clone()).await?;
|
||||
/// sm.replace(sid, rolled_back).await?;
|
||||
/// ```
|
||||
/// 内部执行:内存替换 + 写回 SessionMeta。
|
||||
pub async fn replace(
|
||||
&self,
|
||||
session_id: &str,
|
||||
session: AgentSession,
|
||||
) -> Result<(), EngineError>;
|
||||
|
||||
/// 查询某 parent 的所有直接子 session 的 ID 列表。
|
||||
pub async fn children(&self, parent_id: &str) -> Result<Vec<String>, EngineError>;
|
||||
|
||||
/// 查询某 child session 的 parent ID。
|
||||
/// 如果 parent 已被销毁,返回 `Ok(None)`(允许孤儿 session 存在)。
|
||||
pub async fn parent(&self, child_id: &str) -> Result<Option<String>, EngineError>;
|
||||
|
||||
/// 销毁 session:从内存移除 + 清理 SessionMeta + 清理检查点。
|
||||
///
|
||||
/// **父子关系处理**:允许孤儿 session 存在(子 session 的 parent_id 仍指向已删除的父,
|
||||
/// 但 `parent()` 返回 `None`)。不递归删除子 session——调用方如需级联删除应自行遍历。
|
||||
pub async fn destroy(&self, session_id: &str) -> Result<(), EngineError>;
|
||||
|
||||
/// 暴露 Checkpointer 引用(调用方可直接操作检查点)。
|
||||
pub fn checkpointer(&self) -> &Checkpointer;
|
||||
}
|
||||
```
|
||||
|
||||
**变更记录**(审查修复):
|
||||
- `create()` 返回类型从 `String` 改为 `Result<String, EngineError>`
|
||||
- `create()` / `create_child()` session_id 统一为内部自动生成(UUID v4)
|
||||
- `get()` 改为"仅查内存",新增 `recover()` 显式恢复方法
|
||||
- 新增 `replace()` 方法支持 rollback 后的无缝替换
|
||||
- `destroy()` 明确孤儿策略:允许孤儿存在,不递归删除
|
||||
- `create_child()` 不再接受 `child_id` 参数(统一自动生成)
|
||||
- 锁契约明确化为 struct doc comment
|
||||
- `SessionManagerConfig` 新增 `default_bundle` 字段为后续扩展预留
|
||||
|
||||
### SessionMeta
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct SessionMeta {
|
||||
pub session_id: String,
|
||||
pub agent_name: String,
|
||||
pub parent_id: Option<String>,
|
||||
pub created_at: u64, // Unix 时间戳,秒
|
||||
pub turn_count: u32,
|
||||
}
|
||||
```
|
||||
|
||||
### 存储 Key 命名
|
||||
|
||||
| Key 模式 | 内容 | 说明 |
|
||||
|----------|------|------|
|
||||
| `session:{session_id}:meta` | `SessionMeta` JSON | session 元数据,含 parent_id |
|
||||
| `ckpt:{session_id}:{ckpt_id}` | `SessionSnapshot` JSON | 全量检查点,含 slots |
|
||||
|
||||
风格与 `ContextSlot` 的 `slot_data:{session_id}:{slot_id}` 一致:`前缀:session_id:后缀`。
|
||||
|
||||
**关于两种持久化路径共存**:`ContextSlot::save()`(增量消息持久化)和 `Checkpointer::checkpoint()`(全量快照)是互补的"增量基线 vs 全量备份"关系:
|
||||
- `ContextSlot::save()` 每轮追加消息到 slot 存储(增量),是进程重启后消息不丢的基线
|
||||
- `Checkpointer::checkpoint()` 全量序列化 session 状态(含所有 slot 消息),是 time-travel 回滚的快照
|
||||
- rollback 时优先使用 checkpoint 的 snapshot 数据(一致性保证),不依赖 slot 持久化中的消息状态
|
||||
|
||||
### EngineError
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum EngineError {
|
||||
/// 指定 session_id 不存在。
|
||||
/// 适用场景:get() 内存未命中、create_child() parent 不存在、destroy() 操作不存在的 session。
|
||||
#[error("Session not found: {0}")]
|
||||
SessionNotFound(String),
|
||||
|
||||
/// 创建 session 时 ID 已存在(自动生成 ID 时通常不会触发)。
|
||||
#[error("Session already exists: {0}")]
|
||||
SessionAlreadyExists(String),
|
||||
|
||||
/// 指定 ckpt_id 不存在。
|
||||
#[error("Checkpoint not found: {0}")]
|
||||
CheckpointNotFound(String),
|
||||
|
||||
/// 存储错误(透传 MemoryError)。
|
||||
/// Checkpointer 和 SessionManager 的所有 MemoryStore 操作通过此变体传播错误。
|
||||
/// 与项目既有模式一致(对比 AgentError:直接 #[from] LlmError/ToolError/MemoryError)。
|
||||
#[from]
|
||||
#[error("存储错误: {0}")]
|
||||
Memory(#[from] MemoryError),
|
||||
|
||||
/// 序列化/反序列化失败(serde_json/snapshot 格式错误)。
|
||||
#[error("序列化错误: {0}")]
|
||||
Serialization(String),
|
||||
|
||||
/// Agent 错误(透传 AgentError)。
|
||||
#[from]
|
||||
#[error("Agent 错误: {0}")]
|
||||
Agent(#[from] AgentError),
|
||||
}
|
||||
```
|
||||
|
||||
### 并发模型
|
||||
|
||||
`SessionManager` 内部使用 `tokio::sync::RwLock` 保护 `sessions: HashMap`:
|
||||
|
||||
```rust
|
||||
pub struct SessionManager {
|
||||
sessions: RwLock<HashMap<String, Arc<tokio::sync::Mutex<AgentSession>>>>,
|
||||
// ... 其他字段
|
||||
}
|
||||
```
|
||||
|
||||
- `RwLock` 适合读多写少的场景(`get()` 高频 > `create()` / `destroy()`)
|
||||
- `get()` 返回 `Arc<Mutex<AgentSession>>` 后立即释放 RwLock 读锁,调用方持有的是 session 级别的 Mutex 锁而非管理器级别的锁。**不持有 RwLock 跨越 .await**
|
||||
- 所有写操作(`create`/`destroy`/`replace`)先完成 HashMap 操作(持有写锁),释放 RwLock 后再调用 Checkpointer/MemoryStore 的异步 I/O
|
||||
- 返回的 `AgentSession` 用 `Arc<tokio::sync::Mutex<AgentSession>>` 包裹,支持跨 `.await` 的安全可变访问
|
||||
- `Checkpointer` 无锁(纯函数式操作 MemoryStore,依赖其内部实现)
|
||||
|
||||
---
|
||||
|
||||
## 实施建议
|
||||
|
||||
### 阶段划分(共 7 步)
|
||||
|
||||
```
|
||||
Step 1: 前置 derive 改动 → step-1-branch
|
||||
Step 2: EngineError + 模块骨架 → step-2-branch
|
||||
Step 3: SessionSnapshot + 扩展 → step-3-branch
|
||||
Step 4: Checkpointer → step-4-branch
|
||||
Step 5: SessionManager → step-5-branch
|
||||
Step 6: 自动 checkpoint 集成 → step-6-branch
|
||||
Step 7: 示例 + 测试补强 → step-7-branch
|
||||
```
|
||||
|
||||
#### Step 1:前置 derive 改动
|
||||
|
||||
- **文件**:`src/llm/types/usage.rs`、`src/agent/context.rs`(×2)
|
||||
- **内容**:
|
||||
- `CostTracker`:`#[derive(Debug, Default)]` → `#[derive(Debug, Default, Clone, Serialize, Deserialize)]`
|
||||
- `ContextSlot`:`#[derive(Debug, Clone)]` → `#[derive(Debug, Clone, Serialize, Deserialize)]`
|
||||
- `MergeStrategy`:`#[derive(Debug, Clone)]` → `#[derive(Debug, Clone, Serialize, Deserialize)]`
|
||||
- **验证**:`cargo build --all-targets` 编译通过
|
||||
|
||||
#### Step 2:EngineError + 模块骨架
|
||||
|
||||
- **文件**:
|
||||
- `src/engine/error.rs`(新增):`EngineError` 枚举定义
|
||||
- `src/engine/mod.rs`(新增):模块根声明 + `pub use` 重导出 `EngineError` / `SessionManager` / `Checkpointer` / `CkptMeta`
|
||||
- `src/lib.rs`(修改):加 `pub mod engine;`
|
||||
- **验证**:`cargo build --all-targets && cargo clippy --all-targets -- -D warnings`
|
||||
|
||||
#### Step 3:SessionSnapshot + AgentSession 扩展
|
||||
|
||||
- **文件**:`src/engine/snapshot.rs`(新增,来自 SA 审查建议)、`src/agent/session.rs`
|
||||
- **内容**:
|
||||
- `src/engine/snapshot.rs`:`SessionMemoryEntry` 结构体(含 `value`/`metadata`/`created_at`)、`SessionSnapshot` 结构体定义(`pub(crate)`)
|
||||
- `src/agent/session.rs`:`pub async fn to_snapshot(&self) -> SessionSnapshot`(**异步**,通过 `SessionMemory::list_entries()` 读取完整 session_memory 条目,复制 slots/cost_so_far/各标量字段)
|
||||
- `pub fn from_snapshot(snapshot, agent, bundle) -> Result<Self, EngineError>`(**纯同步**,不写 store;session_memory_data 暂存于内存,不写入持久层)
|
||||
- `pub async fn restore_memory(&self) -> Result<(), EngineError>`(异步,将 from_snapshot 暂存的 session_memory_data 写回持久层;逐条写入,失败时记录 error 但不回滚已写入条目)
|
||||
- `SessionMemory` 新增 `list_entries()` 方法返回 `Vec<(String, String, serde_json::Value, i64)>`(含 value/metadata/created_at),供 `to_snapshot` 消费
|
||||
- **验证**:单元测试 roundtrip(`to_snapshot().await` → `from_snapshot()` → 关键字段一致);`restore_memory` 幂等性测试
|
||||
|
||||
#### Step 4:Checkpointer
|
||||
|
||||
- **文件**:`src/engine/checkpointer.rs`(新增)
|
||||
- **内容**:
|
||||
- `Checkpointer` 结构体(持有 `Arc<dyn MemoryStore>`)
|
||||
- `CkptMeta` 结构体
|
||||
- `checkpoint()`:生成 ckpt_id(时间戳+计数器方案优先,ponytail;`uuid` 备选,需加依赖),`session.to_snapshot()` → JSON → 存 `ckpt:{session_id}:{ckpt_id}`
|
||||
- `rollback_load()`(两阶段 rollback 的第一阶段):读取 JSON → 反序列化为 `SessionSnapshot` → 返回 `SessionSnapshot`
|
||||
- 调用方拿到 `SessionSnapshot` 后,自行调用 `AgentSession::from_snapshot()`(纯同步)+ `restore_memory()`(异步)+ `SessionManager::replace()`(注册)
|
||||
- `list_checkpoints()`:prefix 查询 `ckpt:{session_id}:` → 反序列化 `CkptMeta`(从 snapshot JSON 中提取 `turn_index` / `created_at`)→ 按时间降序
|
||||
- `delete_all()`:prefix 查询 + 逐个删除
|
||||
- **验证**:3-5 个单元测试(checkpoint roundtrip / rollback_load 反序列化正确 / list 排序 / delete_all 幂等性)
|
||||
|
||||
#### Step 5:SessionManager
|
||||
|
||||
- **文件**:`src/engine/session_manager.rs`(新增)
|
||||
- **内容**:
|
||||
- `SessionManagerConfig` 结构体(含 `auto_checkpoint: bool` + `default_bundle: Option<Arc<RuntimeBundle>>`)
|
||||
- `SessionMeta` 结构体(`pub(crate)`)
|
||||
- `SessionManager` 结构体(`RwLock<HashMap<...>>` + `Checkpointer` + `store` + `config`)
|
||||
- `create()`:内部自动生成 session_id(UUID v4),`AgentSession::new()` → 存 `SessionMeta` → 注册到 `sessions` HashMap → `Ok(session_id)`
|
||||
- `create_child()`:验证 parent 存在 → 自动生成 child session_id → 设置 `parent_id` → `create()` 流程
|
||||
- `get()`:**仅查内存**,未命中返回 `SessionNotFound`(不自动从存储恢复)
|
||||
- `recover(session_id, agent, bundle)`:从存储读取 `SessionMeta` + 调 `Checkpointer` 最近 checkpoint → 重建 `AgentSession` → 注册到 HashMap
|
||||
- `replace(session_id, session)`:内存替换(覆盖 Mutex 中的 AgentSession)+ 写回 SessionMeta
|
||||
- `children(parent_id)`:prefix 查询 `session:{parent_id}:` → 过滤 `parent_id` 匹配 → 返回 child_id 列表
|
||||
- `parent(child_id)`:读 `SessionMeta.parent_id`,父已被销毁时返回 `Ok(None)`
|
||||
- `destroy(session_id)`:移除内存记录 → 删除 `SessionMeta` → 调 `Checkpointer::delete_all()`。**允许孤儿 session 存在**(不递归删除子 session)
|
||||
- **验证**:8-10 个单元测试(CRUD / recover 恢复 / replace 替换 / 树形关系 / session 隔离 / destroy 后 get 失败 / 孤儿 parent 返回 None)
|
||||
|
||||
#### Step 6:自动 checkpoint 集成
|
||||
|
||||
- **文件**:`src/engine/session_manager.rs`(扩展)
|
||||
- **内容**:
|
||||
- 在 `SessionManager` 上添加封装方法 `submit_turn(session_id, user_input)`,内部:
|
||||
1. `get(session_id)` 获取 session
|
||||
2. `session.lock().await.submit_turn(user_input).await`
|
||||
3. 如果 `config.auto_checkpoint == true`,同步调用 `checkpointer.checkpoint(&session).await`
|
||||
- checkpoint 失败时通过 `tracing::error!` 记录,不阻断 `submit_turn` 的 `Ok` 返回
|
||||
- 调用方如需强持久化保证,应显式调用 `checkpointer.checkpoint()` 并处理其 `Result`
|
||||
- 流式路径:仅在 `finalize_turn` 时创建 checkpoint(`submit_turn_stream` 返回流时不做 checkpoint)
|
||||
- 客户端断开连接导致 `finalize_turn` 未被调用时,保持上一个 checkpoint 的状态,不留下半成品 checkpoint 污染
|
||||
- `auto_checkpoint` 配置控制开关
|
||||
- **验证**:集成测试(`submit_turn` → `list_checkpoints` 中可查到新 checkpoint);关闭 `auto_checkpoint` 时不产生 checkpoint
|
||||
|
||||
#### Step 7:示例 + 测试补强 + Tracing 埋点
|
||||
|
||||
- **文件**:`examples/engine_demo.rs`(新增,~100 行)
|
||||
- **示例流程**:
|
||||
1. `SessionManager::create` → submit_turn
|
||||
2. `Checkpointer::checkpoint` → list_checkpoints
|
||||
3. `Checkpointer::rollback` + `AgentSession::restore_memory` + `SessionManager::replace`
|
||||
4. 验证回滚后 turn_index 和 cost 恢复到 checkpoint 时刻
|
||||
- **Tracing 埋点**(每个关键操作添加 `tracing` 日志,与项目既有风格一致):
|
||||
- `Checkpointer::checkpoint()` 成功时:`tracing::info!(ckpt_id, turn_index, snapshot_size, "checkpoint created")`
|
||||
- `Checkpointer::rollback()` 成功时:`tracing::info!(ckpt_id, session_id, turn_index, "rolled back")`
|
||||
- `Checkpointer::list_checkpoints` → `tracing::debug!(session_id, count)`
|
||||
- `SessionManager::create` → `tracing::info!(session_id, agent_name, "session created")`
|
||||
- `SessionManager::destroy` → `tracing::info!(session_id, "session destroyed")`
|
||||
- `SessionManager::get` / `recover` / `replace` → `tracing::debug!(session_id, ...)`
|
||||
- 序列化错误 / 存储错误 → `tracing::error!(session_id, error, ...)`
|
||||
- **补充测试**(12-15 个):
|
||||
- 空 slot checkpoint → rollback 后消息为空
|
||||
- Destroy 后再 checkpoint → 返回 `SessionNotFound`
|
||||
- 跨 session 检查点隔离(session A checkpoint 不影响 session B)
|
||||
- 序列化版本兼容(`#[serde(default)]` 兜底:缺少新字段的旧 snapshot 可正常反序列化)
|
||||
- 10 并发 session 创建/销毁(RwLock 写锁争用验证)
|
||||
- 父子 session 消息隔离(子 session 写数据不污染父 session)
|
||||
- `restore_memory` 幂等性(重复调用不产生重复数据)
|
||||
- `from_snapshot` 纯同步验证(检查构造过程中无 async 调用路径)
|
||||
- **验证**:`cargo test --all-targets` 全绿 + `cargo clippy` 0 警告
|
||||
|
||||
### 高层建议
|
||||
|
||||
1. **Step 1 应先行独立提交**:derive 改动可能触发整个 crate 的重新编译,与其他步骤分开可减少冲突
|
||||
2. **`get()` 只查内存,`recover()` 用于存储恢复**:`get()` 不自动从存储重建(因无 `agent`/`bundle` 通道)。冷启动后先 `create()` 再 `get()`,或显式调用 `recover(session_id, agent, bundle)`
|
||||
3. **ckpt_id 生成**:使用 `uuid::Uuid::new_v4()`(需在 `Cargo.toml` `[dependencies]` 中添加 `uuid = { version = "1", features = ["v4"] }`),或走无新增依赖方案:`format!("{}_{}", session_id, timestamp_nanos)` 结合单调计数器。建议优先走无新增依赖方案(ponytail)
|
||||
4. **SessionManager 的 RwLock 粒度**:避免持写锁时调 `checkpointer`(涉及 I/O),锁范围应仅限于 HashMap 操作;`get()` 返回 `Arc` 后立即释放读锁
|
||||
5. **自动 checkpoint 的持久化语义**:自动 checkpoint 采用`同步写入 + tracing::error! 记录失败` 模式(与 Phase 16 `maybe_summarize` 的静默模式一致)。**不提供强持久化保证**——调用方如需确保 checkpoint 成功,应显式调用 `checkpointer.checkpoint()` 并处理其 `Result`
|
||||
6. **ContextSlot 持久化与 Checkpointer 快照的关系**:两者是"增量基线 vs 全量备份"的互补关系。`ContextSlot::save()` 负责每轮追加消息到 slot 存储(增量),`Checkpointer::checkpoint()` 负责全量序列化 session 状态(快照)。rollback 时优先使用 checkpoint 数据(一致性),不依赖 slot 持久化的消息状态
|
||||
7. **`from_snapshot` 后调用 `restore_memory`**:`AgentSession::from_snapshot()` 是纯同步的,不写 store;写回 session_memory 需要显式 `await session.restore_memory()`。三步全流程:`from_snapshot → restore_memory → replace`
|
||||
|
||||
### @Chart 提示
|
||||
|
||||
```
|
||||
flowchart TD
|
||||
subgraph "engine/"
|
||||
SM[SessionManager]
|
||||
CP[Checkpointer]
|
||||
EE[EngineError]
|
||||
end
|
||||
|
||||
subgraph "现有模块"
|
||||
AS[AgentSession]
|
||||
CS[ContextSlot]
|
||||
CT[CostTracker]
|
||||
MS[MemoryStore]
|
||||
end
|
||||
|
||||
SM -->|组合持有| CP
|
||||
SM -->|RwLock 保护| HM[(sessions HashMap)]
|
||||
CP -->|持久化| MS
|
||||
AS -->|to_snapshot| SS[SessionSnapshot]
|
||||
SS -->|from_snapshot| AS
|
||||
|
||||
SM -->|get / create / destroy| AS
|
||||
CP -->|checkpoint / rollback| AS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 变更记录(审查修复)
|
||||
|
||||
| 日期 | 变更 | 触发 |
|
||||
|------|------|------|
|
||||
| 2026-07-15 | **🔴 `to_snapshot(&self)` 从同步改为 `pub async fn`** | SA 第 2 轮审查:同步方法无法 async 读 MemoryStore;需通过 `SessionMemory::list_entries()` 获取完整条目 |
|
||||
| 2026-07-15 | **🔴 `docs/roadmap.md` Phase 17 交付物列表同步更新** | PM 第 2 轮审查:Roadmap 仍使用旧版范围(`tree()`/`fork()`/`destroy_subtree()` 未推迟,`create()` 签名未更新,缺 `recover()`/`replace()`) |
|
||||
| 2026-07-15 | **`SessionMemory::list_entries()` 新增方法** | SA 第 2 轮审查:`to_snapshot` 需要读取完整 entry 数据,现有 API 只返回 `Option<String>` |
|
||||
| 2026-07-15 | **`to_snapshot` 注释清理:移除错误的 Cell/RefCell 方案** | SA 第 2 轮审查:同步方法中无法通过 Cell/RefCell 绕开 async |
|
||||
|
||||
| 日期 | 变更 | 触发 |
|
||||
|------|------|------|
|
||||
| 2026-07-15 | **🔴 `SessionManager::get()` 改为仅查内存,新增 `recover()` 显式恢复方法** | SA 审查:get() "从存储恢复"不可实现(无 agent/bundle 通道) |
|
||||
| 2026-07-15 | **🔴 `from_snapshot()` 改为纯同步构造 + 分离 `restore_memory()` 异步方法;返回 `Result`** | SA 审查:异步 I/O + 返回 Self 导致脏数据 |
|
||||
| 2026-07-15 | **🔴 `session_memory_data` 从 `HashMap<String, String>` 改为 `HashMap<String, SessionMemoryEntry>`** | SA 审查:拍平丢失 metadata/created_at |
|
||||
| 2026-07-15 | **🔴 `EngineError` 新增 `Memory(#[from] MemoryError)` 透传变体** | SA 审查:缺少 MemoryError 透传 |
|
||||
| 2026-07-15 | **🔴 `tree()` 在 MVP 推迟列补充(含推迟理由)** | PM 审查:Roadmap L781 需求完全未提及 |
|
||||
| 2026-07-15 | **🔴 `fork()` 推迟理由补充(技术拆解 + 产品权衡)** | PM 审查:推迟理由不充分 |
|
||||
| 2026-07-15 | **🔴 新增 `SessionManager::replace()` API 支持 rollback 后无缝切换** | PM 审查:rollback 后 session 无法替换到 Manager |
|
||||
| 2026-07-15 | **`SessionSnapshot` 移至 `engine/snapshot.rs`** | SA 审查:DTO 应放在 engine 层,保持依赖方向 engine→agent |
|
||||
| 2026-07-15 | **uuid 依赖修正:改为"时间戳+计数器优先,uuid 备选"** | SA 审查:文档声称"已有依赖"但 Cargo.toml 不含 |
|
||||
| 2026-07-15 | **160KB 具体数字删除(替换为保守上限描述)** | SA 审查:无测量依据 |
|
||||
| 2026-07-15 | **"关键假设(已验证)"改为"设计分析" + 验证方式** | PM 审查:"已验证"字面与实际不符 |
|
||||
| 2026-07-15 | **流式 checkpoint 时序明确定义:仅在 `finalize_turn` 时创建** | PM 审查:时序未定义 |
|
||||
| 2026-07-15 | **自动 checkpoint 语义:`tracing::error!` 模式,非强持久化** | SA 审查:fire-and-forget 不可靠 |
|
||||
| 2026-07-15 | **`create()` 返回 `Result<String, EngineError>` + 统一自动生成 ID** | PM 审查:返回 String 不能表达错误 |
|
||||
| 2026-07-15 | **`destroy()` 明确孤儿策略:允许孤儿,不递归删除,parent() 返回 None** | PM+SA 审查:孤儿语义未定义 |
|
||||
| 2026-07-15 | **`create_child()` 不再接受 `child_id`(统一自动生成)** | PM 审查:ID 策略不一致 |
|
||||
| 2026-07-15 | **`RuntimeBundle` 继承语义补充(`Arc::clone` 共享引用)** | PM 审查:继承语义未定义 |
|
||||
| 2026-07-15 | **并发模型补充 RwLock 锁范围注释** | SA 审查:跨 await 风险缺文档 |
|
||||
| 2026-07-15 | **Checkpointer 独立可用性约束标注** | SA 审查:rollback 重建需要 agent+bundle |
|
||||
| 2026-07-15 | **ContextSlot 与 Checkpointer 两种持久化路径关系补充说明** | SA 审查:共存缺说明 |
|
||||
| 2026-07-15 | **Step 7 扩充:示例流程 + 12-15 个边界测试 + Tracing 埋点规划** | SA 审查:缺 tracing 规划 |
|
||||
| 2026-07-15 | **`SessionManagerConfig` 新增 `default_bundle` 字段** | PM 审查:未来扩展预留 |
|
||||
| 2026-07-15 | **项目文件新增/修改数量同步更新(5 新增 + 5 修改,~725 行)** | 全部审查修复导致文件范围变化 |
|
||||
|
||||
## 参考来源
|
||||
|
||||
- Phase 10 方案文档:`docs/17-phase10-contextslot.md`(ContextSlot 持久化设计,Phase 17 的前置依赖)
|
||||
- Phase 16 方案文档:`docs/22-phase16-summary-auto-generation.md`(上一 Phase 的实施风格参考)
|
||||
- 当前代码:`src/agent/session.rs`(AgentSession 当前实现,`to_snapshot` / `from_snapshot` 扩展点)
|
||||
- 当前代码:`src/agent/context.rs`(ContextSlot 当前实现,derive 改动点)
|
||||
- 当前代码:`src/llm/types/usage.rs`(CostTracker 当前实现,derive 改动点)
|
||||
- 当前代码:`src/lib.rs`(模块注册点)
|
||||
- 当前代码:`src/agent.rs`(模块组织风格参考)
|
||||
@@ -0,0 +1,700 @@
|
||||
# Phase 18:Agent 角色热切换与子代理调度
|
||||
|
||||
## 背景与目标
|
||||
|
||||
### 问题空间
|
||||
|
||||
agcore 已完整交付 Phase 0-17,具备 SessionManager 会话生命周期管理、会话树(父子层次)、Checkpointer 检查点、流式输出、ContextSlot 上下文分区、MemoryStore 持久化、摘要自动生成等能力。当前 Session 在 `create()` 时绑定一个 `Arc<dyn Agent>`,此后无法变更角色;会话间调度仅通过 `create_child()` + `submit_turn()` 手动编排,缺乏内建的子代理派发机制。
|
||||
|
||||
Phase 18 要解决两个正交但关联的问题:
|
||||
|
||||
1. **Agent 角色热切换**:运行时替换 session 绑定的 Agent,保留上下文(slot 历史、turn_index、session_memory、cost_so_far)
|
||||
2. **子代理调度**:在 SessionManager 上提供声明式的 `dispatch` / `dispatch_stream` / `dispatch_all` API,支持父子 session 间的 Memory 继承、bridge_keys 注入、并发控制、结构化回传
|
||||
|
||||
### 目标
|
||||
|
||||
- 提供 `SessionManager::switch_agent(session_id, new_agent)`,替换 `Arc<dyn Agent>`,全量保留 slot / turn_index / session_memory
|
||||
- 提供 `DispatchConfig` / `SubTaskResult` / `SubTaskStreamEvent` 类型以及 `dispatch` / `dispatch_stream` / `dispatch_all` 三个核心方法
|
||||
- 实现父转子三层级交互:父->子(Memory 快照继承 + bridge_keys)、子->父(SubTaskResult 结构化回传 + result_summary)、子<->子(shared namespace)
|
||||
- 产出 4 个端到端示例:`agent_switch_demo` / `sub_agent_dispatch_demo` / `bridge_keys_demo` / `dispatch_stream_demo`
|
||||
|
||||
### 依赖与优先级
|
||||
|
||||
- **依赖**:Phase 17(SessionManager + 会话树 + Checkpointer)[高]
|
||||
- **优先级**:P0
|
||||
- **预估规模**:约 720 行核心 + 210 行测试 + 450 行示例
|
||||
- **审查修复**:第 1 轮审查修复(Finalize 方案重写 + 4 个 🔴 阻塞 + 8 个 🟡 改进)
|
||||
|
||||
---
|
||||
|
||||
## 当前状态分析
|
||||
|
||||
### 现有架构中的关键接入点
|
||||
|
||||
| 接入点 | 位置 | 可用性 | 分析 |
|
||||
|--------|------|--------|------|
|
||||
| `AgentSession.agent` | `src/agent/session.rs:53` | `pub` 字段 | 可直接替换,无需新增 setter [高] |
|
||||
| `AgentSession.session_id` | `src/agent/session.rs:51` | `pub` 字段 | 子 session 创建后可读取 [高] |
|
||||
| `AgentSession.session_memory` | `src/agent/session.rs:58` | `pub` 字段 | dispatch 后父可读子 memory [高] |
|
||||
| `SessionManager::create_child(parent_id, agent)` | `src/engine/session_manager.rs:202-239` | `pub async` | dispatch 可直接复用,bundle 继承避免重复构造 [高] |
|
||||
| `SessionMemory::list_entries()` | `src/agent/session_memory.rs:81-110` | `pub async` | 可获取全量条目用于父子继承 / bridge_keys 过滤 [高] |
|
||||
| `SessionMemory::set_with_meta()` | `src/agent/session_memory.rs:61-79` | `pub async` | 子 session 写入继承数据时保留原始 metadata [高] |
|
||||
| `CostTracker` | `src/llm/cycle.rs` | derive `Clone` | SubTaskResult 可直接 clone usage [高] |
|
||||
| `SessionMeta` 持久化 | `src/engine/session_manager.rs:32-67` | `pub(crate)` | 以 `session:{id}:meta` key 存到 MemoryStore;switch 后需更新 agent_name [高] |
|
||||
| `SessionManager::save_session_meta()` | `src/engine/session_manager.rs:131-142` | `async fn` (非 pub) | switch_agent 需要类似的 meta 更新能力;考虑提取为 `pub(crate)` [高] |
|
||||
| `SessionManager::destroy()` | `src/engine/session_manager.rs:495-512` | `pub async` | dispatch 失败时清理子 session 可直接复用 [高] |
|
||||
| `SessionManager.sessions` | `src/engine/session_manager.rs:92` | `pub(crate)` RwLock<HashMap> | switch_agent 和 dispatch 的 get / replace 操作均依赖此字段 [高] |
|
||||
| `EngineError` 枚举 | `src/engine/error.rs:16-46` | `#[non_exhaustive]` | 已有 6 个变体,需追加 DispatchFailed / SwitchFailed / SubAgentStreamError [中] |
|
||||
| `futures-core` / `futures-util` | `Cargo.toml:17-18` | 已引入 | dispatch_stream 返回 `Pin<Box<dyn Stream>>` 所需依赖已就绪,无需新增 [高] |
|
||||
|
||||
### Agent trait 与 SessionManager 之间的关系
|
||||
|
||||
```
|
||||
AgentSession {
|
||||
agent: Arc<dyn Agent>, // 可替换
|
||||
session_memory: SessionMemory, // 可继承(clone backend)
|
||||
slots: HashMap<String, ContextSlot>,
|
||||
turn_index: u32,
|
||||
cost_so_far: CostTracker,
|
||||
// ... 其余内部字段
|
||||
}
|
||||
|
||||
SessionManager {
|
||||
sessions: RwLock<HashMap<String, Arc<Mutex<AgentSession>>>>,
|
||||
checkpointer: Checkpointer,
|
||||
store: Arc<dyn MemoryStore>,
|
||||
config: SessionManagerConfig,
|
||||
}
|
||||
```
|
||||
|
||||
`AgentSession.agent` 是 `pub` 字段,这意味着 `switch_agent` 只需 `get()` → `lock()` → 替换 `agent` → 写回 meta。Route 明确、无架构阻力 [高]。
|
||||
|
||||
### 锁契约(需格外注意)
|
||||
|
||||
`src/engine/session_manager.rs:4-12` 记录了锁契约:**不持有 RwLock 跨越 `.await`**。所有 `.await` 点必须在 RwLock guard drop 之后。这意味着:
|
||||
|
||||
- `switch_agent`:读锁 `get()` 返回 `Arc<Mutex<AgentSession>>` 后释放,然后 lock session 级别的 Mutex → 替换 agent → 释放 Mutex → save_session_meta(I/O)[高]
|
||||
- `dispatch`:读锁 `get()` 父 session → 释放 → `create_child`(内部写锁)→ lock 子 session → inherit memory → submit_turn → 释放 [高]
|
||||
- 不会引入新的死锁风险 [高]
|
||||
|
||||
### 现有测试覆盖
|
||||
|
||||
SessionManager 已有 906 行(含 12 个测试),覆盖 create/get/destroy/create_child/replace/recover/children/parent/并发创建等场景:`src/engine/session_manager.rs:527-906`。Phase 18 新增测试不修改这些已有测试。
|
||||
|
||||
---
|
||||
|
||||
## 调研发现
|
||||
|
||||
### 1. bridge_keys 注入位置
|
||||
|
||||
**问题**:bridge_keys 本质是父 session 想注入到子 agent prompt 中的上下文数据。它应该放在哪里?
|
||||
|
||||
**调研来源**:
|
||||
- `docs/note-opencode-subagent-dispatch.md` — 明确反对修改 Agent 的 `system_prompt()` [高]
|
||||
- `src/agent/agent.rs:21` — `system_prompt()` 返回 `&str`,无状态变更能力 [高]
|
||||
- `src/agent/session_memory.rs` — `SessionMemory::set()` 提供 key-value 写入,子 agent 可读 [高]
|
||||
|
||||
**结论**:bridge_keys 通过 SessionMemory 副本继承 + 过滤注入,不碰 `system_prompt()`。子 agent 通过 `get_session_data(key)` 读取桥接数据 [高]。
|
||||
|
||||
### 2. SessionMemory 继承策略
|
||||
|
||||
**问题**:子 agent 启动时,父 session 的 SessionMemory 如何传递?
|
||||
|
||||
**方案 A —— 引用共享**:父子共享同一 `SessionMemory` 实例(Arc clone 后端)。优点是零拷贝,缺点是父子隔离被破坏 [中]。
|
||||
|
||||
**方案 B —— 快照副本**:父调用 `list_entries()` 获取全量条目,子通过 `set_with_meta()` 写入自己的 namespace。优点是隔离性强,缺点是 O(n) 拷贝开销 [高]。
|
||||
|
||||
**来源**:
|
||||
- `src/agent/session.rs:503-524` — `to_snapshot()` 已实现类似的 list_entries → HashMap 拍平 [高]
|
||||
- `src/agent/session_memory.rs:61-79` — `set_with_meta()` 可保留原始 metadata [高]
|
||||
- `docs/note-opencode-subagent-dispatch.md` — SA 建议副本策略 [中]
|
||||
|
||||
**结论**:采用方案 B(快照副本),隔离性优先。`inherit_session_memory` 内部使用 `list_entries()` → 按 `bridge_keys` 过滤 → `set_with_meta()` 写入子 namespace [高]。
|
||||
|
||||
### 3. dispatch_all 部分成功语义
|
||||
|
||||
**问题**:当一批子代理中部分失败时,dispatch_all 应该整体失败还是返回部分成功的 `Vec`?
|
||||
|
||||
**来源**:`docs/note-opencode-subagent-dispatch.md` — PM 和 SA 一致认为应返回部分成功语义 [高]。
|
||||
|
||||
**结论**:返回 `Vec<Result<SubTaskResult, EngineError>>`。调用方可迭代检查每个结果,失败条目保留 checkpoint 以便审计 [高]。
|
||||
|
||||
### 4. dispatch_stream 生命周期
|
||||
|
||||
**问题**:`dispatch_stream` 需要返回一个流,流内部要做 `submit_turn_stream` + `finalize_active_stream`。session 所有权和生命周期如何管理?
|
||||
|
||||
**来源**:
|
||||
- `src/engine/session_manager.rs:390-412` — `submit_turn_stream` 的锁模式:短持锁获取流后立即释放 [高]
|
||||
- `src/agent/session.rs:384-445` — `submit_turn_stream` 自身不持有跨 await 的锁 [高]
|
||||
- `docs/note-opencode-subagent-dispatch.md` — SA 建议在 AgentSession 新增 `finalize_active_stream()` 内部方法 [中]
|
||||
|
||||
**结论**:`dispatch_stream` 使用 `&Arc<Self>` 签名 + `tokio::spawn`。内部流管道:create_child → inherit_memory → submit_turn_stream → mpsc channel 转发事件 → 流消费完毕后调用 `finalize_active_stream()`。session 通过 `Arc<Mutex<AgentSession>>` 在 spawned task 中持有 [高]。
|
||||
|
||||
### 5. Cargo.toml 依赖分析
|
||||
|
||||
**来源**:
|
||||
- `Cargo.toml:17` — `futures-util = "0.3"` 已在依赖中 [高]
|
||||
- `Cargo.toml:15` — `tokio-stream = "0.1"` 已在依赖中 [高]
|
||||
- `Cargo.toml:16` — `futures = "0.3"` 已在依赖中 [高]
|
||||
|
||||
**结论**:dispatch_stream 所需的 `StreamExt` / `ReceiverStream` 所需的基础设施已全部就绪,无需新增任何依赖 [高]。
|
||||
|
||||
---
|
||||
|
||||
## 可选方案
|
||||
|
||||
### 方案 A:switch_agent 作为 AgentSession 方法 vs SessionManager 方法
|
||||
|
||||
| 维度 | A1: AgentSession 方法 | A2: SessionManager 方法 |
|
||||
|------|-----------------------|------------------------|
|
||||
| 实现位置 | `agent/session.rs` | `engine/switch.rs` |
|
||||
| 职责归属 | session 实例级 | 管理器级 |
|
||||
| 能否更新 SessionMeta | 不能(无 store 引用) | 能(有 store + checkpointer) |
|
||||
| 能否做自动 checkpoint | 不能(无 checkpointer) | 能 |
|
||||
| 与 create_child / replace 对齐 | 不对齐(create 在 SM) | 对齐(都在 SM) |
|
||||
|
||||
**来源**:
|
||||
- `src/agent/session.rs:49-71` — AgentSession 不持有 store / checkpointer 引用 [高]
|
||||
- `src/engine/session_manager.rs:325-347` — `replace()` 是 SM 方法,涉及 meta 持久化 [高]
|
||||
- roadmap lines 838 — `switch_agent(session_id, new_agent)` 签名暗示 SM 方法 [中]
|
||||
|
||||
**结论**:采用 A2(SessionManager 方法)。AgentSession 没有 store 引用,无法更新 SessionMeta。独立文件 `engine/switch.rs` 作为 SessionManager 的 impl 块。
|
||||
|
||||
### 方案 B:bridge_keys 注入方式
|
||||
|
||||
| 维度 | B1: SessionMemory 副本继承 + 过滤 | B2: 修改 Agent trait |
|
||||
|------|------------------------------------|----------------------|
|
||||
| 系统 prompt 侵入性 | 无 | 需新增 `set_bridge_data()` 方法 |
|
||||
| switch_agent 兼容性 | 天然兼容(与 agent 解耦) | switch 后需重新注入 |
|
||||
| 实现复杂度 | 一个私有辅助函数 | 需改 Agent trait + 所有实现 |
|
||||
| 测试增量 | 小(只测 `inherit_session_memory`) | 大(需测所有 Agent impl) |
|
||||
|
||||
**来源**:
|
||||
- `src/agent/agent.rs:16-30` — Agent trait 当前仅 3 个方法,简洁 [高]
|
||||
- `docs/note-opencode-subagent-dispatch.md` — "bridge_keys 通过 slot 注入,不修改 Agent system_prompt" [高]
|
||||
|
||||
**结论**:采用 B1。隔离关注点:Agent 负责"角色",SessionMemory 负责"桥接数据"。
|
||||
|
||||
### 方案 C:dispatch_stream 返回类型
|
||||
|
||||
| 维度 | C1: `Pin<Box<dyn Stream<Item=SubTaskStreamEvent>+Send>>` | C2: 自定义 struct 包装 |
|
||||
|------|----------------------------------------------------------|------------------------|
|
||||
| 与现有 API 一致性 | 与 `submit_turn_stream` 一致 [高] | 不一致 |
|
||||
| 调用方灵活性 | 直接 `.next()` + StreamExt | 需解包装 |
|
||||
| 实现复杂度 | 直接返回 stream | 需额外 struct + 方法 |
|
||||
| 可组合性 | 高(可直接 map/filter/collect) | 低 |
|
||||
|
||||
**来源**:
|
||||
- `src/engine/session_manager.rs:390-412` — `submit_turn_stream` 返回 `Pin<Box<dyn Stream<Item=StreamEvent>+Send>>` [高]
|
||||
|
||||
**结论**:采用 C1。保持一致的模式,调用方可以 `StreamExt::collect` / `map` 等。
|
||||
|
||||
### 否决方案
|
||||
|
||||
| 方案 | 否决原因 |
|
||||
|------|----------|
|
||||
| switch_agent 做自动 checkpoint | 与 auto_checkpoint 语义不一致(submit_turn 才触发),用户可手动 checkpoint。来源:`src/engine/session_manager.rs:357-384` auto_checkpoint 仅在 submit_turn/finalize_turn 触发 |
|
||||
| AgentSession 中的 `Agent` 用 `Box<dyn Agent>` | 与现有 `Arc<dyn Agent>` 不一致,且 SessionSnapshot 不序列化 agent(`src/agent/session.rs:502`)。来源:`src/agent/session.rs:53` |
|
||||
| dispatch_all 返回所有成功再返回 | 需要调用方等待全部完成才能拿到第一个结果。Rust 已有 `JoinSet` / `FuturesUnordered` 可选,但 v0.3 先保持简单 |
|
||||
| child_memory 加额外权限控制 | 子 session 是父创建的,父天然有 destroy / read 权限。来源:`docs/note-opencode-subagent-dispatch.md` PM 明确"不做额外权限控制" |
|
||||
| 子 session 失败时保留 checkpoint | `destroy()` 调 `checkpointer.delete_all`(`session_manager.rs:508`),不保留持久化残留。失败路径的调试信息通过 `tracing::error!` 日志记录 |
|
||||
|
||||
---
|
||||
|
||||
## 推荐方案
|
||||
|
||||
### 整体架构
|
||||
|
||||
```
|
||||
SessionManager (existing)
|
||||
├── switch_agent(id, new_agent) → engine/switch.rs
|
||||
├── dispatch(parent, agent, task, cfg) → engine/sub_agent.rs
|
||||
├── dispatch_stream(parent, agent, task, cfg) → engine/sub_agent.rs
|
||||
└── dispatch_all(parent, tasks, cfg) → engine/sub_agent.rs
|
||||
```
|
||||
|
||||
`switch.rs` 和 `sub_agent.rs` 均为 SessionManager 的 `impl` 块文件,通过 `pub mod` 在 `engine/mod.rs` 中注册 [高]。
|
||||
|
||||
### 决策清单
|
||||
|
||||
| # | 决策 | 结论 | 理由 |
|
||||
|---|------|------|------|
|
||||
| D1 | switch_agent 位置 | SessionManager 方法,在 `engine/switch.rs` | 需 store 更新 SessionMeta,AgentSession 无 store 引用 |
|
||||
| D2 | bridge_keys 注入方式 | SessionMemory 副本继承 + 过滤,不碰 system_prompt | 概念正交,switch_agent 友好 |
|
||||
| D3 | Memory 继承策略 | 快照副本(list_entries → set_with_meta) | 父子隔离优先,O(n) 拷贝可接受 |
|
||||
| D4 | dispatch_all 返回类型 | `Vec<Result<SubTaskResult, EngineError>>` | 部分成功语义,Rust-idiomatic |
|
||||
| D5 | dispatch_stream 返回类型 | `Pin<Box<dyn Stream<Item=SubTaskStreamEvent>+Send>>` | 与 `submit_turn_stream` 一致 |
|
||||
| D6 | switch_agent 的 lock 策略 | get() 读锁立即释放 → Mutex lock → 替换 → 释放 Mutex → I/O | 严格遵循已有锁契约 |
|
||||
| D7 | switch checkpoint | 不自动 checkpoint | 与 `auto_checkpoint` 语义一致(仅 submit_turn/finalize_turn 触发) |
|
||||
| D8 | dispatch 失败清理 | destroy 子 session | 不留僵尸 session |
|
||||
| D9 | dispatch 失败时 checkpoint | destroy 清理全部(含 checkpoint) | `destroy()` 内部调 `checkpointer.delete_all`,不留持久化垃圾 |
|
||||
| D10 | dispatch_all 并发控制 | `tokio::sync::Semaphore` | 轻量、内建、语义清晰 |
|
||||
| D11 | dispatch_all / dispatch_stream 签名 | `self: &Arc<Self>` | 满足 `tokio::spawn` `'static` 约束 |
|
||||
| D12 | 子 session 创建时 bundle | 从父 session 的 `RuntimeBundle` clone | 复用 `create_child` 已有逻辑 |
|
||||
|
||||
### 设计理由详述
|
||||
|
||||
**D1 为什么 switch_agent 必须放在 SessionManager 下**:因为 switch 后需要更新持久化的 SessionMeta(`agent_name` 变化),而 `save_session_meta` 需要 `&self.store`。AgentSession 不持有 store 引用(纯内存对象)。如果放在 AgentSession 上,要么给它加 store 引用(开历史倒车),要么让调用方手动调 `save_session_meta`(容易遗漏)[高]。
|
||||
|
||||
**D3 为什么选副本而非引用**:隔离性优先原则。父 session 可能在子运行期间 `set` 新数据,引用共享会导致子看到父的运行时中间状态;副本确保子看到的是 dispatch 时刻的稳定快照。性能方面,SessionMemory 条目数通常 < 100,O(n) 拷贝可忽略 [高]。
|
||||
|
||||
**D5 dispatch_stream 方案**:核心挑战是 session 生命周期管理和消息 finalize。方案使用 spawn task + `tokio::sync::mpsc::unbounded_channel`。spawn task 通过事件追踪重建消息列表:记录 `user_input` 作为首条 `UserMessage`,从 `StreamEvent::ToolExecutionCompleted` 事件提取工具结果,从 `StreamEvent::MessageComplete` 提取完整响应。流结束后直接调用 `AgentSession::finalize_turn(response, new_messages).await`。当 receiver 端 drop 时 sender 侧的 `send()` 错误会被捕获,task 内清理。`dispatch_stream` 返回的 stream 发出 `SubTaskStreamEvent::ChildCreated`(先导)+ `Stream(StreamEvent)`(中间,透传)+ `Completed(SubTaskResult)`(最终),消费者无需额外调 finalize [高]。
|
||||
|
||||
## 实施建议
|
||||
|
||||
### 阶段划分
|
||||
|
||||
共 9 个步骤,建议依次实施,不可并行。总预估时间由实现者在实施时评估。
|
||||
|
||||
#### Step 1 — `error.rs` 扩展
|
||||
|
||||
**文件**:`src/engine/error.rs`
|
||||
**内容**:EngineError 追加 3 个变体
|
||||
- `DispatchFailed(String)` — 子代理调度通用失败
|
||||
- `SwitchFailed(String)` — 角色切换失败
|
||||
- `SubAgentStreamError { child_id: String, detail: String }` — 流式调度中的子代理错误
|
||||
|
||||
**验证**:`cargo build` 成功。
|
||||
**注意**:已存在的 `#[non_exhaustive]` 属性确保这不是 breaking change [高]。
|
||||
|
||||
#### Step 2 — `session.rs` 无变更
|
||||
|
||||
**文件**:`src/agent/session.rs` — 不修改现有 API。
|
||||
|
||||
**审查发现**:第一轮审查确认 `finalize_active_stream()` 假设不成立。`submit_turn_stream`(`session.rs:384-445`)返回 stream 后 `LlmCycle` 即被 drop(`cycle.rs:647` 通过 `std::mem::take` 移出消息),不存在"active stream 内部状态"可读取。
|
||||
|
||||
**结论**:改为在 `dispatch_stream` 的 spawn task 中**从 StreamEvent 序列重建消息列表**,直接调用已有的 `finalize_turn(response, new_messages).await`。详见 Step 7 第 3 项。
|
||||
|
||||
**验证**:不修改 `session.rs`,Step 7 实施前 `cargo build` 可通过。
|
||||
|
||||
#### Step 3 — `switch.rs`
|
||||
|
||||
**文件**:`src/engine/switch.rs`
|
||||
**内容**:
|
||||
|
||||
```rust
|
||||
impl SessionManager {
|
||||
/// 热切换指定 session 的 Agent 角色。
|
||||
///
|
||||
/// - 保留 slot 历史 / turn_index / session_memory / cost_so_far
|
||||
/// - 自动更新 SessionMeta 中的 agent_name(保持原始 created_at / parent_id)
|
||||
/// - 不自动 checkpoint(与 `auto_checkpoint` 语义一致:仅 submit_turn 触发)
|
||||
/// - **注意**: 切换后新的 system_prompt 将与已有对话历史共存。
|
||||
/// 建议在切换后发送一条明确的上下文过渡提示
|
||||
/// (如"你现在以新角色 X 的身份继续对话")作为切换后的首条输入。
|
||||
/// - **安全提示**: `AgentSession.agent` 是 `pub` 字段可直接访问,
|
||||
/// 绕过 `switch_agent` 直接修改会导致 SessionMeta 中的 agent_name
|
||||
/// 与内存状态不一致,请始终使用此方法。
|
||||
pub async fn switch_agent(
|
||||
&self,
|
||||
session_id: &str,
|
||||
new_agent: Arc<dyn Agent>,
|
||||
) -> Result<(), EngineError> {
|
||||
// 1. get session(RwLock 读锁,返回后释放)
|
||||
let session = self.get(session_id).await?;
|
||||
|
||||
// 2. lock Mutex,替换 agent,读 name + turn_index
|
||||
let (agent_name, turn_index) = {
|
||||
let mut guard = session.lock().await;
|
||||
guard.agent = new_agent;
|
||||
(guard.agent.name().to_string(), guard.turn_index())
|
||||
}; // 释放 Mutex
|
||||
|
||||
// 3. 读取原始 SessionMeta(用于保留 created_at / parent_id)
|
||||
let existing_meta = self
|
||||
.load_session_meta(session_id)
|
||||
.await?
|
||||
.ok_or_else(|| EngineError::SessionNotFound(session_id.to_string()))?;
|
||||
|
||||
// 4. 构造新 meta 并持久化(I/O,无锁)
|
||||
let meta = SessionMeta {
|
||||
session_id: session_id.to_string(),
|
||||
agent_name,
|
||||
parent_id: existing_meta.parent_id,
|
||||
created_at: existing_meta.created_at,
|
||||
turn_count: turn_index,
|
||||
};
|
||||
self.save_session_meta(&meta).await?;
|
||||
|
||||
tracing::info!(
|
||||
session_id = %session_id,
|
||||
agent_name = %meta.agent_name,
|
||||
previous_agent = %existing_meta.agent_name,
|
||||
"agent switched"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**测试**(预计 4 个):
|
||||
1. 基本切换:switch 后 `agent.name()` 返回新 name
|
||||
2. 上下文保留:turn_index / session_memory / slot 历史均不变
|
||||
3. SessionMeta 持久化:`load_session_meta` 验证 agent_name 已更新
|
||||
4. 不存在的 session:返回 `SessionNotFound`
|
||||
|
||||
**验证**:`cargo test --all-targets` + clippy
|
||||
|
||||
#### Step 4 — `sub_agent.rs` 类型
|
||||
|
||||
**文件**:`src/engine/sub_agent.rs`
|
||||
**内容**:3 个类型定义
|
||||
|
||||
```rust
|
||||
/// 子代理调度配置。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DispatchConfig {
|
||||
/// 最大并发数(dispatch_all 用)。默认 10。
|
||||
pub max_concurrency: usize,
|
||||
/// 是否继承父 SessionMemory。默认 true。
|
||||
pub inherit_session_memory: bool,
|
||||
/// 桥接 key 列表:
|
||||
/// - `None` = 不继承任何父 SessionMemory
|
||||
/// - `Some(vec![])` = 继承全部父 SessionMemory
|
||||
/// - `Some(keys)` = 仅继承指定的 keys
|
||||
/// 默认 `None`(零继承),显式选择加入。
|
||||
pub bridge_keys: Option<Vec<String>>,
|
||||
/// 子↔子共享 namespace。如果为 `Some(prefix)`,
|
||||
/// 子 agent 可通过 `session.get_session_data(key)` 访问
|
||||
/// `shared:{prefix}:{key}` 命名空间的数据。
|
||||
/// 默认 `Some(parent_session_id)`。
|
||||
pub shared_namespace: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for DispatchConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_concurrency: 10,
|
||||
inherit_session_memory: true,
|
||||
bridge_keys: None,
|
||||
shared_namespace: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 子代理执行结果。
|
||||
///
|
||||
/// dispatch 成功后子 session **保留在 SessionManager 中**,调用方可
|
||||
/// 通过 `sm.get(&result.child_id)` 获取子 session 引用,进而通过
|
||||
/// `session_memory()` 读取子 SessionMemory(如 "result_summary")。
|
||||
#[derive(Debug)]
|
||||
pub struct SubTaskResult {
|
||||
/// 子 session ID。可通过此 ID 在 SessionManager 中读取子 session。
|
||||
pub child_id: String,
|
||||
/// LLM 最终响应。
|
||||
pub response: MessageResponse,
|
||||
/// 本次调用的 token 用量。
|
||||
pub usage: CostTracker,
|
||||
/// 可选摘要(读取子 session_memory 中的 "result_summary")。
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
/// 流式子代理调度事件。
|
||||
#[derive(Debug)]
|
||||
pub enum SubTaskStreamEvent {
|
||||
/// 子 session 已创建(携带 child_id)。
|
||||
ChildCreated { child_id: String },
|
||||
/// LLM 流事件(透传)。
|
||||
Stream(StreamEvent),
|
||||
/// 执行完成(携带完整结果)。
|
||||
Completed(SubTaskResult),
|
||||
}
|
||||
```
|
||||
|
||||
`SubTaskStreamEvent` 需实现 `Display` 和 `std::error::Error`(`Completed` 和 `ChildCreated` 不触发错误路径,`Display` 仅用于调试日志)[中]。
|
||||
|
||||
**验证**:`cargo build`
|
||||
|
||||
#### Step 5 — `sub_agent.rs dispatch` 核心
|
||||
|
||||
**文件**:`src/engine/sub_agent.rs`
|
||||
**内容**:
|
||||
|
||||
```rust
|
||||
impl SessionManager {
|
||||
/// 私有辅助:从父 session memory 继承条目到子 session。
|
||||
///
|
||||
/// **一致性模型**:捕获的是调用时刻的父 session_memory 快照。
|
||||
/// 即使在 `list_entries()` 返回后、`set_with_meta()` 写入前
|
||||
/// 父 session 被并发写入新数据,子 session 也**不会**看到这些
|
||||
/// 新数据(快照副本的内生特征)。[审查确认]
|
||||
async fn inherit_session_memory(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
child_id: &str,
|
||||
config: &DispatchConfig,
|
||||
) -> Result<(), EngineError> { /* ... */ }
|
||||
|
||||
/// 派发一个子任务,返回结构化结果。
|
||||
pub async fn dispatch(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
sub_agent: Arc<dyn Agent>,
|
||||
task: impl Into<String>,
|
||||
config: DispatchConfig,
|
||||
) -> Result<SubTaskResult, EngineError> { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
**dispatch 流程**:
|
||||
1. `create_child(parent_id, sub_agent)` → 获取 child_id
|
||||
2. 若 `config.inherit_session_memory == true` → `inherit_session_memory(parent_id, child_id, config)`
|
||||
3. `submit_turn(child_id, task)` → 获取 response
|
||||
4. 读取 `"result_summary"`(可选)
|
||||
5. 返回 `SubTaskResult`(子 session 保留在 SessionManager 中,可通过 `sm.get(&child_id)` 读取 child_memory)
|
||||
6. 失败路径:`let _ = self.destroy(&child_id).await; tracing::error!(...)`(静默吞掉清理错误,**原始 EngineError 优先**;`destroy` 会清理子 session 的 SessionMeta + checkpoint 条目,不留僵尸)
|
||||
|
||||
**测试**(预计 5 个):
|
||||
1. 基本调度:子 agent 返回预期响应
|
||||
2. bridge_keys 过滤:仅指定的 key 被继承
|
||||
3. memory 继承:父 set 的值子可读到
|
||||
4. submit_turn 失败:错误传播 + 子 session 被销毁
|
||||
5. 无效 parent_id:返回 `SessionNotFound`
|
||||
|
||||
**验证**:`cargo test --all-targets`
|
||||
|
||||
#### Step 6 — `sub_agent.rs dispatch_all`
|
||||
|
||||
**文件**:`src/engine/sub_agent.rs`
|
||||
**内容**:
|
||||
|
||||
```rust
|
||||
impl SessionManager {
|
||||
/// 并行派发一批子任务。
|
||||
pub async fn dispatch_all(
|
||||
self: &Arc<Self>,
|
||||
parent_id: &str,
|
||||
tasks: Vec<(Arc<dyn Agent>, String)>,
|
||||
config: DispatchConfig,
|
||||
) -> Vec<Result<SubTaskResult, EngineError>> { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 使用 `tokio::sync::Semaphore` 限制并发数(默认 `config.max_concurrency`)
|
||||
- **Semaphore acquire 在 spawn 内**:`let permit = semaphore.clone().acquire_owned().await;` — permit 所有权转移到 spawned task。避免 spawn N 个 task 时全量分配 Future 内存 [审查修复]
|
||||
- 每个 task `tokio::spawn` + `Arc<Self>` clone
|
||||
- 内部调用 `dispatch` 的同类逻辑(create_child → inherit → submit_turn)
|
||||
- **indexed 收集**:预分配 `Vec<Option<Result<...>>>` 按 `tasks` 索引填入,维持输入顺序。不使用排序(排序需等所有 child_id 生成后)[审查修复]
|
||||
- 每个结果独立:`Ok(SubTaskResult)` 或 `Err(EngineError)`
|
||||
|
||||
**测试**(预计 4 个):
|
||||
1. 并行 3 个全部成功
|
||||
2. 部分失败(MockProvider 对特定 task 返回错误)
|
||||
3. Semaphore 上限验证(max_concurrency=1 时串行执行)
|
||||
4. 空 tasks 列表
|
||||
|
||||
**验证**:`cargo test --all-targets`
|
||||
|
||||
#### Step 7 — `sub_agent.rs dispatch_stream`
|
||||
|
||||
**文件**:`src/engine/sub_agent.rs`
|
||||
**内容**:
|
||||
|
||||
```rust
|
||||
impl SessionManager {
|
||||
pub async fn dispatch_stream(
|
||||
self: &Arc<Self>,
|
||||
parent_id: &str,
|
||||
sub_agent: Arc<dyn Agent>,
|
||||
task: impl Into<String>,
|
||||
config: DispatchConfig,
|
||||
) -> Result<
|
||||
Pin<Box<dyn Stream<Item = SubTaskStreamEvent> + Send>>,
|
||||
EngineError,
|
||||
> { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 同步部分(lock 外):create_child + inherit_memory
|
||||
- 获取 Stream 后通过 `tokio::sync::mpsc::unbounded_channel` 转发事件(与 LLM stream 内部背压策略一致,避免有界 channel 的 sender 阻塞风险)[审查修复]
|
||||
- spawn task 持有 `Arc<Mutex<AgentSession>>` 消费 LLM stream
|
||||
- **消息重建机制**(替代已移除的 `finalize_active_stream()`):[审查修复]
|
||||
```
|
||||
// 在 spawn task 中:
|
||||
let mut new_messages: Vec<Message> = vec![Message::user_text(&task)];
|
||||
let mut final_response: Option<MessageResponse> = None;
|
||||
|
||||
while let Some(event) = llm_stream.next().await {
|
||||
// 转发事件到输出 channel
|
||||
tx.send(SubTaskStreamEvent::Stream(event.clone()))?;
|
||||
// 从 ToolExecutionCompleted 构造 ToolResult 消息
|
||||
if let StreamEvent::ToolExecutionCompleted { tool_name, tool_call_id, input, output } = &event {
|
||||
new_messages.push(Message::tool_result(tool_call_id, tool_name, output));
|
||||
}
|
||||
// 捕获最终响应
|
||||
if let StreamEvent::MessageComplete(ref resp) = event {
|
||||
final_response = Some(resp.clone());
|
||||
}
|
||||
}
|
||||
// 流结束后,追加 assistant 消息并 finalize
|
||||
if let Some(response) = &final_response {
|
||||
new_messages.push(response.message.clone());
|
||||
child_session.lock().await
|
||||
.finalize_turn(response, new_messages).await?;
|
||||
}
|
||||
```
|
||||
- 事件序列:`ChildCreated` → `Stream(StreamEvent)` × N → `Completed(SubTaskResult)`
|
||||
- 消费者 drop receiver → unbounded channel sender 错误 → task 自动退出
|
||||
|
||||
**测试**(预计 4 个):
|
||||
1. 事件序列验证:收到 ChildCreated → 至少一个 Stream → Completed
|
||||
2. 错误传播:LLM 内部错误 → 正确映射到 error 事件
|
||||
3. receiver dropped:drop receiver 后 task 正确退出,不 panic
|
||||
4. finalize 正确性:Completed 中的 usage / summary 正确
|
||||
|
||||
**验证**:`cargo test --all-targets`
|
||||
|
||||
#### Step 8 — `mod.rs` + 集成验证
|
||||
|
||||
**文件**:`src/engine/mod.rs`
|
||||
**内容**:追加 `pub mod switch;` 和 `pub mod sub_agent;` + `pub use`
|
||||
|
||||
```rust
|
||||
pub mod checkpointer;
|
||||
pub mod error;
|
||||
pub mod session_manager;
|
||||
pub mod snapshot;
|
||||
pub mod switch; // <-- 新增
|
||||
pub mod sub_agent; // <-- 新增
|
||||
```
|
||||
|
||||
**验证**:
|
||||
1. `cargo test --all-targets` — 374+ 测试全部通过
|
||||
2. `cargo clippy --all-targets -- -D warnings` — 0 警告
|
||||
3. `cargo doc --no-deps` — 0 warning
|
||||
|
||||
#### Step 9 — 示例
|
||||
|
||||
**文件 1**:`examples/agent_switch_demo.rs`(约 80 行)
|
||||
- 创建 session → submit_turn(角色 A)→ switch_agent(角色 B)→ submit_turn(角色 B)→ 验证上下文保留
|
||||
- 演示目的:证明 switch_agent 保留 slot 历史 / turn_index / session_memory
|
||||
|
||||
**文件 2**:`examples/sub_agent_dispatch_demo.rs`(约 150 行)
|
||||
- 父 session → dispatch_all 3 个子 agent(研究、写作、审校)→ 收集结果 → 父汇总
|
||||
- 树形验证:`children(parent_id)` 返回 3 个子 ID
|
||||
- 演示目的:多 agent 协作完整链路
|
||||
|
||||
**文件 3**:`examples/bridge_keys_demo.rs`(约 140 行)
|
||||
- 父设置 SessionMemory(key: "project_goal", "constraints")→ dispatch + bridge_keys → 子 agent 通过 `get_session_data` 读取
|
||||
- 子↔子交互:父通过 `DispatchConfig.shared_namespace` 设定共享命名空间,子 A 写入 `shared:{parent_id}:fact_x`,子 B 通过约定 key 读取
|
||||
- 演示目的:bridge_keys 过滤机制 + 父子数据桥接 + 子↔子共享 namespace
|
||||
|
||||
**文件 4 — 新增**:`examples/dispatch_stream_demo.rs`(约 100 行)
|
||||
- 父 session → dispatch_stream 单个子 agent → 消费 `SubTaskStreamEvent` 序列
|
||||
- 验证收到 `ChildCreated` + 至少一个 `Stream` + `Completed` 事件
|
||||
- 输出 `SubTaskResult.child_id` / `usage` / `summary`,验证消息重建和 finalize 正确性
|
||||
- 演示 `receiver dropped` 场景:中途 drop receiver 后 task 正确退出不 panic
|
||||
- 演示目的:dispatch_stream 的事件序列 + finalize 完整性验证
|
||||
|
||||
**验证**:4 个示例全部 `cargo run --example` exit 0
|
||||
|
||||
### 高层实施建议
|
||||
|
||||
1. **Step 1 优先于所有步骤**:Error 扩展是所有后续步骤的基础,无依赖可并行 [高]
|
||||
2. **Step 3 独立性强**:switch_agent 不依赖 dispatch 的任何类型,可单独实施和测试 [高]
|
||||
3. **Step 4 是 Step 5-7 的前置**:类型定义不依赖其他逻辑,建议在 Step 3 完成后立即实施 [高]
|
||||
4. **Step 5-7 按复杂度递增**:dispatch → dispatch_all → dispatch_stream。dispatch_all 复用 dispatch 的核心逻辑;dispatch_stream 是最复杂的,建议最后实施 [高]
|
||||
5. **Step 8 集成验证不可跳过**:clippy + doc 全量验证确保无回归 [高]
|
||||
6. **Step 9 在所有核心完成后实施**:示例是验收标准的一部分,PM 确认 3 个递进示例 [中]
|
||||
7. **全量测试密码**:实施过程中持续 `cargo test --all-targets`,不在最后统一修复 [高]
|
||||
|
||||
### 风险矩阵
|
||||
|
||||
| # | 风险 | 等级 | 可能性 | 对策 |
|
||||
|---|------|------|--------|------|
|
||||
| R1 | dispatch_stream 的消息重建:从 StreamEvent 序列重建 `new_messages` 的完整性 | 🟡 中 | 低 | 已移除 `finalize_active_stream()` 方案。spawn task 通过追踪 `ToolExecutionCompleted` / `MessageComplete` 事件重建消息列表。完整性由 `MessageComplete` 事件保证 |
|
||||
| R2 | `tokio::spawn` `'static` + SessionManager 引用 | 🟡 中 | 低 | dispatch_all 和 dispatch_stream 签名已明确用 `&Arc<Self>`;调用方包装 `Arc<SessionManager>` |
|
||||
| R3 | 子 session 创建成功但后续 submit_turn 失败 | 🟡 中 | 中 | dispatch 内 `destroy(child_id)` 放在 `?` 前确保清理;通过 `let child_id = ...;` 先绑定,再 `let r = submit_turn(...).await`,失败时 `destroy(&child_id).await?` 清理 |
|
||||
| R4 | 部分失败时孤儿 checkpoint 数据 | 🟢 低 | 必然 | **正向利用**:保留用于调试审计,存储开销可忽略 |
|
||||
| R5 | 并发 dispatch_all 中任务 panic | 🟡 中 | 低 | `tokio::spawn` 的 `JoinHandle` 通过 `.await` 捕获 panic;panic 传播到 `dispatch_all` 内作为 `Err` 返回 |
|
||||
| R6 | bridge_keys 中不存在的 key | 🟢 低 | 中 | 静默跳过(与 `List_entries` 返回全量后再过滤,不存在的 key 自然不会出现在结果中) |
|
||||
|
||||
### 架构图(文本示意)
|
||||
|
||||
```
|
||||
SessionManager
|
||||
/ | \
|
||||
/ | \
|
||||
switch_agent dispatch dispatch_stream
|
||||
| | |
|
||||
v v v
|
||||
AgentSession create_child create_child
|
||||
.agent = new inherit_mem inherit_mem
|
||||
SessionMeta submit_turn submit_turn_stream
|
||||
更新 返回结果 mpsc 转发事件
|
||||
finalize_on_complete
|
||||
|
||||
交互层次:
|
||||
父 -> 子: SessionMemory snapshot + bridge_keys 过滤
|
||||
子 -> 父: SubTaskResult { child_id, response, usage, summary }
|
||||
子 <-> 子: shared:{parent_session_id} namespace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 参考来源
|
||||
|
||||
### 代码路径
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `src/agent/session.rs` | AgentSession 定义、`agent` pub 字段(L53)、`session_memory` pub 字段(L58)、`submit_turn_stream`(L384)、`finalize_turn`(L456) |
|
||||
| `src/agent/agent.rs` | Agent trait 定义(3 个方法) |
|
||||
| `src/agent/session_memory.rs` | SessionMemory: `set`(L50)、`set_with_meta`(L61)、`list_entries`(L81) |
|
||||
| `src/engine/session_manager.rs` | SessionManager: `create_child`(L202)、`get`(L244)、`destroy`(L495)、`save_session_meta`(L131)、`load_session_meta`(L144)、SessionMeta(L32)、锁契约(L4-12) |
|
||||
| `src/engine/error.rs` | EngineError 枚举(当前 6 变体,`#[non_exhaustive]`) |
|
||||
| `src/engine/mod.rs` | 模块注册 |
|
||||
| `src/engine/snapshot.rs` | SessionSnapshot(from_snapshot / to_snapshot 所需) |
|
||||
| `src/llm/stream.rs` | StreamEvent 枚举 |
|
||||
| `Cargo.toml` | 依赖声明(`futures-util` L17、`tokio-stream` L15、`futures-core` L18) |
|
||||
|
||||
### 文档路径
|
||||
|
||||
| 文档 | 用途 |
|
||||
|------|------|
|
||||
| `docs/roadmap-v0.3.0.md` §Phase 18 | Phase 18 原始需求(交付物、交互层级、优先级) |
|
||||
| `docs/note-opencode-agent-switching.md` | Agent 热切换调研笔记(桥接方案分析、生命周期讨论) |
|
||||
| `docs/note-opencode-subagent-dispatch.md` | SubAgent Dispatch 调研笔记(PM/SA 建议、设计推演) |
|
||||
| `docs/23-phase17-agent-execution-engine.md` | Phase 17 方案文档(SessionManager 设计背景) |
|
||||
| `docs/7-agent-runtime.md` | Agent 运行时设计文档(Session 与 Agent 的关系) |
|
||||
| `docs/17-phase10-contextslot.md` | ContextSlot 上下文管理(Phase 10) |
|
||||
| `docs/24-phase18-agent-switch-and-dispatch.md` | 本文档 — 第 1 轮审查修复记录 |
|
||||
|
||||
### 决策轨迹
|
||||
|
||||
| 决策 | 参考来源 | 置信度 |
|
||||
|------|----------|--------|
|
||||
| switch_agent 在 SessionManager 而非 AgentSession | `src/agent/session.rs` AgentSession 无 store 引用 | 高 |
|
||||
| bridge_keys 通过 SessionMemory 副本,不碰 system_prompt | `docs/note-opencode-subagent-dispatch.md` PM/SA 建议 | 高 |
|
||||
| dispatch_all 返回 `Vec<Result<..>>` 部分成功 | `docs/note-opencode-subagent-dispatch.md` PM/SA 一致 | 高 |
|
||||
| dispatch_stream 返回 `Pin<Box<dyn Stream>>` | `src/engine/session_manager.rs` `submit_turn_stream` 签名一致 | 高 |
|
||||
| 失败时 destroy 子 session 清理全部(含 checkpoint) | `session_manager.rs:508` destroy 调 `checkpointer.delete_all` | 高 |
|
||||
| dispatch_all 用 `&Arc<Self>` 签名 | `tokio::spawn` `'static` 约束 | 高 |
|
||||
| child_memory 不做额外权限控制 | `docs/note-opencode-subagent-dispatch.md` PM 明确 | 高 |
|
||||
| `#[non_exhaustive]` 已存在 -> 新增 EngineError 变体不是 breaking change | `src/engine/error.rs:15` | 高 |
|
||||
| `futures-util` 已存在,无需新增依赖 | `Cargo.toml:17` | 高 |
|
||||
|
||||
### 审查修复轨迹(第 1 轮)
|
||||
|
||||
| # | 问题 | 🔴/🟡 | 修复内容 |
|
||||
|---|------|--------|---------|
|
||||
| F1 | `finalize_active_stream()` 假设不成立:`submit_turn_stream` 返回后 cycle 被 drop | 🔴 | 移除 Step 2 的 `finalize_active_stream()`,改为 spawn task 内从 StreamEvent 重建消息列表直接调 `finalize_turn()` |
|
||||
| F2 | `SubTaskResult` 缺 child_memory 访问路径 | 🔴 | `SubTaskResult.child_id` 可经由 `sm.get()` 读取子 session。构型 doc comment 增加说明 |
|
||||
| F3 | dispatch 失败路径 `destroy` 自身 I/O 可能失败,覆盖原始错误 | 🔴 | 改用 `let _ = destroy` + `tracing::error!`,原始 `EngineError` 优先 |
|
||||
| F4 | switch_agent SessionMeta 构造中 `created_at`/`parent_id` 用占位符 | 🔴 | 改用 `load_session_meta` 读取原始值,`turn_count` 从 `guard.turn_index()` 读取 |
|
||||
| F5 | D9 与 `destroy()` 实现矛盾(方案说保留,代码说删除) | 🟡 | D9 修正为"destroy 清理全部",否决条目同步更新 |
|
||||
| F6 | `bridge_keys` 默认值安全反直觉(空=全量继承) | 🟡 | 类型改为 `Option<Vec<String>>`,`None` = 不继承(默认),`Some(vec![])` = 全量 |
|
||||
| F7 | Semaphore acquire 位置未指定 | 🟡 | 指定 `acquire_owned()` 在 spawn 内 + indexed 收集维持输入顺序 |
|
||||
| F8 | dispatch_stream 缺少独立示例 | 🟡 | Step 9 追加 `dispatch_stream_demo` |
|
||||
| F9 | mpsc channel 背压策略未指定 | 🟡 | 改用 `unbounded_channel`,与 LLM stream 内部模式一致 |
|
||||
| F10 | 子↔子交互层缺少实现细节 | 🟡 | `DispatchConfig.shared_namespace` 字段 + 示例 3 演示 |
|
||||
| F11 | inherit_session_memory 竞态窗口未文档化 | 🟡 | doc comment 声明快照一致性模型 |
|
||||
| F12 | switch_agent system_prompt 断裂风险未说明 | 🟡 | doc comment 增加使用建议 + 安全提示 |
|
||||
|
||||
---
|
||||
|
||||
*本文档对应的实施步骤记录在 `docs/roadmap-v0.3.0.md` §Phase 18,实施完成后同步更新 roadmap 状态。*
|
||||
@@ -0,0 +1,652 @@
|
||||
# Phase 19:知识图谱 + 双通道检索
|
||||
|
||||
## 背景与目标
|
||||
|
||||
### 问题空间
|
||||
|
||||
agcore v0.3.0 已交付 Phase 0-18,记忆系统具备 `KnowledgeStore`(页面级内容检索)和 `VectorStore`(向量语义检索),但缺少实体-关系维度的关联检索能力。用户搜索"X 与什么相关"时,现有系统无法返回实体间的拓扑关系。
|
||||
|
||||
`docs/note-knowledge-graph-design.md` 已记录完整的知识图谱设计,Phase 19 将其落地为可编译、可测试的模块。
|
||||
|
||||
### 目标
|
||||
|
||||
- 新增 `memory/graph.rs`,实现 `KnowledgeGraph` trait + `InMemoryGraph` 内存实现
|
||||
- 扩展 `MemoryRetriever` 为双通道:KnowledgeStore(内容)+ KnowledgeGraph(实体关系)
|
||||
- 通过 `RetrievalStrategy` 枚举控制通道选择(Hybrid / KnowledgeOnly / GraphOnly)
|
||||
- 统一 `RetrievalResult.items` 为 `Vec<RetrievalItem>`,enum 变体区分类别
|
||||
- 标签管理 API 预留(无自动提取流程,Agent 层显式写入)
|
||||
- Phase 19 仅提供底层 CRUD 接口,实体/关系的写入由 Agent 层(如 LLM 提取)在后续 Phase 中接入。当前无自动填充流程,需 Agent 显式调用 `add_entity`/`add_relation`。
|
||||
|
||||
### 与现有模块的定位关系
|
||||
|
||||
```
|
||||
KnowledgeStore: 页面级内容("什么是 X") ← Phase 6 已有
|
||||
VectorStore: 向量语义(相似度检索) ← Phase 15 已有
|
||||
KnowledgeGraph: 实体级关系("X 与什么相关") ← Phase 19 新增
|
||||
MemoryRetriever: 统一检索入口 ← Phase 19 扩展为双通道
|
||||
```
|
||||
|
||||
### 依赖与优先级
|
||||
|
||||
- **依赖**:Phase 6(KnowledgeStore)[高]、Phase 15(VectorStore 模式参考)[低]
|
||||
- **优先级**:P0(v0.3.0 最后一个 Phase)
|
||||
- **预估规模**:约 600 行核心 + 200 行测试
|
||||
|
||||
---
|
||||
|
||||
## 需求分析
|
||||
|
||||
### 功能需求
|
||||
|
||||
| ID | 需求 | 优先级 |
|
||||
|----|------|--------|
|
||||
| F1 | `GraphEntity` / `GraphRelation` / `RelationDirection` 类型定义 | P0 |
|
||||
| F2 | `KnowledgeGraph` trait(10 个 async 方法) | P0 |
|
||||
| F3 | `InMemoryGraph` 实现(HashMap + Vec + tag_index) | P0 |
|
||||
| F4 | BFS 图遍历(防环、权重衰减、方向过滤) | P0 |
|
||||
| F5 | `RetrievalItem` / `RetrievalStrategy` / `RetrievalResult` 扩展 | P0 |
|
||||
| F6 | `MemoryRetriever` 双通道(`tokio::join!` 并行) | P0 |
|
||||
| F7 | 标签管理(set_entity_tags / find_tags / entity_count_by_tag) | P1(预留) |
|
||||
|
||||
### 非功能需求
|
||||
|
||||
| ID | 需求 | 说明 |
|
||||
|----|------|------|
|
||||
| NF1 | 零新依赖 | 纯 std + tokio + 已有 crate |
|
||||
| NF2 | 异步安全 | `InMemoryGraph` 内部 Mutex 保护,trait 方法 async |
|
||||
| NF3 | 类型安全 | 不新增 `MemoryError` 变体,复用现有 5 个 |
|
||||
| NF4 | 向后兼容 | `MemoryRetriever::new()` 签名不变,可选链式注入 graph |
|
||||
| NF5 | Breaking change 受控 | `RetrievalResult.items` 类型变化,需在 CHANGELOG 标注 |
|
||||
|
||||
---
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 3.1 数据模型
|
||||
|
||||
#### GraphEntity
|
||||
|
||||
```rust
|
||||
/// 图谱实体 —— 表示一个可被关联检索的节点。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GraphEntity {
|
||||
/// 唯一标识(如 "person:rust-dev-01")。
|
||||
pub id: String,
|
||||
/// 实体名称(用于展示和关键词匹配)。
|
||||
pub name: String,
|
||||
/// 实体类型("person" | "concept" | "project" | ...)。
|
||||
pub entity_type: String,
|
||||
/// 一句话描述。
|
||||
pub description: String,
|
||||
/// 检索标签(全小写,原子词,由 Agent 层显式写入)。
|
||||
pub tags: Vec<String>,
|
||||
/// 任意附加属性(与 PersistentVectorStore.metadata 保持一致)。
|
||||
pub properties: HashMap<String, String>,
|
||||
}
|
||||
```
|
||||
|
||||
#### GraphRelation
|
||||
|
||||
```rust
|
||||
/// 图谱关系 —— 连接两个实体的有向边。
|
||||
///
|
||||
/// 无 `id` 字段,用 `(source_id, target_id, relation_type)` 三元组唯一标识。
|
||||
/// 提供 `composite_key()` 作为派生 id,满足未来独立 id 需求。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GraphRelation {
|
||||
/// 源实体 ID。
|
||||
pub source_id: String,
|
||||
/// 目标实体 ID。
|
||||
pub target_id: String,
|
||||
/// 关系类型("works_on" | "part_of" | "related_to" | ...)。
|
||||
pub relation_type: String,
|
||||
/// 关系强度 [0.0, 1.0],用于 BFS 评分衰减。
|
||||
pub weight: f32,
|
||||
}
|
||||
|
||||
impl GraphRelation {
|
||||
/// 复合键:`source_id:target_id:relation_type`,用于去重和查找。
|
||||
pub fn composite_key(&self) -> String {
|
||||
format!("{}:{}:{}", self.source_id, self.target_id, self.relation_type)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### RelationDirection
|
||||
|
||||
```rust
|
||||
/// 关系遍历方向。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RelationDirection {
|
||||
/// 仅出边:source_id → target_id(默认)。
|
||||
Outgoing,
|
||||
/// 仅入边:target_id → source_id。
|
||||
Incoming,
|
||||
/// 双向遍历。
|
||||
Both,
|
||||
}
|
||||
|
||||
impl Default for RelationDirection {
|
||||
fn default() -> Self {
|
||||
Self::Outgoing
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### ScoredEntity
|
||||
|
||||
```rust
|
||||
/// 带评分的实体 + 路径信息。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScoredEntity {
|
||||
pub entity: GraphEntity,
|
||||
/// 基于图距离的评分 [0.0, 1.0],沿路径权重乘积衰减。
|
||||
pub score: f32,
|
||||
/// 从查询实体到当前实体的 ID 路径(用于可解释性)。
|
||||
pub path: Vec<String>,
|
||||
}
|
||||
```
|
||||
|
||||
#### TagConstraints
|
||||
|
||||
```rust
|
||||
/// 标签约束配置。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TagConstraints {
|
||||
/// 每个实体最多标签数(默认 8)。
|
||||
pub max_tags_per_entity: usize,
|
||||
}
|
||||
|
||||
impl Default for TagConstraints {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_tags_per_entity: 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 KnowledgeGraph trait
|
||||
|
||||
```rust
|
||||
/// 知识图谱抽象 —— 实体-关系存储与图遍历检索。
|
||||
///
|
||||
/// 所有方法 `async + Send + Sync`,支持跨 `.await` 调用。
|
||||
/// 复用 `MemoryError`,不新增变体。
|
||||
#[async_trait]
|
||||
pub trait KnowledgeGraph: Send + Sync {
|
||||
// ── 实体管理 ──
|
||||
|
||||
/// 添加或更新实体(upsert 语义)。
|
||||
async fn add_entity(&self, entity: GraphEntity) -> Result<(), MemoryError>;
|
||||
|
||||
/// 按 ID 获取实体,不存在返回 `Ok(None)`。
|
||||
async fn get_entity(&self, id: &str) -> Result<Option<GraphEntity>, MemoryError>;
|
||||
|
||||
/// 删除实体及其所有关联关系。
|
||||
async fn remove_entity(&self, id: &str) -> Result<(), MemoryError>;
|
||||
|
||||
// ── 关系管理 ──
|
||||
|
||||
/// 添加关系(若复合键已存在则覆盖 weight)。
|
||||
async fn add_relation(&self, relation: GraphRelation) -> Result<(), MemoryError>;
|
||||
|
||||
/// 按复合键删除关系。
|
||||
async fn remove_relation(
|
||||
&self,
|
||||
source_id: &str,
|
||||
target_id: &str,
|
||||
relation_type: &str,
|
||||
) -> Result<(), MemoryError>;
|
||||
|
||||
/// 从指定实体出发,BFS 遍历 depth 层,返回关联实体(带评分)。
|
||||
///
|
||||
/// - `direction`:遍历方向(Outgoing / Incoming / Both)
|
||||
/// - `relation_types`:可选过滤,仅遍历指定关系类型
|
||||
async fn get_related(
|
||||
&self,
|
||||
entity_id: &str,
|
||||
depth: usize,
|
||||
direction: RelationDirection,
|
||||
relation_types: Option<&[&str]>,
|
||||
) -> Result<Vec<ScoredEntity>, MemoryError>;
|
||||
|
||||
// ── 检索 ──
|
||||
|
||||
/// 按关键词子串匹配实体(不区分大小写),与 KnowledgeStore.search 一致。
|
||||
async fn find_by_keywords(&self, keywords: &[String]) -> Result<Vec<GraphEntity>, MemoryError>;
|
||||
|
||||
// ── 标签管理(预留接口,Agent 层显式写入) ──
|
||||
|
||||
/// 按前缀查找已有标签(用于标签复用)。
|
||||
async fn find_tags(&self, prefix: &str) -> Result<Vec<String>, MemoryError>;
|
||||
|
||||
/// 设置实体标签(替换式,保留前 max_tags_per_entity 个)。
|
||||
/// 返回实际设置的标签数。
|
||||
async fn set_entity_tags(
|
||||
&self,
|
||||
entity_id: &str,
|
||||
tags: Vec<String>,
|
||||
) -> Result<usize, MemoryError>;
|
||||
|
||||
/// 按标签统计实体数量。
|
||||
async fn entity_count_by_tag(&self, tag: &str) -> Result<usize, MemoryError>;
|
||||
|
||||
/// 获取标签约束配置。
|
||||
fn tag_constraints(&self) -> TagConstraints;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 InMemoryGraph 实现
|
||||
|
||||
#### 内部结构
|
||||
|
||||
```rust
|
||||
/// 内存知识图谱实现 —— 纯内存,无持久化。
|
||||
///
|
||||
/// 生命周期跟随实例;持久化路径参考 InMemoryVectorStore → PersistentVectorStore 演进模式。
|
||||
pub struct InMemoryGraph {
|
||||
/// 内部状态(单一锁结构,避免嵌套锁死锁)
|
||||
inner: Mutex<GraphInner>,
|
||||
/// 标签约束
|
||||
constraints: TagConstraints,
|
||||
}
|
||||
|
||||
struct GraphInner {
|
||||
/// id → entity
|
||||
entities: HashMap<String, GraphEntity>,
|
||||
/// 所有关系(线性扫描,实测 5000 条 ≈ 1-50µs,无需邻接表索引)
|
||||
relations: Vec<GraphRelation>,
|
||||
/// tag → entity_ids(反向索引,用于 find_tags / entity_count_by_tag)
|
||||
tag_index: HashMap<String, HashSet<String>>,
|
||||
}
|
||||
```
|
||||
|
||||
#### BFS 遍历算法
|
||||
|
||||
```rust
|
||||
async fn get_related(
|
||||
&self,
|
||||
entity_id: &str,
|
||||
depth: usize,
|
||||
direction: RelationDirection,
|
||||
relation_types: Option<&[&str]>,
|
||||
) -> Result<Vec<ScoredEntity>, MemoryError> {
|
||||
// 1. 验证起点存在
|
||||
let inner = self.inner.lock().unwrap();
|
||||
if !inner.entities.contains_key(entity_id) {
|
||||
return Err(MemoryError::NotFound(entity_id.to_string()));
|
||||
}
|
||||
|
||||
// 2. BFS 初始化
|
||||
let mut visited: HashSet<String> = HashSet::new();
|
||||
let mut result: Vec<ScoredEntity> = Vec::new();
|
||||
// 队列:(entity_id, score, path)
|
||||
let mut queue: VecDeque<(String, f32, Vec<String>)> = VecDeque::new();
|
||||
|
||||
queue.push_back((entity_id.to_string(), 1.0, vec![entity_id.to_string()]));
|
||||
visited.insert(entity_id.to_string());
|
||||
|
||||
// 3. BFS 逐层遍历
|
||||
for _ in 0..depth {
|
||||
let mut next_queue: VecDeque<(String, f32, Vec<String>)> = VecDeque::new();
|
||||
|
||||
while let Some((current_id, score, path)) = queue.pop_front() {
|
||||
// 筛选与 current_id 相关的关系
|
||||
for rel in inner.relations.iter() {
|
||||
// 方向过滤
|
||||
let (match_source, match_target) = match direction {
|
||||
RelationDirection::Outgoing => (&rel.source_id, &rel.target_id),
|
||||
RelationDirection::Incoming => (&rel.target_id, &rel.source_id),
|
||||
RelationDirection::Both => {
|
||||
if rel.source_id == current_id {
|
||||
(&rel.source_id, &rel.target_id)
|
||||
} else if rel.target_id == current_id {
|
||||
(&rel.target_id, &rel.source_id)
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if *match_source != current_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 关系类型过滤
|
||||
if let Some(types) = relation_types {
|
||||
if !types.contains(&rel.relation_type.as_str()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let neighbor_id = match_target.clone();
|
||||
if visited.contains(&neighbor_id) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(neighbor_id.clone());
|
||||
|
||||
// 权重乘积衰减
|
||||
let new_score = score * rel.weight;
|
||||
let mut new_path = path.clone();
|
||||
new_path.push(neighbor_id.clone());
|
||||
|
||||
result.push(ScoredEntity {
|
||||
entity: inner.entities.get(&neighbor_id).cloned()
|
||||
.ok_or_else(|| MemoryError::NotFound(neighbor_id.clone()))?,
|
||||
score: new_score,
|
||||
path: new_path.clone(),
|
||||
});
|
||||
|
||||
next_queue.push_back((neighbor_id, new_score, new_path));
|
||||
}
|
||||
}
|
||||
|
||||
queue = next_queue;
|
||||
}
|
||||
|
||||
// 4. 按分数降序排列
|
||||
result.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
Ok(result)
|
||||
}
|
||||
```
|
||||
|
||||
`depth=0` 时仅验证起点实体存在,返回空关联列表(不遍历任何边)。
|
||||
|
||||
**BFS 关键设计点**:
|
||||
|
||||
| 特性 | 处理方式 |
|
||||
|------|----------|
|
||||
| 环路 | `visited: HashSet<String>` 已访问集合防环 |
|
||||
| 评分衰减 | 沿路径 `score *= rel.weight`,权重乘积 |
|
||||
| 多路径 | BFS 天然先到先得,同一实体只保留首次到达路径 |
|
||||
| 关系类型过滤 | `relation_types: Option<&[&str]>`,`None` 表示不过滤 |
|
||||
| 方向过滤 | `RelationDirection` 枚举,`Both` 时双向检查 |
|
||||
|
||||
> 以上性能数据为基于算法复杂度的估算值(O(R) 线性扫描,R=关系数),实际性能需通过基准测试验证。建议在实现后添加 `#[bench]` 或 criterion 基准测试。
|
||||
|
||||
### 3.4 检索扩展
|
||||
|
||||
#### RetrievalStrategy
|
||||
|
||||
```rust
|
||||
/// 检索策略 —— 控制双通道分流。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub enum RetrievalStrategy {
|
||||
/// 并行 KnowledgeStore + KnowledgeGraph,合并排序(默认)。
|
||||
#[default]
|
||||
Hybrid,
|
||||
/// 仅 KnowledgeStore。
|
||||
KnowledgeOnly,
|
||||
/// 仅 KnowledgeGraph。
|
||||
GraphOnly,
|
||||
}
|
||||
```
|
||||
|
||||
#### RetrievalItem
|
||||
|
||||
```rust
|
||||
/// 统一检索条目 —— enum 变体区分类别,两通道分数均在 [0,1] 区间。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RetrievalItem {
|
||||
/// 知识页面(来自 KnowledgeStore)。
|
||||
KnowledgePage {
|
||||
page: KnowledgePage,
|
||||
/// TextOverlap 评分 [0.0, 1.0]。
|
||||
score: f32,
|
||||
},
|
||||
/// 图谱实体(来自 KnowledgeGraph)。
|
||||
GraphEntity {
|
||||
entity: crate::memory::graph::GraphEntity,
|
||||
/// 图距离评分 [0.0, 1.0]。
|
||||
score: f32,
|
||||
/// 从查询实体到当前实体的 ID 路径。
|
||||
path: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl RetrievalItem {
|
||||
/// 统一分数(用于合并排序)。
|
||||
pub fn score(&self) -> f32 {
|
||||
match self {
|
||||
Self::KnowledgePage { score, .. } => *score,
|
||||
Self::GraphEntity { score, .. } => *score,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:两个通道的分数维度不同(TextOverlap vs 图距离),合并排序仅用于统一返回,不代表跨通道可比性。
|
||||
|
||||
#### 向后兼容导出(ScoredItem)
|
||||
|
||||
```rust
|
||||
// ── 向后兼容导出 ──
|
||||
|
||||
/// 旧版带评分的知识页面检索结果(已废弃)。
|
||||
///
|
||||
/// 请迁移到 `RetrievalItem::KnowledgePage { page, score }`。
|
||||
#[deprecated(since = "0.3.0", note = "使用 RetrievalItem::KnowledgePage 代替")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScoredItem {
|
||||
pub page: KnowledgePage,
|
||||
pub score: f32,
|
||||
}
|
||||
|
||||
// 在 memory.rs 模块根的重导出中保留:
|
||||
// #[allow(deprecated)]
|
||||
// pub use retriever::ScoredItem;
|
||||
```
|
||||
|
||||
#### RetrievalResult 更新
|
||||
|
||||
```rust
|
||||
/// 检索结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetrievalResult {
|
||||
/// 统一条目列表,按分数降序排列。
|
||||
pub items: Vec<RetrievalItem>,
|
||||
pub query: String,
|
||||
/// 本次检索实际执行的策略(可能因 graph 未注入而退化),而非用户通过 `with_strategy()` 配置的值。
|
||||
pub strategy: RetrievalStrategy,
|
||||
}
|
||||
```
|
||||
|
||||
#### MemoryRetriever 扩展
|
||||
|
||||
```rust
|
||||
pub struct MemoryRetriever {
|
||||
knowledge_store: KnowledgeStore,
|
||||
/// 可选知识图谱(None 时退化为单通道)。
|
||||
knowledge_graph: Option<Arc<dyn KnowledgeGraph>>,
|
||||
/// 检索策略(默认 Hybrid)。
|
||||
strategy: RetrievalStrategy,
|
||||
config: RetrieverConfig,
|
||||
stop_words: HashSet<String>,
|
||||
}
|
||||
|
||||
impl MemoryRetriever {
|
||||
/// 创建新的 MemoryRetriever(保持向后兼容)。
|
||||
pub fn new(knowledge_store: KnowledgeStore, config: RetrieverConfig) -> Self {
|
||||
Self {
|
||||
knowledge_store,
|
||||
knowledge_graph: None,
|
||||
strategy: RetrievalStrategy::default(),
|
||||
config,
|
||||
stop_words: default_stop_words(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 注入知识图谱,启用双通道检索。
|
||||
pub fn with_knowledge_graph(mut self, graph: Arc<dyn KnowledgeGraph>) -> Self {
|
||||
self.knowledge_graph = Some(graph);
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置检索策略。
|
||||
pub fn with_strategy(mut self, strategy: RetrievalStrategy) -> Self {
|
||||
self.strategy = strategy;
|
||||
self
|
||||
}
|
||||
|
||||
/// 检索相关记忆(双通道)。
|
||||
pub async fn retrieve(&self, query: &str) -> Result<RetrievalResult, MemoryError> {
|
||||
if query.is_empty() {
|
||||
return Ok(RetrievalResult {
|
||||
items: Vec::new(),
|
||||
query: query.to_string(),
|
||||
strategy: self.strategy.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let keywords = extract_keywords(query, &self.stop_words);
|
||||
let has_graph = self.knowledge_graph.is_some();
|
||||
|
||||
// 按策略分流
|
||||
match (&self.strategy, has_graph) {
|
||||
// 仅知识页面
|
||||
(RetrievalStrategy::KnowledgeOnly, _) | (_, false) => {
|
||||
let items = self.search_knowledge_store(query, &keywords).await?;
|
||||
Ok(RetrievalResult {
|
||||
items,
|
||||
query: query.to_string(),
|
||||
strategy: RetrievalStrategy::KnowledgeOnly,
|
||||
})
|
||||
}
|
||||
// 仅图谱
|
||||
(RetrievalStrategy::GraphOnly, true) => {
|
||||
let graph = self.knowledge_graph.as_ref().unwrap();
|
||||
let items = self.search_graph(query, &keywords, graph).await?;
|
||||
Ok(RetrievalResult {
|
||||
items,
|
||||
query: query.to_string(),
|
||||
strategy: self.strategy.clone(),
|
||||
})
|
||||
}
|
||||
// 混合:并行执行,合并排序
|
||||
(RetrievalStrategy::Hybrid, true) => {
|
||||
let graph = self.knowledge_graph.as_ref().unwrap();
|
||||
let (kp_items, g_items) = tokio::join!(
|
||||
self.search_knowledge_store(query, &keywords),
|
||||
self.search_graph(query, &keywords, graph),
|
||||
);
|
||||
|
||||
let mut items = kp_items?;
|
||||
items.extend(g_items?);
|
||||
items.sort_by(|a, b| {
|
||||
b.score().partial_cmp(&a.score())
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
items.truncate(self.config.max_results);
|
||||
|
||||
Ok(RetrievalResult {
|
||||
items,
|
||||
query: query.to_string(),
|
||||
strategy: self.strategy.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 标签管理
|
||||
|
||||
#### 标签索引维护
|
||||
|
||||
`tag_index: HashMap<String, HashSet<String>>` 维护 tag → entity_ids 反向映射:
|
||||
|
||||
- **`set_entity_tags`**:先清除旧标签的反向引用,再写入新标签。超出 `max_tags_per_entity` 时截断。
|
||||
- **`find_tags`**:遍历 `tag_index.keys()`,按前缀过滤。
|
||||
- **`entity_count_by_tag`**:直接返回 `tag_index.get(tag).map_or(0, |s| s.len())`。
|
||||
|
||||
#### 实现要点
|
||||
|
||||
```rust
|
||||
async fn set_entity_tags(
|
||||
&self,
|
||||
entity_id: &str,
|
||||
tags: Vec<String>,
|
||||
) -> Result<usize, MemoryError> {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
let entity = inner.entities.get_mut(entity_id)
|
||||
.ok_or_else(|| MemoryError::NotFound(entity_id.to_string()))?;
|
||||
|
||||
// 清除旧标签的反向引用
|
||||
for old_tag in &entity.tags {
|
||||
if let Some(ids) = inner.tag_index.get_mut(old_tag) {
|
||||
ids.remove(entity_id);
|
||||
if ids.is_empty() {
|
||||
inner.tag_index.remove(old_tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 截断到 max_tags_per_entity
|
||||
let max = self.constraints.max_tags_per_entity;
|
||||
let new_tags: Vec<String> = tags.into_iter().take(max).collect();
|
||||
|
||||
// 写入新标签的反向引用
|
||||
for tag in &new_tags {
|
||||
inner.tag_index.entry(tag.clone())
|
||||
.or_default()
|
||||
.insert(entity_id.to_string());
|
||||
}
|
||||
|
||||
entity.tags = new_tags.clone();
|
||||
Ok(new_tags.len())
|
||||
}
|
||||
```
|
||||
|
||||
#### 标签复用流程(文档说明)
|
||||
|
||||
```
|
||||
LLM 提取候选标签 → 对每个候选:
|
||||
graph.find_tags(candidate.lowercase())
|
||||
├─ 命中已有标签 → 复用
|
||||
└─ 无匹配 → 注册新标签
|
||||
```
|
||||
|
||||
> **标注**:当前无自动提取流程,需 Agent 层显式调用 `set_entity_tags`。
|
||||
|
||||
---
|
||||
|
||||
## 实现计划
|
||||
|
||||
| Step | 内容 | 文件范围 | 验证标准 | 预估行数 |
|
||||
|------|------|----------|----------|----------|
|
||||
| 1 | `graph.rs` 核心类型:GraphEntity / GraphRelation / RelationDirection / ScoredEntity / TagConstraints | `src/memory/graph.rs` | `cargo check` 编译通过 | ~80 |
|
||||
| 2 | `KnowledgeGraph` trait 定义(10 个 async 方法) | `src/memory/graph.rs` | trait 编译通过,无未实现方法 | ~90 |
|
||||
| 3 | `InMemoryGraph` 实现 + BFS 遍历 | `src/memory/graph.rs` | 单元测试:添加实体/关系、BFS 遍历、方向过滤、类型过滤 | ~250 |
|
||||
| 4 | 标签管理实现(set_entity_tags / find_tags / entity_count_by_tag) | `src/memory/graph.rs` | 单元测试:标签增删查、截断、反向索引维护 | ~80 |
|
||||
| 5 | `retriever.rs` 扩展:RetrievalItem / RetrievalStrategy / MemoryRetriever 改造 | `src/memory/retriever.rs` | `cargo check` + 双通道检索测试 | ~180 |
|
||||
| 6 | `memory.rs` 模块根更新 + 重导出 | `src/memory.rs` | `cargo check`,pub use 无编译错误 | ~10 |
|
||||
| 7 | 内联测试 | `src/memory/graph.rs` + `src/memory/retriever.rs` | `cargo test --all-targets` 全绿 | ~150 |
|
||||
|
||||
**总预估**:约 840 行(核心 640 + 测试 200)
|
||||
|
||||
---
|
||||
|
||||
## 风险评估
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| 标签 API 无消费者 | 低 — 预留接口,不影响核心功能 | 文档标注"Agent 层显式写入",后续 Phase 接入 |
|
||||
| 评分不可比 | 中 — TextOverlap vs 图距离维度不同 | `RetrievalItem` enum 变体分离,合并排序仅统一返回,文档注明维度差异 |
|
||||
| BFS 性能 | 低 — 5000 关系遍历 ≈ 1-50µs | 不引入邻接表索引,等实测超过 1ms 再优化 |
|
||||
| Breaking change | 中 — `RetrievalResult.items` 类型变化 | CHANGELOG 标注,`ScoredItem` 保留为 `pub` 兼容导出(deprecate) |
|
||||
| Mutex 竞争 | 低 — InMemoryGraph 单实例场景 | 读多写少, Mutex 性能足够;后续可升级 RwLock |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
| 检查项 | 标准 | 验证命令 |
|
||||
|--------|------|----------|
|
||||
| 编译 | 0 error | `cargo check --all-targets` |
|
||||
| 测试 | 全绿,测试数从 ~391 增至 ~410+ | `cargo test --all-targets` |
|
||||
| Clippy | 0 warning | `cargo clippy --all-targets -- -D warnings` |
|
||||
| 文档 | 0 warning | `cargo doc --no-deps` |
|
||||
| BFS 覆盖 | 所有边界条件:空图、单实体、环路、深度 0、方向过滤、类型过滤 | 内联测试 |
|
||||
| 双通道 | Hybrid / KnowledgeOnly / GraphOnly 三种策略功能正确 | 内联测试 |
|
||||
| 向后兼容 | `MemoryRetriever::new()` 签名不变,现有调用无需修改 | `cargo check` 无 breaking error |
|
||||
@@ -0,0 +1,694 @@
|
||||
# AG Core v0.3.2 Step 1(Phase 20)— Cargo Features 基础设施改造实施方案
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
**背景**:agcore 是一个 Rust 编写的智能体核心工具箱,目前约 23,718 行、66 个源文件。v0.3.0 发布后,所有模块在编译时全量捆绑,下游用户无法按需选择模块,即使只使用 LLM 对话也需要编译 sqlite / MCP / agent 引擎等全部依赖。
|
||||
|
||||
**目标**:通过 Cargo features 拆分让下游按需选择模块。Step 1 是基础设施变更 —— `Cargo.toml` features 定义 + 依赖 optional 化 + 必要的子模块 cfg 门控,编译通过后打 checkpoint。
|
||||
|
||||
**预期效果**:
|
||||
- `default = ["full"]` → v0.3.0 用户零迁移成本
|
||||
- 最小组合(`document`)零重型外部依赖(仅依赖始终编译的轻量依赖:serde/serde_json/thiserror/async-trait/tracing)
|
||||
- 纯对话组合(`chat + provider-openai`)仅需 ~10 个依赖,不含 sqlite / MCP / engine
|
||||
|
||||
## 2. 需求分析
|
||||
|
||||
### 2.1 约束条件
|
||||
|
||||
| # | 约束 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `default = ["full"]` | 保持向后兼容,v0.3.0 用户零迁移成本 |
|
||||
| 2 | `document` feature 零重型外部依赖(仅依赖始终编译的轻量依赖:serde/serde_json/thiserror/async-trait/tracing) | 纯 std + 始终编译的轻量依赖(serde/serde_json/thiserror/async-trait/tracing) |
|
||||
| 3 | tokio 从 `["full"]` 拆细 | 已验证全库无 net/fs/signal 使用,拆为 `["rt", "sync", "time", "macros", "process", "io-util"]` |
|
||||
| 4 | 重型依赖全部 optional | tokio、reqwest、rusqlite、tracing-subscriber、tokio-stream、futures、futures-util、futures-core、bytes、async-stream、tokio-util、time |
|
||||
| 5 | 始终编译的轻量依赖 | serde、serde_json、thiserror、async-trait、tracing |
|
||||
|
||||
### 2.2 关键决策
|
||||
|
||||
| # | 决策 | 理由 |
|
||||
|---|------|------|
|
||||
| 1 | `tools` feature 必须 `imply tokio` | `src/tools/registry.rs` 使用 `tokio::time::timeout` |
|
||||
| 2 | `pub mod llm` 门控条件为 `any(feature = "llm-types", feature = "llm")` | `prompt → llm-types` 路径需要 llm 模块编译,但只需 types 子模块 |
|
||||
| 3 | 测试 dev-dependencies 加 `tokio = { version = "1", features = ["rt", "macros"] }` | 现有 `#[tokio::test]` 需要 tokio runtime |
|
||||
| 4 | 快捷组合名保持原名(chat/multi/light) | 文档中说明各组合包含的 feature 约束 |
|
||||
| 5 | `init_tracing()` 函数整体用 `#[cfg(feature = "tracing-init")]` 包裹 | 避免 `use tracing_subscriber` 出现在未启用 feature 时编译失败 |
|
||||
|
||||
## 3. 方案设计
|
||||
|
||||
### 3.1 Features 定义(完整 Cargo.toml `[features]` 草案)
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = ["full"]
|
||||
|
||||
# === 模块级 features ===
|
||||
document = []
|
||||
llm-types = []
|
||||
prompt = ["llm-types"]
|
||||
llm = ["llm-types", "tokio", "async-stream", "futures-core", "tokio-stream"]
|
||||
tools = ["llm-types", "futures", "tokio-util", "tokio"]
|
||||
tools-mcp = ["tools", "reqwest"]
|
||||
# memory 模块依赖 llm(conversation/vector_store 使用 compact/embedding)、tokio(knowledge.rs 使用 Mutex)、time(types.rs 使用 OffsetDateTime)
|
||||
memory = ["document", "llm", "tokio", "time"]
|
||||
memory-sqlite = ["memory", "rusqlite", "time"]
|
||||
agent = ["llm", "tools", "memory", "futures-util"]
|
||||
engine = ["agent"]
|
||||
|
||||
# === Provider features ===
|
||||
# Provider features — openai/anthropic 额外依赖 bytes(流式解析)和 futures-util(Stream 组合)
|
||||
provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
|
||||
provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
|
||||
# deepseek/qwen 使用 openai_compat 适配层,不需要 bytes 和 futures-util
|
||||
provider-deepseek = ["llm", "reqwest"]
|
||||
provider-qwen = ["llm", "reqwest"]
|
||||
provider-ollama = ["llm", "reqwest"]
|
||||
|
||||
# === 工具 features ===
|
||||
tracing-init = ["tracing-subscriber"]
|
||||
|
||||
# === 快捷组合 ===
|
||||
full = [
|
||||
"document", "llm-types", "prompt", "llm",
|
||||
"tools", "tools-mcp",
|
||||
"memory", "memory-sqlite",
|
||||
"agent", "engine",
|
||||
"provider-openai", "provider-anthropic", "provider-deepseek",
|
||||
"provider-qwen", "provider-ollama",
|
||||
"tracing-init",
|
||||
]
|
||||
light = ["llm", "provider-openai", "tools", "tools-mcp", "memory", "agent", "engine", "prompt", "document"]
|
||||
chat = ["agent", "provider-openai"]
|
||||
multi = ["engine", "provider-openai"]
|
||||
```
|
||||
|
||||
**features 依赖图(简略)**:
|
||||
|
||||
```
|
||||
document (零外部依赖)
|
||||
└── memory (+llm, +tokio, +time) ─── memory-sqlite (+rusqlite, +time)
|
||||
|
||||
llm-types (零依赖)
|
||||
├── prompt
|
||||
└── llm (+tokio, +async-stream, +futures-core, +tokio-stream)
|
||||
├── tools (+futures, +tokio-util) ─── tools-mcp (+reqwest)
|
||||
├── provider-openai / provider-anthropic (+reqwest, +bytes, +futures-util)
|
||||
├── provider-deepseek / provider-qwen / provider-ollama (+reqwest)
|
||||
└── agent (+tools, +memory, +futures-util) ─── engine
|
||||
```
|
||||
|
||||
### 3.2 依赖 optional 化方案
|
||||
|
||||
**始终编译(5 个,不参与门控)**:
|
||||
|
||||
```toml
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
async-trait = "0.1"
|
||||
tracing = "0.1"
|
||||
```
|
||||
|
||||
**12 个依赖加 `optional = true`**:
|
||||
|
||||
| 依赖 | 原声明 | 新声明 |
|
||||
|------|--------|--------|
|
||||
| tokio | `{ version = "1", features = ["full"] }` | `{ version = "1", features = ["rt", "sync", "time", "macros", "process", "io-util"], optional = true }` |
|
||||
| reqwest | `{ version = "0.12", features = ["json", "stream"] }` | `{ version = "0.12", features = ["json", "stream"], optional = true }` |
|
||||
| rusqlite | `{ version = "0.32", features = ["bundled"] }` | `{ version = "0.32", features = ["bundled"], optional = true }` |
|
||||
| tracing-subscriber | `{ version = "0.3", features = ["env-filter"] }` | `{ version = "0.3", features = ["env-filter"], optional = true }` |
|
||||
| tokio-stream | `{ version = "0.1" }` | `{ version = "0.1", optional = true }` |
|
||||
| futures | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
|
||||
| futures-util | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
|
||||
| futures-core | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
|
||||
| bytes | `{ version = "1" }` | `{ version = "1", optional = true }` |
|
||||
| async-stream | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
|
||||
| tokio-util | `{ version = "0.7", features = ["rt", "sync"] }` | `{ version = "0.7", features = ["rt", "sync"], optional = true }` |
|
||||
| time | `{ version = "0.3", features = ["serde", "parsing", "formatting", "macros"] }` | `{ version = "0.3", features = ["serde", "parsing", "formatting", "macros"], optional = true }` |
|
||||
|
||||
**dev-dependencies 新增**:
|
||||
|
||||
```toml
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["rt", "macros"] }
|
||||
```
|
||||
|
||||
### 3.3 源文件改动清单
|
||||
|
||||
共涉及 **8 个文件**(预估 ~100 行改动):`Cargo.toml`、`src/lib.rs`、`src/llm.rs`、`src/llm/cycle.rs`、`src/tools.rs`、`src/memory.rs`、`src/memory/store.rs`、`src/agent/session.rs`
|
||||
|
||||
---
|
||||
|
||||
#### 文件 1:`Cargo.toml`
|
||||
|
||||
**改动 1.1** — 新增 `[features]` 表(约 45 行,插入在 `[package]` 之后、`[dependencies]` 之前)
|
||||
|
||||
```diff
|
||||
+ [features]
|
||||
+ default = ["full"]
|
||||
+
|
||||
+ # === 模块级 features ===
|
||||
+ document = []
|
||||
+ llm-types = []
|
||||
+ prompt = ["llm-types"]
|
||||
+ llm = ["llm-types", "tokio", "async-stream", "futures-core", "tokio-stream"]
|
||||
+ tools = ["llm-types", "futures", "tokio-util", "tokio"]
|
||||
+ tools-mcp = ["tools", "reqwest"]
|
||||
+ # memory 模块依赖 llm(conversation/vector_store 使用 compact/embedding)、tokio(knowledge.rs 使用 Mutex)、time(types.rs 使用 OffsetDateTime)
|
||||
+ memory = ["document", "llm", "tokio", "time"]
|
||||
+ memory-sqlite = ["memory", "rusqlite", "time"]
|
||||
+ agent = ["llm", "tools", "memory", "futures-util"]
|
||||
+ engine = ["agent"]
|
||||
+
|
||||
+ # === Provider features ===
|
||||
+ # Provider features — openai/anthropic 额外依赖 bytes(流式解析)和 futures-util(Stream 组合)
|
||||
+ provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
|
||||
+ provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
|
||||
+ # deepseek/qwen 使用 openai_compat 适配层,不需要 bytes 和 futures-util
|
||||
+ provider-deepseek = ["llm", "reqwest"]
|
||||
+ provider-qwen = ["llm", "reqwest"]
|
||||
+ provider-ollama = ["llm", "reqwest"]
|
||||
+
|
||||
+ # === 工具 features ===
|
||||
+ tracing-init = ["tracing-subscriber"]
|
||||
+
|
||||
+ # === 快捷组合 ===
|
||||
+ full = [
|
||||
+ "document", "llm-types", "prompt", "llm",
|
||||
+ "tools", "tools-mcp",
|
||||
+ "memory", "memory-sqlite",
|
||||
+ "agent", "engine",
|
||||
+ "provider-openai", "provider-anthropic", "provider-deepseek",
|
||||
+ "provider-qwen", "provider-ollama",
|
||||
+ "tracing-init",
|
||||
+ ]
|
||||
+ light = ["llm", "provider-openai", "tools", "tools-mcp", "memory", "agent", "engine", "prompt", "document"]
|
||||
+ chat = ["agent", "provider-openai"]
|
||||
+ multi = ["engine", "provider-openai"]
|
||||
```
|
||||
|
||||
**改动 1.2** — tokio 依赖声明修改
|
||||
|
||||
```diff
|
||||
- tokio = { version = "1", features = ["full"] }
|
||||
+ tokio = { version = "1", features = ["rt", "sync", "time", "macros", "process", "io-util"], optional = true }
|
||||
```
|
||||
|
||||
**改动 1.3** — 11 个重型依赖逐行加 `optional = true`
|
||||
|
||||
```diff
|
||||
- reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
+ reqwest = { version = "0.12", features = ["json", "stream"], optional = true }
|
||||
|
||||
- rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
+ rusqlite = { version = "0.32", features = ["bundled"], optional = true }
|
||||
|
||||
- tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
+ tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||
|
||||
- tokio-stream = "0.1"
|
||||
+ tokio-stream = { version = "0.1", optional = true }
|
||||
|
||||
- futures = "0.3"
|
||||
+ futures = { version = "0.3", optional = true }
|
||||
|
||||
- futures-util = "0.3"
|
||||
+ futures-util = { version = "0.3", optional = true }
|
||||
|
||||
- futures-core = "0.3"
|
||||
+ futures-core = { version = "0.3", optional = true }
|
||||
|
||||
- bytes = "1"
|
||||
+ bytes = { version = "1", optional = true }
|
||||
|
||||
- async-stream = "0.3"
|
||||
+ async-stream = { version = "0.3", optional = true }
|
||||
|
||||
- tokio-util = { version = "0.7", features = ["rt"] }
|
||||
+ tokio-util = { version = "0.7", features = ["rt", "sync"], optional = true }
|
||||
|
||||
- time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"] }
|
||||
+ time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"], optional = true }
|
||||
```
|
||||
|
||||
**改动 1.4** — `[dev-dependencies]` 新增 tokio
|
||||
|
||||
```diff
|
||||
+ [dev-dependencies]
|
||||
+ tokio = { version = "1", features = ["rt", "macros"] }
|
||||
```
|
||||
|
||||
**说明**:如果原 `Cargo.toml` 已有 `[dev-dependencies]` 则追加该行;若无则新增整个 section。
|
||||
|
||||
---
|
||||
|
||||
#### 文件 2:`src/lib.rs`(当前约 26 行 → 改动后约 40 行)
|
||||
|
||||
**当前内容(参考)**:
|
||||
```rust
|
||||
//! agcore —— 智能体(Agent)核心工具箱。
|
||||
|
||||
pub mod llm;
|
||||
pub mod document;
|
||||
pub mod prompt;
|
||||
pub mod tools;
|
||||
pub mod memory;
|
||||
pub mod agent;
|
||||
pub mod engine;
|
||||
|
||||
pub use document::Document;
|
||||
|
||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
static INIT: std::sync::Once = std::sync::Once::new();
|
||||
pub fn init_tracing() {
|
||||
INIT.call_once(|| {
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("agcore=info"));
|
||||
tracing_subscriber::registry()
|
||||
.with(fmt::layer())
|
||||
.with(filter)
|
||||
.init();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**改动后内容**:
|
||||
```diff
|
||||
//! agcore —— 智能体(Agent)核心工具箱。
|
||||
|
||||
- pub mod llm;
|
||||
+ #[cfg(any(feature = "llm-types", feature = "llm"))]
|
||||
+ pub mod llm;
|
||||
- pub mod document;
|
||||
+ #[cfg(feature = "document")]
|
||||
+ pub mod document;
|
||||
- pub mod prompt;
|
||||
+ #[cfg(feature = "prompt")]
|
||||
+ pub mod prompt;
|
||||
- pub mod tools;
|
||||
+ #[cfg(feature = "tools")]
|
||||
+ pub mod tools;
|
||||
- pub mod memory;
|
||||
+ #[cfg(feature = "memory")]
|
||||
+ pub mod memory;
|
||||
- pub mod agent;
|
||||
+ #[cfg(feature = "agent")]
|
||||
+ pub mod agent;
|
||||
- pub mod engine;
|
||||
+ #[cfg(feature = "engine")]
|
||||
+ pub mod engine;
|
||||
|
||||
- pub use document::Document;
|
||||
+ #[cfg(feature = "document")]
|
||||
+ pub use document::Document;
|
||||
|
||||
- use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
- static INIT: std::sync::Once = std::sync::Once::new();
|
||||
- pub fn init_tracing() {
|
||||
- INIT.call_once(|| {
|
||||
- let filter = EnvFilter::try_from_default_env()
|
||||
- .unwrap_or_else(|_| EnvFilter::new("agcore=info"));
|
||||
- tracing_subscriber::registry()
|
||||
- .with(fmt::layer())
|
||||
- .with(filter)
|
||||
- .init();
|
||||
- });
|
||||
- }
|
||||
+ #[cfg(feature = "tracing-init")]
|
||||
+ use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
+
|
||||
+ #[cfg(feature = "tracing-init")]
|
||||
+ static INIT: std::sync::Once = std::sync::Once::new();
|
||||
+
|
||||
+ #[cfg(feature = "tracing-init")]
|
||||
+ pub fn init_tracing() {
|
||||
+ INIT.call_once(|| {
|
||||
+ let filter = EnvFilter::try_from_default_env()
|
||||
+ .unwrap_or_else(|_| EnvFilter::new("agcore=info"));
|
||||
+ tracing_subscriber::registry()
|
||||
+ .with(fmt::layer())
|
||||
+ .with(filter)
|
||||
+ .init();
|
||||
+ });
|
||||
+ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 文件 3:`src/llm.rs`(当前约 12 行 → 改动后约 24 行)
|
||||
|
||||
**改动说明**:为每个子模块声明加 feature 门控。`types` 子模块在 `llm-types` 或 `llm` 任一 feature 启用时编译(`llm` imply `llm-types`,但 `prompt` 也 depend on `llm-types`);其余子模块(compact/convert/cycle 等)仅在 `llm` feature 启用时编译。
|
||||
|
||||
```diff
|
||||
//! LLM 调用周期 —— 大模型基础调用周期控制。
|
||||
|
||||
- pub mod types;
|
||||
+ #[cfg(feature = "llm-types")]
|
||||
+ pub mod types;
|
||||
- pub mod compact;
|
||||
+ #[cfg(feature = "llm")]
|
||||
+ pub mod compact;
|
||||
- pub mod convert;
|
||||
+ #[cfg(feature = "llm")]
|
||||
+ pub mod convert;
|
||||
- pub mod cycle;
|
||||
+ #[cfg(feature = "llm")]
|
||||
+ pub mod cycle;
|
||||
- pub mod embedding;
|
||||
+ #[cfg(feature = "llm")]
|
||||
+ pub mod embedding;
|
||||
- pub mod error;
|
||||
+ #[cfg(feature = "llm")]
|
||||
+ pub mod error;
|
||||
- pub mod hooks;
|
||||
+ #[cfg(feature = "llm")]
|
||||
+ pub mod hooks;
|
||||
- pub mod mock;
|
||||
+ #[cfg(feature = "llm")]
|
||||
+ pub mod mock;
|
||||
- pub mod provider;
|
||||
+ // provider 模块依赖 reqwest(通过 reqwest::Client),仅在任一 provider feature 启用时编译
|
||||
+ #[cfg(any(feature = "provider-openai", feature = "provider-anthropic", feature = "provider-deepseek", feature = "provider-qwen", feature = "provider-ollama"))]
|
||||
+ pub mod provider;
|
||||
- pub mod stream;
|
||||
+ #[cfg(feature = "llm")]
|
||||
+ pub mod stream;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 文件 3b:`src/llm/cycle.rs`(新增文件,约 35 行)
|
||||
|
||||
**改动说明**:`cycle.rs` 中使用 `crate::tools::ToolRegistry`(第 29 行),依赖 `tools` feature。工具相关字段和方法需加 `#[cfg(feature = "tools")]` 门控。
|
||||
|
||||
> ⚠️ 这是 Phase 22.7 原计划的门控变更,因为编译阻塞提前到 Step 1 执行。
|
||||
|
||||
```diff
|
||||
//! Cycle —— 多轮对话与工具调用编排。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt; // 来自 llm→tokio imply 链
|
||||
+ #[cfg(feature = "tools")]
|
||||
use crate::tools::ToolRegistry;
|
||||
|
||||
// ... struct / enum 定义 ...
|
||||
|
||||
// ===== CycleConfig — 工具相关字段加 cfg 门控 =====
|
||||
pub struct CycleConfig {
|
||||
pub max_retries: usize,
|
||||
pub max_history: usize,
|
||||
+ #[cfg(feature = "tools")]
|
||||
pub max_tool_turns: usize,
|
||||
+ #[cfg(feature = "tools")]
|
||||
pub tool_timeout_secs: u64,
|
||||
// ... 其他字段 ...
|
||||
}
|
||||
|
||||
// ===== Cycle — 方法加 cfg 门控 =====
|
||||
impl Cycle {
|
||||
/// 仅在有 tools feature 时才有工具调用相关方法
|
||||
+ #[cfg(feature = "tools")]
|
||||
pub async fn submit_with_tools(&self, ...) -> Result<...> {
|
||||
// ...
|
||||
}
|
||||
|
||||
+ /// submit_with_tools_stream 方法同样需要 tools 门控,
|
||||
+ /// 因参数包含 Arc<ToolRegistry> 而与 submit_with_tools 同理。
|
||||
+ #[cfg(feature = "tools")]
|
||||
+ pub async fn submit_with_tools_stream(
|
||||
+ &self, ... // 方法签名中包含 Arc<ToolRegistry> 参数
|
||||
+ ) -> Result<...> {
|
||||
+ // ...
|
||||
+ }
|
||||
|
||||
+ #[cfg(feature = "tools")]
|
||||
async fn run_tool_loop(&self, ...) -> Result<...> {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
+ // ===== 顶层函数 — 同样依赖 ToolRegistry =====
|
||||
+ /// run_tool_loop 函数(顶层函数,非 LlmCycle 方法)同样依赖 Arc<ToolRegistry>,
|
||||
+ /// 参数包含 Arc<ToolRegistry>,需 #[cfg(feature = "tools")]。
|
||||
+ #[cfg(feature = "tools")]
|
||||
+ pub async fn run_tool_loop(
|
||||
+ // ... 函数签名中包含 Arc<ToolRegistry> 参数
|
||||
+ ) -> Result<...> {
|
||||
+ // ...
|
||||
+ }
|
||||
|
||||
**说明**:`Cycle` 本身的 struct 定义、`submit()` 基础方法、`ResponseStream` 等不依赖 tools 的部分保持无门控,仅在 `llm` feature 下编译即可。
|
||||
|
||||
---
|
||||
|
||||
#### 文件 4:`src/tools.rs`(当前约 13 行 → 改动后约 15 行)
|
||||
|
||||
**改动说明**:`mcp` 子模块及对应的 `pub use` 仅在 `tools-mcp` feature 启用时编译。其余子模块(base/error/permission/registry)始终在 `tools` feature 下编译。
|
||||
|
||||
```diff
|
||||
//! 工具系统 —— 工具抽象、注册、调用、权限控制与 MCP 集成。
|
||||
|
||||
pub mod base;
|
||||
pub mod error;
|
||||
- pub mod mcp;
|
||||
+ #[cfg(feature = "tools-mcp")]
|
||||
+ pub mod mcp;
|
||||
pub mod permission;
|
||||
pub mod registry;
|
||||
|
||||
pub use base::{BaseTool, ToolContext, ToolRef};
|
||||
pub use error::ToolError;
|
||||
- pub use mcp::{McpClient, McpTransport};
|
||||
+ #[cfg(feature = "tools-mcp")]
|
||||
+ pub use mcp::{McpClient, McpTransport};
|
||||
pub use permission::{Permission, PermissionChecker, PermissionConfig};
|
||||
pub use registry::{ToolInvocation, ToolRegistry};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 文件 5:`src/memory/store.rs`(当前约 62 行 → 改动后约 64 行)
|
||||
|
||||
**改动说明**:`sqlite_store` 子模块及其 `pub use` 仅在 `memory-sqlite` feature 启用时编译。
|
||||
|
||||
```diff
|
||||
//! MemoryStore 抽象接口与默认实现。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crate::memory::error::MemoryError;
|
||||
use crate::memory::types::{MemoryFilter, MemoryItem};
|
||||
|
||||
pub mod in_memory;
|
||||
- pub mod sqlite_store;
|
||||
+ #[cfg(feature = "memory-sqlite")]
|
||||
+ pub mod sqlite_store;
|
||||
|
||||
pub use in_memory::InMemoryStore;
|
||||
- pub use sqlite_store::SqliteStore;
|
||||
+ #[cfg(feature = "memory-sqlite")]
|
||||
+ pub use sqlite_store::SqliteStore;
|
||||
```
|
||||
|
||||
**说明**:`MemoryStore` trait、`EvictionConfig`、`EvictionPolicy` 等定义保持不变,不需要 cfg 门控。
|
||||
|
||||
---
|
||||
|
||||
#### 文件 6:`src/memory.rs`(当前约 32 行 → 改动后约 34 行)
|
||||
|
||||
**改动说明**:`SqliteStore` 的重新导出仅在 `memory-sqlite` feature 启用时编译。其余子模块声明和 `pub use` 保持不变(`memory` feature 门控由 `src/lib.rs` 负责)。
|
||||
|
||||
```diff
|
||||
//! 记忆系统 —— 对话消息管理、知识页面存储与关键词检索。
|
||||
|
||||
// 所有子模块声明保持不变:
|
||||
// pub mod conversation;
|
||||
// pub mod error;
|
||||
// pub mod graph;
|
||||
// pub mod knowledge;
|
||||
// pub mod retriever;
|
||||
// pub mod store;
|
||||
// ...
|
||||
|
||||
// 高频类型
|
||||
pub use conversation::{ConversationMemory, ConversationMemoryConfig};
|
||||
pub use error::MemoryError;
|
||||
pub use graph::{GraphEntity, GraphRelation, InMemoryGraph, KnowledgeGraph, RelationDirection, ScoredEntity};
|
||||
pub use knowledge::KnowledgeStore;
|
||||
pub use retriever::MemoryRetriever;
|
||||
pub use store::{InMemoryStore, MemoryStore};
|
||||
+ #[cfg(feature = "memory-sqlite")]
|
||||
+ pub use store::SqliteStore;
|
||||
// 其余 pub use 保持不变...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 文件 7:`src/agent/session.rs`(新增文件,约 30 行)
|
||||
|
||||
**改动说明**:`session.rs` 中引用了 `crate::engine::*`(SessionMemoryEntry、SessionSnapshot、EngineError),而 `agent` feature 不含 `engine`(`engine = ["agent"]` 是反向依赖)。需要对 engine 相关导入和方法加门控。
|
||||
|
||||
> ⚠️ 阻塞 B3:`src/agent/session.rs:28-29` 无条件引用 `crate::engine::*`,在 `agent` feature 下编译时因缺少 engine 而失败。
|
||||
|
||||
```diff
|
||||
//! Session —— Agent 会话管理。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crate::llm::types::LLMRequest;
|
||||
use crate::memory::MemoryStore;
|
||||
+ #[cfg(feature = "engine")]
|
||||
use crate::engine::snapshot::{SessionMemoryEntry, SessionSnapshot};
|
||||
+ #[cfg(feature = "engine")]
|
||||
use crate::engine::EngineError;
|
||||
|
||||
// ===== AgentSession — pending_memory_restore 字段 =====
|
||||
+ /// AgentSession 结构体中的 pending_memory_restore 字段类型来自 engine 模块,
|
||||
+ /// 需要条件编译。
|
||||
pub struct AgentSession {
|
||||
+ // ... 其他字段 ...
|
||||
+
|
||||
+ #[cfg(feature = "engine")]
|
||||
+ pending_memory_restore: Option<HashMap<String, SessionMemoryEntry>>,
|
||||
+ // ... 其他字段 ...
|
||||
+ }
|
||||
|
||||
impl Session {
|
||||
/// to_snapshot / from_snapshot / restore_memory 仅在 engine feature 下可用
|
||||
+ #[cfg(feature = "engine")]
|
||||
pub fn to_snapshot(&self) -> SessionSnapshot {
|
||||
// ...
|
||||
}
|
||||
|
||||
+ #[cfg(feature = "engine")]
|
||||
pub fn from_snapshot(snap: SessionSnapshot) -> Result<Self, EngineError> {
|
||||
// ...
|
||||
}
|
||||
|
||||
+ #[cfg(feature = "engine")]
|
||||
async fn restore_memory(&mut self, entries: Vec<SessionMemoryEntry>) -> Result<(), MemoryError> {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:`Session` 结构体本身以及不依赖 engine 的方法(如 `new()`、`add_message()`、`get_history()`)保持无门控,仅在 `agent` feature 下编译即可。
|
||||
|
||||
---
|
||||
|
||||
## 4. 实施步骤
|
||||
|
||||
按 **3 个 commit** 粒度执行,每个 commit 后编译验证。
|
||||
|
||||
### Commit 1:Cargo.toml features 定义 + 依赖 optional 化
|
||||
|
||||
**涉及文件**:仅 `Cargo.toml`
|
||||
|
||||
**操作清单**:
|
||||
|
||||
1. 在 `[package]` 之后、`[dependencies]` 之前插入 `[features]` 表(16 个 features + 4 个快捷组合,约 45 行)
|
||||
2. tokio features 从 `["full"]` 改为 `["rt", "sync", "time", "macros", "process", "io-util"]` 并加 `optional = true`
|
||||
3. reqwest / rusqlite / tracing-subscriber / tokio-stream / futures / futures-util / futures-core / bytes / async-stream / tokio-util / time 共 11 个依赖加 `optional = true`
|
||||
4. 在 `[dependencies]` 之后新增 `[dev-dependencies]` 加 `tokio = { version = "1", features = ["rt", "macros"] }`
|
||||
|
||||
**验证**:
|
||||
```bash
|
||||
cargo build --no-default-features # 不依赖任何 optional crate,应通过
|
||||
cargo build -F document # 零外部依赖,应通过
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Commit 2:cfg 门控(pub mod + pub use + init_tracing)
|
||||
|
||||
**涉及文件**:`src/lib.rs`、`src/llm.rs`、`src/llm/cycle.rs`、`src/tools.rs`、`src/memory/store.rs`、`src/memory.rs`、`src/agent/session.rs`
|
||||
|
||||
**操作清单**:
|
||||
|
||||
按文件逐一执行:
|
||||
|
||||
1. `src/lib.rs` — 7 个 `pub mod` 加 `#[cfg(feature = "...")]`、`Document` pub use 加 cfg、`init_tracing` 整体用 `#[cfg(feature = "tracing-init")]` 包裹
|
||||
2. `src/llm.rs` — 10 个子模块按 `llm-types` / `llm` / provider 分类门控
|
||||
3. `src/llm/cycle.rs` — `ToolRegistry` 导入加 `#[cfg(feature = "tools")]`,工具字段和方法加相同门控
|
||||
4. `src/tools.rs` — `pub mod mcp` 和 `pub use mcp::*` 加 `#[cfg(feature = "tools-mcp")]`
|
||||
5. `src/memory/store.rs` — `pub mod sqlite_store` 和 `pub use sqlite_store::SqliteStore` 加 `#[cfg(feature = "memory-sqlite")]`
|
||||
6. `src/memory.rs` — `pub use store::SqliteStore` 加 `#[cfg(feature = "memory-sqlite")]`
|
||||
7. `src/agent/session.rs` — engine 相关导入加 `#[cfg(feature = "engine")]`,to_snapshot/from_snapshot/restore_memory 加相同门控
|
||||
|
||||
**验证**:
|
||||
```bash
|
||||
cargo build -F "full" # 全量回归
|
||||
cargo build -F "prompt" # 验证 llm::types imply 路径
|
||||
cargo build -F "tools" # 验证 tokio imply 路径
|
||||
cargo build -F "memory" # 验证记忆模块不含 sqlite
|
||||
cargo build -F "chat,provider-openai" # 纯对话组合
|
||||
cargo build -F "chat,provider-openai,tools-mcp" # 带 MCP 对话
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Commit 3:Checkpoint 全量验证
|
||||
|
||||
**操作清单**:
|
||||
|
||||
1. 完整的验证矩阵执行(见第 5 节)
|
||||
2. `cargo test -F "full"` 确认 427 passed
|
||||
|
||||
**验证**:
|
||||
```bash
|
||||
cargo test -F "full"
|
||||
cargo build -F "light"
|
||||
cargo build -F "multi"
|
||||
```
|
||||
|
||||
## 5. 验证标准
|
||||
|
||||
### 编译验证矩阵
|
||||
|
||||
| 命令 | 验证目标 | 预期结果 |
|
||||
|------|---------|---------|
|
||||
| `cargo build --no-default-features` | 空 crate | 编译通过(无模块) |
|
||||
| `cargo build -F "full"` | 全量回归 | 编译通过,与 v0.3.0 语义一致 |
|
||||
| `cargo test -F "full"` | 测试回归 | `427 passed` |
|
||||
| `cargo build -F "document"` | 文档模块独立 | 编译通过,零外部依赖 |
|
||||
| `cargo build -F "prompt"` | 提示词独立 | 编译通过,`llm::types` imply 路径正确 |
|
||||
| `cargo build -F "tools"` | 工具独立 | 编译通过,tokio imply 路径正确 |
|
||||
| `cargo build -F "memory"` | 记忆模块独立编译 | ✅ 不含 sqlite(依赖 B1/B2 修复) |
|
||||
| `cargo build -F "memory-sqlite"` | 含 SQLite 的记忆模块 | ✅ 含 rusqlite |
|
||||
| `cargo build -F "agent"` | Agent 独立编译 | ✅ 含 llm+tools+memory,不含 engine(依赖 B1/B3 修复) |
|
||||
| `cargo build -F "engine"` | Engine 独立编译 | ✅ imply agent → llm+tools+memory |
|
||||
| `cargo build -F "chat,provider-openai"` | 纯对话组合 | 编译通过,不含 MCP、sqlite |
|
||||
| `cargo build -F "chat,provider-openai,tools-mcp"` | 带 MCP 对话 | 编译通过,含 reqwest 无 sqlite |
|
||||
| `cargo build -F "light"` | 生产常用组合 | 编译通过 |
|
||||
| `cargo build -F "multi"` | 多 provider 组合 | 编译通过 |
|
||||
|
||||
### 验证操作指令
|
||||
|
||||
每次编译验证后执行(验证编译产物不含意外符号):
|
||||
|
||||
```bash
|
||||
# 确认空 crate 确实没有模块符号
|
||||
cargo build --no-default-features 2>&1 && echo "OK"
|
||||
|
||||
# 确认 document 零外部依赖(无 reqwest/rusqlite 等符号)
|
||||
cargo build -F "document" 2>&1 && echo "OK"
|
||||
|
||||
# 全量构建 + 测试
|
||||
cargo build -F "full" 2>&1 && cargo test -F "full" 2>&1 | tail -5
|
||||
```
|
||||
|
||||
### 验证通过条件
|
||||
|
||||
- 所有 14 条编译验证命令返回 exit code 0
|
||||
- `cargo test -F "full"` 输出 `427 passed`(与 v0.3.0 基线一致,不要求测试数精确匹配,但必须全部通过且数量合理)
|
||||
- 无 `unused import` / `unused variable` / `dead code` warning(由 `#[cfg]` 引起的新 warning 需逐一修复)
|
||||
|
||||
## 6. 风险评估
|
||||
|
||||
| 风险 | 等级 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| **tokio features 拆细遗漏**:某些代码路径用到 net/fs/signal | 低 | 已通过 SA(静态分析)验证全库无相关使用 |
|
||||
| **`#[tokio::test]` 编译失败**:测试代码无 tokio runtime | 低 | `[dev-dependencies]` 添加 `tokio = { version = "1", features = ["rt", "macros"] }` |
|
||||
| **下游 transitive tokio features 缩小**:依赖 agcore 的 crate 之前通过 agcore 间接获得 `full` tokio,现在范围缩小 | 中 | Phase 27 README 发布说明中明确告知迁移方案;下游如需完整 tokio 需自行添加 |
|
||||
| **imply 链未闭合**:某个 feature 依赖了未 imply 的 feature | 低 | 7 种特征组合全部逐条构建验证;features 定义中有交叉引用的全部显式列出 |
|
||||
| **unused cfg warning**:某些 `#[cfg]` 标记导致编译 warning | 低 | 每个 commit 后检查编译器输出,发现后立即修复 |
|
||||
| **测试依赖循环**:dev-deps 与普通 deps 版本冲突 | 低 | dev-deps 的 tokio 版本与主依赖保持一致 (`version = "1"`,由 cargo 自动选择兼容版本) |
|
||||
| **memory 跨模块 imply 链** | **高** | memory 模块依赖 llm(conversation/vector_store)、tokio(knowledge)、time(types),imply 链必须完整传递 | `memory` feature 定义已包含 `llm`、`tokio`、`time`;验证矩阵覆盖 memory 独立编译 |
|
||||
| **跨模块引用未门控** | **高** | provider.rs 依赖 reqwest、cycle.rs 依赖 ToolRegistry、session.rs 依赖 engine,Step 1 必须添加 cfg 门控 | provider 模块 cfg 改为 provider-xxx 条件;cycle.rs 加 tools 门控;session.rs 加 engine 门控 |
|
||||
@@ -0,0 +1,424 @@
|
||||
# AG Core v0.3.2 Step 3(Phase 26–27)— 验证固化 + 文档更新实施方案
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
**背景**:agcore v0.3.2 Step 1(Phase 20–25)已交付 —— Cargo features 拆分基础设施改造全部完成,所有模块 `#[cfg]` 门控注入完毕,依赖全部 optional 化,`default = ["full"]` 保持向后兼容。当前项目处于已改造完成但未经 CI 固化、无文档指引的状态。
|
||||
|
||||
**当前状态快照**:
|
||||
- v0.3.0 → v0.3.2 Step 1:68 个源文件,23,765 行
|
||||
- 16 个 features(10 模块级 + 5 provider + 1 工具)+ 4 个快捷组合
|
||||
- `cargo test --features "full"`:427 passed
|
||||
- 7 种 feature 组合的 `cargo test --lib` 已全部通过(full / light / chat / chat+mcp / multi / multi+mcp / clippy),无需修复 cfg 遗漏
|
||||
- 但 `cargo test`(不带 `--lib`)会因 18 个 example 缺少 `required-features` 而失败
|
||||
- 项目无 CI/CD 配置
|
||||
|
||||
**目标**:通过 Step 3 将 features 体系验证固化到 CI 中,消除编译死代码警告,完成文档指引,使 v0.3.2 达到可发布状态。
|
||||
|
||||
**预期效果**:
|
||||
- 每次提交自动验证 7 种 feature 组合的编译 + 测试(零 warning)
|
||||
- 18 个 example 各自标注准确的 `required-features`,外树用户可一键运行
|
||||
- `cargo clippy --all-features -- -D warnings` 零告警
|
||||
- README 含完整 feature 表 + `Cargo.toml` 配置示例 + 升级指南,新用户 5 分钟内可选定组合
|
||||
- roadmap 同步更新
|
||||
|
||||
## 2. 需求分析
|
||||
|
||||
### 2.1 功能需求
|
||||
|
||||
| # | 需求 | 说明 | 对应工作 |
|
||||
|---|------|------|---------|
|
||||
| F1 | LlmProvider trait 不应依赖具体 provider feature | trait 自身不依赖 reqwest 或任何 provider 实现,纯 Mock 场景也应可用 | 工作 0 |
|
||||
| F2 | 每个 example 通过 `cargo run --example xxx` 正确编译 | 18 个 example 各有精确的最小 features 声明 | 工作 1 |
|
||||
| F3 | CI 自动验证 7 种特征组合的编译与测试 | push / PR 触发 | 工作 2 |
|
||||
| F4 | 所有 feature 组合下 0 个编译器警告 | 消除 dead_code 等警告 | 工作 3 |
|
||||
| F5 | README 提供完整的 feature 选择指引 | 表格 + 场景推荐 + Cargo.toml 示例 | 工作 5(Phase 27) |
|
||||
| F6 | example 文件顶部标注所需 features | 用户可一键复制运行命令 | 工作 5(Phase 27) |
|
||||
| F7 | roadmap 状态同步 | 总入口 + v0.3.2 子文档 | 工作 5(Phase 27) |
|
||||
|
||||
### 2.2 非功能需求
|
||||
|
||||
| # | 需求 | 指标 | 对应工作 |
|
||||
|---|------|------|---------|
|
||||
| N1 | 向后兼容 | `default = ["full"]` 行为与 v0.3.0 一致,427 tests passed | 全部 |
|
||||
| N2 | CI 时效 | 全矩阵 ≤ 10 分钟 | 工作 2 |
|
||||
| N3 | 最少侵入 | 不改动功能逻辑,仅 cfg / 配置 / 文档变更 | 全部 |
|
||||
|
||||
### 2.3 推演概要
|
||||
|
||||
**需求拆解**:从当前编译验证结果出发,发现三类待解决问题:
|
||||
1. **架构归属问题**——`LlmProvider` trait 定义在 provider 模块门控下,语义上应归属 `llm` 基础设施。同时其返回类型 `ProviderCapabilities` / `ProviderFeatures` 也必须一并移出
|
||||
2. **example 可编译性问题**——18 个 example 无 `required-features`,多组合下 `cargo test` 失败
|
||||
3. **代码质量问题**——`session.rs` 中 `bundle()` 方法在 `chat` 组合下 dead_code
|
||||
4. **工程缺失**——无 CI、README 无 features 说明、roadmap 未同步
|
||||
|
||||
**边界识别**:
|
||||
- 工作 0 仅移动 trait 及关联类型定义,不改变公开 API 签名
|
||||
- 工作 1 的 required-features 是最小集合,不添加冗余 feature
|
||||
- 工作 2 的 CI 仅验证编译 + 单元测试,不包含集成测试
|
||||
- 工作 5 的文档更新不涉及新的功能描述
|
||||
|
||||
**非目标声明**:
|
||||
- 不新增 feature 组合(保持现有的 4 个快捷组合不变)
|
||||
- 不重构 `ProviderConfig` / `ProviderType` / `create_provider()` 等 provider 模块创建逻辑(仅移出 trait 和元数据结构体)
|
||||
- 不集成集成测试(CI 仅验证 `--lib` 单元测试 + example 编译验证)
|
||||
- 不改动 `Cargo.toml` 的 `[dependencies]` 声明
|
||||
- 不改变 `default = ["full"]` 的默认行为
|
||||
|
||||
### 2.4 需求映射矩阵
|
||||
|
||||
| 功能需求 | 非功能需求 | 对应工作 | 验收项 |
|
||||
|---------|-----------|---------|-------|
|
||||
| F1 + N1 + N3 | — | 工作 0 | A1, A2, A9 |
|
||||
| F2 | — | 工作 1 | A10 |
|
||||
| F3 | N2 | 工作 2 | A5, A11 |
|
||||
| F4 | — | 工作 3 | A4 |
|
||||
| F5 | N1 | 工作 5 | A6 |
|
||||
| F6 | — | 工作 5 | A7 |
|
||||
| F7 | — | 工作 5 | A8 |
|
||||
| — | N3 | 全部 | A1 |
|
||||
| — | R2 缓解 | 全部 | A10 |
|
||||
|
||||
## 3. 方案设计
|
||||
|
||||
### 3.1 总体架构调整
|
||||
|
||||
```
|
||||
工作 0 — 架构修正(LlmProvider trait 及关联类型归属调整)
|
||||
|
||||
当前:
|
||||
src/llm/provider.rs #[cfg(any(feature = "provider-openai", ...))]
|
||||
├─ pub trait LlmProvider { ... }
|
||||
├─ pub struct ProviderCapabilities { ... }
|
||||
├─ pub struct ProviderFeatures { ... }
|
||||
└─ pub fn capabilities(&self) -> ProviderCapabilities;
|
||||
|
||||
目标:
|
||||
src/llm/provider_trait.rs #[cfg(feature = "llm")]
|
||||
├─ pub trait LlmProvider { ... }
|
||||
├─ pub struct ProviderCapabilities { ... }
|
||||
├─ pub struct ProviderFeatures { ... }
|
||||
└─ pub fn capabilities(&self) -> ProviderCapabilities;
|
||||
src/llm/provider.rs #[cfg(any(feature = "provider-openai", ...))]
|
||||
└─ 各 provider 实现 + ProviderConfig / ProviderType / create_provider()
|
||||
src/llm.rs
|
||||
└─ pub use provider_trait::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
|
||||
影响文件(6 个源文件 + 1 个新建):
|
||||
- src/llm.rs — 添加 mod provider_trait 声明 + pub use 重导出
|
||||
- src/llm/provider_trait.rs — 新文件,trait + 关联类型定义移入
|
||||
- src/llm/provider.rs — 移出 trait + 关联类型
|
||||
- src/llm/provider/openai.rs — use super:: → use crate::llm::
|
||||
- src/llm/provider/anthropic.rs — 同上
|
||||
- src/llm/provider/ollama.rs — 同上
|
||||
- src/llm/provider/openai_compat.rs — 同上(两个 import 合并)
|
||||
```
|
||||
|
||||
### 3.2 各子项设计方案
|
||||
|
||||
#### 工作 0 — LlmProvider trait 归属修正
|
||||
|
||||
**设计方案**:
|
||||
1. 在 `src/llm/` 下新建 `provider_trait.rs`,门控为 `#[cfg(feature = "llm")]`
|
||||
2. 从 `src/llm/provider.rs` 中提取以下定义到新文件:
|
||||
- `pub trait LlmProvider`(含关联方法 `chat` / `chat_stream` / `capabilities`)
|
||||
- `pub struct ProviderCapabilities`(含字段 `features: ProviderFeatures`)
|
||||
- `pub struct ProviderFeatures`(含 8 个功能开关字段)
|
||||
3. `src/llm.rs` 中声明 `mod provider_trait;`,并 `pub use provider_trait::{LlmProvider, ProviderCapabilities, ProviderFeatures};`
|
||||
4. `src/llm/provider.rs` 移除上述定义,保留 `ProviderConfig` / `ProviderType` / `create_provider()` 等运行时代码
|
||||
5. 更新所有 import 路径(详见下方清单)
|
||||
|
||||
**Import 路径调整清单**:
|
||||
|
||||
现有写法 → 目标写法
|
||||
|
||||
| # | 文件 | 现有 import | 目标 import |
|
||||
|---|------|------------|------------|
|
||||
| 1 | `agent/builder.rs:16` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
|
||||
| 2 | `agent/builder.rs:135`(test) | `use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
|
||||
| 3 | `agent/session.rs:35` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
|
||||
| 4 | `agent/runtime.rs:21` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
|
||||
| 5 | `llm/mock.rs:46` | `use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
|
||||
| 6 | `llm/cycle.rs:22` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
|
||||
| 7 | `llm/cycle.rs:940,1401`(test) | `use crate::llm::provider::{ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{ProviderCapabilities, ProviderFeatures};` |
|
||||
| 8 | `llm/provider/openai.rs:24` | `use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
|
||||
| 9 | `llm/provider/anthropic.rs:21` | `use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
|
||||
| 10 | `llm/provider/ollama.rs:14` | `use super::{LlmProvider, ProviderCapabilities};` | `use crate::llm::{LlmProvider, ProviderCapabilities};` |
|
||||
| 11 | `llm/provider/openai_compat.rs:18,21` | `use super::ProviderCapabilities;` + `use crate::llm::provider::LlmProvider;` | `use crate::llm::{LlmProvider, ProviderCapabilities};`(合并为一行) |
|
||||
| 12 | `llm/provider/registry.rs:6` | `use crate::llm::provider::{LlmProvider, ProviderConfig, ProviderType, create_provider};` | `use crate::llm::LlmProvider;` + `use crate::llm::provider::{ProviderConfig, ProviderType, create_provider};`(拆分) |
|
||||
|
||||
**示例文件 import 调整**:
|
||||
|
||||
| # | 文件 | 现有 import | 目标 import |
|
||||
|---|------|------------|------------|
|
||||
| 13 | `examples/end_to_end.rs:21` | `use agcore::llm::provider::{create_provider, LlmProvider, ProviderConfig, ProviderType};` | `use agcore::llm::LlmProvider;` + `use agcore::llm::provider::{create_provider, ProviderConfig, ProviderType};` |
|
||||
| 14 | `examples/context_slot_demo.rs:18` | `use agcore::llm::provider::LlmProvider;` | `use agcore::llm::LlmProvider;` |
|
||||
| 15 | `examples/streaming_events_demo.rs:19` | `use agcore::llm::provider::LlmProvider;` | `use agcore::llm::LlmProvider;` |
|
||||
| 16 | `examples/quick_start.rs:9` | `use agcore::llm::provider::LlmProvider;` | `use agcore::llm::LlmProvider;` |
|
||||
|
||||
**Breaking Change 声明**:
|
||||
|
||||
工作 0 是**非兼容性变更**,现有用户可能通过以下路径引用 `LlmProvider`:
|
||||
|
||||
| 旧路径(v0.3.0–v0.3.2 Step 1) | 新路径(v0.3.2 Step 3 后) |
|
||||
|--------------------------------|---------------------------|
|
||||
| `agcore::llm::provider::LlmProvider` | `agcore::llm::LlmProvider` |
|
||||
| `agcore::llm::provider::ProviderCapabilities` | `agcore::llm::ProviderCapabilities` |
|
||||
| `agcore::llm::provider::ProviderFeatures` | `agcore::llm::ProviderFeatures` |
|
||||
|
||||
**向后兼容方案(可选)**:在 `src/llm/provider.rs` 中添加 `#[cfg(feature = "llm")]` 门控的类型别名,让老路径仍然可用:
|
||||
```rust
|
||||
#[cfg(feature = "llm")]
|
||||
pub use super::provider_trait::LlmProvider;
|
||||
#[cfg(feature = "llm")]
|
||||
pub use super::provider_trait::ProviderCapabilities;
|
||||
#[cfg(feature = "llm")]
|
||||
pub use super::provider_trait::ProviderFeatures;
|
||||
```
|
||||
**推荐**:用户应迁移到新路径 `agcore::llm::LlmProvider`,`provider` 模块仅保留 `ProviderConfig` / `ProviderType` / `create_provider()` 等创建逻辑。
|
||||
|
||||
**验证**:
|
||||
- `cargo test --features "full"` 仍 427 passed
|
||||
- `cargo test --no-default-features --features "llm,llm-types" --lib` 编译通过(无需任何 provider feature)
|
||||
- 所有 12 个内部文件 + 4 个示例文件的 import 路径正确
|
||||
|
||||
#### 工作 1 — examples required-features 标注
|
||||
|
||||
**设计方案**:在 `Cargo.toml` 中为每个 example 添加 `[[example]]` + `required-features`,精确到最小 features 集合。
|
||||
|
||||
```
|
||||
上下文 slot 示例(context_slot_demo):
|
||||
工作 0 后仅需 ["agent"](修正前需 ["agent", "provider-openai"])
|
||||
因为 agent 的测试无需真实 provider,mock 即可
|
||||
|
||||
推理不变的 example(simple_visit):
|
||||
真正调用 LLM,需要 ["llm", "provider-openai", "tracing-init"]
|
||||
```
|
||||
|
||||
完整映射关系见实施计划 §4.2。
|
||||
|
||||
#### 工作 2 — CI 配置
|
||||
|
||||
**设计方案**:GitHub Actions 矩阵策略,7 个并行测试 job + 1 clippy + 1 format + 1 example 验证。
|
||||
|
||||
| Job | Command | 作用域 |
|
||||
|-----|---------|--------|
|
||||
| full | `RUSTFLAGS="-D warnings" cargo test --features "full" --lib` | 全量回归,零警告 |
|
||||
| light | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "light" --lib` | 生产常用,零警告 |
|
||||
| chat | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "chat,provider-openai" --lib` | 纯对话,零警告 |
|
||||
| chat+mcp | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "chat,provider-openai,tools-mcp" --lib` | 对话 + 工具,零警告 |
|
||||
| multi | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "multi,provider-openai" --lib` | 多 Agent,零警告 |
|
||||
| multi+mcp | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "multi,provider-openai,tools-mcp" --lib` | 多 Agent + 工具,零警告 |
|
||||
| clippy | `cargo clippy --all-features --lib -- -D warnings` | lint 检查 |
|
||||
| format | `cargo fmt --check`(stable toolchain) | 格式检查 |
|
||||
| examples | `cargo test --features "full"`(不加 `--lib`,编译并运行所有 example) | example 编译验证 |
|
||||
|
||||
**关键决策**:
|
||||
- 矩阵中统一使用 `--lib` 而非 `--all-targets`。理由:examples 的编译由 `required-features` 独立管理,若混入矩阵会因 feature 组合不匹配导致 example 编译失败,干扰模块测试结果验证。
|
||||
- 使用 `RUSTFLAGS="-D warnings"` 将警告升级为编译错误,确保 `F4(0 编译器警告)`被矩阵中所有 6 个测试 job 强制执行。
|
||||
- format job 使用 stable toolchain(`cargo fmt --check` 不需要 nightly)。
|
||||
- 独立 `examples` job 使用 `cargo test --features "full"`(不加 `--lib`),验证所有 example 在完整 features 下编译并运行通过。
|
||||
|
||||
#### 工作 3 — bundle() 死代码警告修复
|
||||
|
||||
**设计方案**:在 `src/agent/session.rs:154` 的 `pub(crate) fn bundle()` 方法上添加 `#[cfg(feature = "engine")]` 条件编译。
|
||||
|
||||
背景:`bundle()` 仅被 `engine/session_manager.rs:219` 调用,当启用 `chat` 组合(agent 但非 engine)时产生 dead_code 警告。
|
||||
|
||||
**验证**:`cargo test --no-default-features --features "chat,provider-openai" --lib` 0 warnings。
|
||||
|
||||
#### 工作 4(可选)— 编译时间基线
|
||||
|
||||
记录但不沉淀到代码或 CI 中,仅供性能参考:
|
||||
```bash
|
||||
time cargo build --features "full"
|
||||
time cargo build --no-default-features --features "light"
|
||||
```
|
||||
|
||||
注:此工作在 v0.3.2 发布前为手动执行,不纳入 CI 或验收标准。若后续版本需要编译时间回归检测,可将其提升为正式工作项。
|
||||
|
||||
#### 工作 5 — 文档更新
|
||||
|
||||
三处并行更新:
|
||||
|
||||
**README.md 新增 features 表格**:
|
||||
- 4 个快捷组合 + 推荐使用场景 + `Cargo.toml` 配置示例
|
||||
- 下游用户可快速选择并复制配置
|
||||
|
||||
**升级指南章节(README.md 新增)**:
|
||||
- 针对工作 0 的 Breaking Change 提供迁移说明
|
||||
- 列出旧路径 → 新路径的对照表
|
||||
- 提供向后兼容的重导出方案说明
|
||||
- 示例:`agcore::llm::provider::LlmProvider` → `agcore::llm::LlmProvider`
|
||||
- 提醒用户更新 `use` 声明
|
||||
|
||||
**example 文件顶部注释**:
|
||||
- 每个 example 第一行格式:`// Required features: cargo run --example xxx --features "..."`
|
||||
- 对应工作 1 的 `required-features` 声明
|
||||
|
||||
**roadmap 状态同步**:
|
||||
- `docs/roadmap.md`:补充 Phase 26-27 完成状态 + v0.3.2 链接
|
||||
- `docs/roadmap-v0.3.2.md`:Phase 26/27 状态从 ⏳ 改为 ✅ + 完成日期
|
||||
|
||||
### 3.3 ADR 记录
|
||||
|
||||
#### ADR-1:LlmProvider trait 及关联类型归属 llm 模块
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| 问题 | `LlmProvider` trait 定义在 provider 模块门控 `any(provider-openai, provider-anthropic, ...)` 下,纯 Mock 场景被迫引入至少一个 provider feature。其返回类型 `ProviderCapabilities` / `ProviderFeatures` 同样被困在 provider 门控中 |
|
||||
| 决策 | 将 trait 定义 + `ProviderCapabilities` / `ProviderFeatures` 一并提取到 `src/llm/provider_trait.rs`,归属 `#[cfg(feature = "llm")]` |
|
||||
| 备选方案 | 保持不动,在 mock provider 上添加 cfg 绕过 — 否决,因为 provider 模块整体门控错误 |
|
||||
| 理由 | trait 本身是个接口定义,不依赖 reqwest 或任何 provider 实现细节;`ProviderCapabilities` / `ProviderFeatures` 是 trait 方法的返回类型,必须与 trait 同门控 |
|
||||
| 影响 | 修改 6 个源文件 + 1 个新建文件 + 4 个示例文件(详见 §3.2 import 调整清单) |
|
||||
| 状态 | 已采纳 |
|
||||
|
||||
#### ADR-2:CI 使用 nightly toolchain
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| 问题 | 项目已使用 edition 2024,是否降级到 2021 以使用 stable Rust |
|
||||
| 决策 | 测试和 clippy 使用 nightly(edition 2024 目前要求 nightly);format 使用 stable |
|
||||
| 备选方案 | 降级 edition 到 2021 — 否决,已迁移至 edition 2024 且编译通过 |
|
||||
| 理由 | edtion 2024 是主动选择的方向,降级是倒退且涉及大量语法变更;`cargo fmt --check` 无需 nightly |
|
||||
| 影响 | CI 依赖 `actions-rust-lang/setup-rust-toolchain@v1`;format job 指定 `toolchain: stable` |
|
||||
| 状态 | 已采纳 |
|
||||
|
||||
#### ADR-3:CI 矩阵使用 `--lib` 而非 `--all-targets`
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| 问题 | `cargo test --all-targets` 会编译所有 example,与矩阵中自选的 feature 组合可能冲突 |
|
||||
| 决策 | 矩阵测试使用 `--lib`,examples 由独立 job(`cargo test --features "full"` 不加 `--lib`)验证 |
|
||||
| 备选方案 | 在矩阵中也传入 `--all-targets` — 否决,example 编译失败会干扰模块测试验证 |
|
||||
| 理由 | 分离关注点:矩阵验证模块级编译 + 零警告,独立 job 验证 example 编译 |
|
||||
| 状态 | 已采纳 |
|
||||
|
||||
## 4. 实施计划
|
||||
|
||||
### 4.1 任务拆解与优先级
|
||||
|
||||
| 优先级 | 工作 | 编号 | 规模 | 依赖 |
|
||||
|--------|------|------|------|------|
|
||||
| P0 | LlmProvider trait 归属修正 | 工作 0 | ~30 行(含关联类型移动 + import 调整) | 无 |
|
||||
| P0 | bundle() 门控修复 | 工作 3 | 1 行 | 无 |
|
||||
| P0 | examples required-features | 工作 1 | ~50 行 | 工作 0(context_slot_demo / quick_start 最小 features 从 agent+provider-openai 降为 agent) |
|
||||
| P0 | CI 配置 | 工作 2 | ~80 行 | 无 |
|
||||
| P1 | 文档更新 | 工作 5 | ~150 行 | 全部 |
|
||||
| P2 | 编译时间基线 | 工作 4 | 手动 | 全部 |
|
||||
|
||||
### 4.2 各 example 的 required-features 清单
|
||||
|
||||
| example 文件名 | required-features | 运行环境备注 |
|
||||
|---------------|-------------------|------------|
|
||||
| `prompt_composer` | `["prompt"]` | — |
|
||||
| `custom_tool` | `["tools"]` | — |
|
||||
| `conversation_memory_demo` | `["memory"]` | — |
|
||||
| `knowledge_graph_demo` | `["memory"]` | — |
|
||||
| `knowledge_search_demo` | `["memory"]` | — |
|
||||
| `agent_session_demo` | `["agent"]` | — |
|
||||
| `task_agent_demo` | `["agent"]` | — |
|
||||
| `context_slot_demo` | `["agent"]` | 工作 0 后无需 provider |
|
||||
| `quick_start` | `["agent"]` | 工作 0 后无需 provider |
|
||||
| `simple_visit` | `["llm", "provider-openai", "tracing-init"]` | 需要 API key |
|
||||
| `streaming_events_demo` | `["llm", "provider-openai"]` | 需要 API key |
|
||||
| `agent_switch_demo` | `["engine"]` | — |
|
||||
| `bridge_keys_demo` | `["engine"]` | — |
|
||||
| `dispatch_stream_demo` | `["engine"]` | — |
|
||||
| `engine_demo` | `["engine"]` | — |
|
||||
| `sub_agent_dispatch_demo` | `["engine"]` | — |
|
||||
| `document_demo` | `["memory", "tracing-init"]` | 需 sqlite 依赖(memory-sqlite feature 可选) |
|
||||
| `end_to_end` | `["agent", "memory-sqlite", "provider-openai"]` | 需要 API key + sqlite 依赖 |
|
||||
|
||||
**验证策略**:每个 example 除 `--lib` 验证外,还需单独运行以下命令确认 required-features 精确性:
|
||||
```bash
|
||||
cargo test --no-default-features --features "<features>" --example <name>
|
||||
```
|
||||
|
||||
### 4.3 Commit 策略
|
||||
|
||||
每个工作独立 commit,按依赖顺序排列:
|
||||
|
||||
| 顺序 | Scope | Type | 描述 | 依赖 |
|
||||
|------|-------|------|------|------|
|
||||
| 1 | `core` | `refactor` | 将 LlmProvider trait 及关联类型移出 provider 模块归属 llm | 无 |
|
||||
| 2 | `agent` | `fix` | 为 session.rs bundle() 方法添加 engine feature 门控 | 无 |
|
||||
| 3 | `examples` | `chore` | 为 18 个 example 添加 required-features 声明 | 工作 1(context_slot 等受益于工作 0 的轻量 features) |
|
||||
| 4 | `ci` | `chore` | 创建 CI 测试矩阵配置 | 无 |
|
||||
| 5 | `docs` | `docs` | 更新 README feature 表 + 升级指南 + 示例注释 + roadmap 状态 | 全部 |
|
||||
|
||||
### 4.4 参考实现:CI 配置
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
on: [push, pull_request]
|
||||
env:
|
||||
RUSTFLAGS: "-D warnings"
|
||||
jobs:
|
||||
test-matrix:
|
||||
strategy:
|
||||
matrix:
|
||||
features:
|
||||
- "full"
|
||||
- "light"
|
||||
- "chat,provider-openai"
|
||||
- "chat,provider-openai,tools-mcp"
|
||||
- "multi,provider-openai"
|
||||
- "multi,provider-openai,tools-mcp"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
- run: cargo test --no-default-features --features "${{ matrix.features }}" --lib
|
||||
clippy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
- run: cargo clippy --all-features --lib -- -D warnings
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- run: cargo fmt --check
|
||||
examples:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
- run: cargo test --features "full"
|
||||
```
|
||||
|
||||
## 5. 风险评估
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解措施 | 对应验收项 |
|
||||
|------|------|------|---------|-----------|
|
||||
| 工作 0 重构后公开 API 被意外改变 | 低 | 高 | 重构前后分别跑 `cargo test --features "full"` 确认测试数一致(427),且 `cargo doc` 无差异 | A1 |
|
||||
| required-features 标注不准确导致 example 运行时缺少 trait 实现 | 低 | 中 | 每个 example 在 `--lib` 验证后,再单独跑 `cargo test --example xxx --no-default-features --features "对应features"` 确认 | A10 |
|
||||
| CI 首次在 GitHub runner 上因环境差异(OS、Toolchain 版本)失败 | 中 | 低 | 非阻塞问题,修复后重新推送即可;本地已在 macOS 验证 7 种组合 | A5 |
|
||||
| edition 2024 在 GitHub runner 的特定 nightly 版本上不稳定 | 低 | 中 | 可在 `Cargo.toml` 中加 `rust-version = "1.85"` 下限约束 | A5 |
|
||||
| bundle() 门控修复后 engine 组合下方法不可见 | 极低 | 中 | `cargo test --features "engine,provider-openai"` 编译通过即可验证 | A4 |
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
| # | 验收项 | 验证方式 | 对应工作 |
|
||||
|---|--------|---------|---------|
|
||||
| A1 | `cargo test --features "full"` 仍 427 passed | `cargo test -F full -q` | 工作 0 |
|
||||
| A2 | `cargo test --no-default-features --features "llm,llm-types" --lib` 编译通过 | 无需任何 provider feature 即完成编译 | 工作 0 |
|
||||
| A3 | 7 种组合下 `cargo test --no-default-features --features "组合" --lib -q` 全部通过 | 逐一验证 | 工作 1(example features 正确不会干扰 --lib) |
|
||||
| A4 | 6 个矩阵组合(full / light / chat / chat+mcp / multi / multi+mcp)下 `RUSTFLAGS="-D warnings" cargo test --lib` 0 warnings | 所有组合均无编译器警告 | 工作 3 |
|
||||
| A5 | `.github/workflows/ci.yml` 文件存在,结构包含 9 个 job(6 测试 + 1 clippy + 1 format + 1 examples) | 文件检查 | 工作 2 |
|
||||
| A6 | README.md 包含 features 表格 + 使用场景 + Cargo.toml 配置示例 + 升级指南 | review 通过 | 工作 5 |
|
||||
| A7 | 所有 18 个 example 文件首行含 `// Required features: cargo run --example xxx --features "..."` 注释 | review 通过 | 工作 5 |
|
||||
| A8 | `docs/roadmap.md` 和 `docs/roadmap-v0.3.2.md` 中 Phase 26/27 状态标记为 ✅ | review 通过 | 工作 5 |
|
||||
| A9 | 工作 0 后 `cargo doc --no-deps --features "llm,llm-types"` 可生成 `LlmProvider` / `ProviderCapabilities` / `ProviderFeatures` 的 API 文档(无需任何 provider feature) | review 通过 | 工作 0 |
|
||||
| A10 | 每个 example 单独验证:`cargo test --no-default-features --features "<对应features>" --example <name>` 编译通过 | 逐一验证 18 个 example | 工作 1 |
|
||||
| A11 | 全矩阵 CI(6 测试 + clippy + format + examples)从 checkout 到完成 ≤ 10 分钟 | 实测计时 | 工作 2 |
|
||||
@@ -0,0 +1,790 @@
|
||||
# Phase 28-30 — OpenAI Response API Provider 实施方案
|
||||
|
||||
> **版本**:v1 | **作者**:Writer Agent | **日期**:2026-07-20
|
||||
>
|
||||
> **阅读前提**:本文档假设读者已熟悉现有的 Provider 实现模式(`AnthropicProvider` 独立实现方式)、IR 类型系统(`MessageRequest` / `MessageResponse` / `ContentBlock` / `StreamEvent` / `LlmProvider trait`)以及 Cargo features 门控机制。
|
||||
>
|
||||
> **前置条件**:v0.3.2(Phase 20-27)已发布,Cargo features 拆分完成,CI 矩阵 6 种组合全部通过。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
OpenAI 于 2025 年下半年发布了 **Response API**(`POST /responses`),作为 Chat Completions API(`POST /chat/completions`)的下一代接口。Response API 不仅提供了更简洁的请求/响应结构,还将 `web_search`、`file_search`、`computer_use` 等内置工具提升为一等公民,并引入了 `previous_response_id` 多轮续写等新机制。
|
||||
|
||||
agcore 当前通过 `GenericOpenaiProvider` 实现了 OpenAI Chat Completions 协议。`ProviderType::OpenaiResponse` 枚举项已在 `src/llm/provider.rs` 中定义,但工厂函数返回 `Err("Phase 1 暂不实现;请使用 OpenaiChat")`。
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
- 实现独立的 `OpenaiResponseProvider`(不套用 `GenericOpenaiProvider`,参考 `AnthropicProvider` 模式)
|
||||
- 覆盖 Response API 的核心能力:文本对话、流式输出、Vision 输入、工具调用(function calling)
|
||||
- 新增独立 feature `provider-openai-response`,加入 `full` 快捷组合
|
||||
- 内置工具(`web_search` / `file_search` / `computer_use`)通过 `MessageRequest.extra` 逃生舱传递
|
||||
- 多轮接续第一版走全量消息历史模式
|
||||
|
||||
### 1.3 范围
|
||||
|
||||
| 维度 | 包含 | 不包含 |
|
||||
|------|------|--------|
|
||||
| 协议端点 | `POST /responses` | `/responses/{id}/input_items` 等管理端点 |
|
||||
| 输入模式 | 全量消息历史 + `previous_response_id` | 增量续写优化 |
|
||||
| 内置工具 | 通过 `extra` 逃生舱透传 | 原生 ToolDef 结构改动 |
|
||||
| 流式 | SSE 语义事件 → `StreamEvent` | — |
|
||||
| 结构化输出 | `text.format` | 暂不专项封装 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 需求分析
|
||||
|
||||
### 2.1 功能需求
|
||||
|
||||
| # | 需求 | 优先级 | 说明 |
|
||||
|---|------|--------|------|
|
||||
| F1 | 文本对话(非流式 + 流式) | P0 | 最基础的对话能力 |
|
||||
| F2 | Vision 图片输入 | P0 | `UserImage` → `input_image` |
|
||||
| F3 | Function Calling 工具调用 | P0 | `ToolDef` → `{type: "function", ...}` |
|
||||
| F4 | 多轮接续 | P1 | 全量消息历史模式 |
|
||||
| F5 | System 消息处理 | P0 | 多个 System 消息拼接到 `instructions` |
|
||||
| F6 | 流式 SSE 事件映射 | P0 | 按 Response API SSE 事件序列映射 |
|
||||
| F7 | 内置工具逃生舱 | P2 | `extra` 字段透传 `web_search` / `file_search` |
|
||||
| F8 | 结构化输出逃生舱 | P2 | `extra` 字段透传 `text.format` |
|
||||
|
||||
### 2.2 非功能需求
|
||||
|
||||
| # | 需求 | 指标 |
|
||||
|---|------|------|
|
||||
| N1 | 编译隔离 | 新增 feature 不增加 `light` / `chat` 组合的依赖 |
|
||||
| N2 | 测试覆盖 | wiremock 覆盖非流式 + 流式 + 错误路径 |
|
||||
| N3 | 错误映射 | 复用 `GenericOpenaiProvider` 的错误映射逻辑 |
|
||||
| N4 | Clippy 合规 | `cargo clippy --all-features --lib -- -D warnings` 通过 |
|
||||
|
||||
### 2.3 与 Chat Completions 的差异回顾
|
||||
|
||||
| 维度 | Chat Completions | Response API |
|
||||
|------|-----------------|--------------|
|
||||
| 端点 | `POST /chat/completions` | `POST /responses` |
|
||||
| 输入 | `messages: [{role, content}]` | `input: string \| items[]` + 顶层 `instructions` |
|
||||
| 输出 | `choices[n].message` | `output: []` 异构 items 数组 |
|
||||
| 内置工具 | 无(仅 function calling) | `web_search` / `file_search` / `computer_use` 一等公民 |
|
||||
| 多轮接续 | 调用方拼接 messages | `previous_response_id` 参数 或 全量回传 |
|
||||
| 流式 | SSE chunk `choices[n].delta` | SSE 语义事件:`response.text.delta` / `response.output_item.added` 等 |
|
||||
| 结构化输出 | `response_format` | `text.format` |
|
||||
| 认证 | `Authorization: Bearer` | 相同 |
|
||||
| 错误结构 | 相同(401/429/500) | 相同 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 方案设计
|
||||
|
||||
### 3.1 设计决策
|
||||
|
||||
| # | 决策 | 选项 | 选择 | 理由 |
|
||||
|---|------|------|------|------|
|
||||
| D1 | 实现方式 | 独立 Provider vs 套用 GenericOpenaiProvider | **独立 Provider** | Response API 请求/响应结构与 Chat Completions 差异过大,序列化/反序列化无共用价值 |
|
||||
| D2 | Feature 粒度 | 合并到 `provider-openai` vs 独立 | **独立 feature** | 与 `AnthropicProvider` 对齐,避免 `full` 组合膨胀 |
|
||||
| D3 | 加入快捷组合 | 加入 `full` 但不加入 `light` | **`full` 包含** | Response API 属于高级能力,`light` 保持轻量 |
|
||||
| D4 | 多轮方案 | 全量历史 vs 增量 | **全量历史(模式 A)** | 功能正确,无需改动 `LlmCycle` |
|
||||
| D5 | 内置工具支持 | 改 ToolDef vs extra 逃生舱 | **extra 逃生舱** | 不改已有 IR 类型,最小侵入 |
|
||||
|
||||
### 3.2 Feature 定义
|
||||
|
||||
```toml
|
||||
provider-openai-response = ["llm", "reqwest", "bytes", "futures-util"]
|
||||
```
|
||||
|
||||
与 `provider-openai` / `provider-anthropic` 的依赖集合一致——`llm` 已包含 `tokio` / `async-stream` / `futures-core` / `futures-util` / `tokio-stream`,此处补充 `reqwest`(HTTP 客户端)和 `bytes`(流式 buffer 操作)。
|
||||
|
||||
`full` 快捷组合追加 `"provider-openai-response"`。
|
||||
|
||||
### 3.3 新增文件
|
||||
|
||||
所有实现集中在单一文件:
|
||||
|
||||
```
|
||||
src/llm/provider/openai_response.rs ← 全部实现(Wire 类型 + Provider 结构体 + 请求转换 + 响应转换 + 流式处理 + 测试)
|
||||
```
|
||||
|
||||
不在 `provider/` 下创建子目录。模块声明在 `src/llm.rs`,在现有 Provider features cfg 条件中追加 `feature = "provider-openai-response"`:
|
||||
|
||||
```rust
|
||||
#[cfg(any(
|
||||
feature = "provider-openai",
|
||||
feature = "provider-anthropic",
|
||||
feature = "provider-deepseek",
|
||||
feature = "provider-qwen",
|
||||
feature = "provider-ollama",
|
||||
feature = "provider-openai-response",
|
||||
))]
|
||||
pub mod provider;
|
||||
```
|
||||
|
||||
### 3.4 架构概览
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ OpenaiResponseProvider │
|
||||
│ ┌──────────────────────────────────────────┐ │
|
||||
│ │ convert_request() │ │
|
||||
│ │ MessageRequest → OpenaiResponseRequest │ │
|
||||
│ └──────────────────┬───────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────▼───────────────────────┐ │
|
||||
│ │ HTTP POST /responses │ │
|
||||
│ │ (reqwest Client) │ │
|
||||
│ └──────────────────┬───────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────▼───────────────────────┐ │
|
||||
│ │ convert_response() │ │
|
||||
│ │ OpenaiResponseBody → MessageResponse │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────────────────────┐ │
|
||||
│ │ ResponseSseEventStream │ │
|
||||
│ │ SSE bytes → StreamEvent 流 │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.5 Wire 类型设计
|
||||
|
||||
#### 请求体类型
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct OpenaiResponseRequest {
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instructions: Option<String>,
|
||||
pub input: Vec<ResponseInputItem>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<ResponseTool>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_output_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub previous_response_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub store: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub truncation: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning: Option<Value>,
|
||||
}
|
||||
```
|
||||
|
||||
#### Input Item 枚举
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum ResponseInputItem {
|
||||
Message {
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
item_type: Option<String>, // 可选,固定为 "message"(assistant 回传时使用)
|
||||
role: String,
|
||||
content: Vec<ResponseInputContent>,
|
||||
},
|
||||
FunctionCall {
|
||||
#[serde(rename = "type")]
|
||||
item_type: String, // 固定为 "function_call"
|
||||
call_id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
status: Option<String>,
|
||||
},
|
||||
FunctionCallOutput {
|
||||
#[serde(rename = "type")]
|
||||
item_type: String, // 固定为 "function_call_output"
|
||||
call_id: String,
|
||||
output: String,
|
||||
},
|
||||
}
|
||||
|
||||
> **关于 `ResponseInputItem` 与 `ResponseOutputItem` 的职责划分**:
|
||||
>
|
||||
> - **`ResponseInputItem`**(`#[serde(untagged)]`):仅用于**请求序列化**(`convert_request`),由代码控制枚举变体的生成,永远不会遇到未知的 `item_type`。因此 untagged 模式是安全的,无需 fallback。
|
||||
> - **`ResponseOutputItem`**(非 untagged,`item_type: String` 为必填字段):用于**响应反序列化**(`convert_response`),来自 API 响应。未知的 `item_type` 已通过 §3.7 的 `ContentBlock::Extension` fallback 处理,不会因新增 item 类型而触发 serde 反序列化失败。
|
||||
|
||||
/// 消息内容块(嵌套在 Message 变体的 content 数组中)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum ResponseInputContent {
|
||||
InputText {
|
||||
text: String,
|
||||
},
|
||||
InputImage {
|
||||
image_url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<String>,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
> **补充说明**:Response API 的 `input` 字段还支持简化格式——`input: "Hello"`(单字符串)或 `input: ["Hello", "Hi"]`(字符串数组),但这些格式只能表达纯文本消息。为支持多模态内容(文本 + 图片)和工具调用,本实现使用完整的消息对象数组格式。
|
||||
|
||||
#### Tool 类型
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum ResponseTool {
|
||||
Function {
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: Value,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应体类型
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct OpenaiResponseBody {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub output: Vec<ResponseOutputItem>,
|
||||
pub usage: Usage,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct ResponseOutputItem {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub item_type: String,
|
||||
pub status: Option<String>,
|
||||
pub role: Option<String>,
|
||||
pub content: Option<Vec<ResponseContentPart>>,
|
||||
pub call_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub arguments: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct ResponseContentPart {
|
||||
#[serde(rename = "type")]
|
||||
pub part_type: String,
|
||||
pub text: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
#### SSE 事件类型
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum ResponseSseEvent {
|
||||
#[serde(rename = "response.created")]
|
||||
ResponseCreated { response: ResponseSseMeta },
|
||||
#[serde(rename = "response.completed")]
|
||||
ResponseCompleted { response: ResponseSseMeta },
|
||||
#[serde(rename = "response.failed")]
|
||||
ResponseFailed { error: Option<serde_json::Value> },
|
||||
#[serde(rename = "response.output_item.added")]
|
||||
ResponseOutputItemAdded { item: ResponseOutputItem },
|
||||
#[serde(rename = "response.output_item.done")]
|
||||
ResponseOutputItemDone { item: ResponseOutputItem },
|
||||
#[serde(rename = "response.output_text.delta")]
|
||||
ResponseOutputTextDelta { delta: String, item_id: String },
|
||||
#[serde(rename = "response.output_text.done")]
|
||||
ResponseOutputTextDone { text: String, item_id: String },
|
||||
#[serde(rename = "response.refusal.delta")]
|
||||
ResponseRefusalDelta { delta: String, item_id: String },
|
||||
#[serde(rename = "response.refusal.done")]
|
||||
ResponseRefusalDone { refusal: String, item_id: String },
|
||||
#[serde(rename = "response.function_call_arguments.delta")]
|
||||
ResponseFunctionCallArgumentsDelta { delta: String, item_id: String },
|
||||
#[serde(rename = "response.function_call_arguments.done")]
|
||||
ResponseFunctionCallArgumentsDone { arguments: String, item_id: String },
|
||||
#[serde(rename = "error")]
|
||||
Error { code: String, message: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct ResponseSseMeta {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub status: String,
|
||||
}
|
||||
```
|
||||
|
||||
### 3.6 请求转换(convert_request)
|
||||
|
||||
#### 消息类型映射
|
||||
|
||||
| 输入场景 | Message 类型 | → Response API input item |
|
||||
|---------|-------------|--------------------------|
|
||||
| 文本 User | `Message::User { content: [Text] }` | `{role: "user", content: [{type: "input_text", text}]}` |
|
||||
| Vision | `Message::UserImage { data, mime_type, detail }` | `{role: "user", content: [{type: "input_image", image_url: "data:{mime};base64,{data}", detail}]}` |
|
||||
| User 多模态 | `Message::User { content: [Text, Image, ...] }` | `{role: "user", content: [{type: "input_text", text}, {type: "input_image", image_url, detail}]}` |
|
||||
| Assistant 文本 | `Message::Assistant { content: [Text] }` | User 侧:`{role: "assistant", content: [{type: "output_text", text}]}`(无 `type` 字段);回传时: `{type: "message", role: "assistant", content: [{type: "output_text", text}]}`(有 `type: "message"`) |
|
||||
| Assistant 工具调用 | `Message::Assistant { content: [ToolUse] }` | `FunctionCall { call_id, name, arguments }` |
|
||||
| Assistant 文本+工具 | `Message::Assistant { content: [Text, ToolUse, ...] }` | 一个 `Message(assistant)` + 一个或多个 `FunctionCall` 项 |
|
||||
| 工具结果 | `Message::ToolResult { tool_call_id, content, is_error }` | `FunctionCallOutput { call_id, output: content }` |
|
||||
| System | `Message::System { content }` | 拼接到顶层 `instructions` 字段(非 input) |
|
||||
|
||||
#### 字段映射
|
||||
|
||||
| MessageRequest 字段 | → Response API 字段 |
|
||||
|---------------------|---------------------|
|
||||
| `model` | `model` |
|
||||
| `max_tokens` | `max_output_tokens` |
|
||||
| `temperature` | `temperature` |
|
||||
| `top_p` | `top_p` |
|
||||
| `stop_sequences` | `stop` |
|
||||
| `stream` | `stream` |
|
||||
| `tools` (ToolDef) | `tools` = `[{type: "function", name, description, parameters}]` |
|
||||
| `tool_choice` | `tool_choice` |
|
||||
|
||||
#### extra 字段映射
|
||||
|
||||
| `MessageRequest.extra` key | → Response API 字段 |
|
||||
|---------------------------|---------------------|
|
||||
| `previous_response_id` | `previous_response_id` |
|
||||
| `store` | `store` |
|
||||
| `metadata` | `metadata` |
|
||||
| `truncation` | `truncation` |
|
||||
| `reasoning.effort` | `reasoning: {effort: ...}` |
|
||||
| 内置工具(`web_search` / `file_search` 等) | 追加到 `tools` 数组 |
|
||||
|
||||
> **备注**:当前仅支持 `reasoning.effort` 子字段(值为 `low`/`medium`/`high`),其他子字段(如 `reasoning.summary`)将在后续版本支持。
|
||||
|
||||
### 3.7 响应转换(convert_response)
|
||||
|
||||
| Response API output item | → MessageResponse 中的表示 |
|
||||
|-------------------------|------------------------------|
|
||||
| `{type: "message", role: "assistant", content: [{type: "output_text", text}]}` | `Message::Assistant { content: [ContentBlock::Text { text }] }` |
|
||||
| `{type: "function_call", name, arguments, call_id}` | `ContentBlock::ToolUse { id: call_id, name, input: arguments }` |
|
||||
| `{type: "web_search_call", ...}` | `ContentBlock::Extension { kind: "web_search_call", data: ... }` |
|
||||
| `{type: "reasoning", ...}` | `ContentBlock::Extension { kind: "reasoning", data: ... }` |
|
||||
| `{type: "file_search_call", ...}` | `ContentBlock::Extension { kind: "file_search_call", data: ... }` |
|
||||
|
||||
**status → StopReason 映射**:
|
||||
- `completed` → `StopReason::Stop`
|
||||
- `incomplete` → `StopReason::Length`
|
||||
- `failed` → `StopReason::Other`
|
||||
|
||||
当 `response.output` 为空数组时,返回 `LlmError::Request { status: 200, body: "empty output" }`,表示响应格式异常。
|
||||
|
||||
对于未知的 `item_type`(非 `message`/`function_call`/`web_search_call`/`file_search_call`/`reasoning`),转换为 `ContentBlock::Extension { kind: item_type, data: serde_json::to_value(item)? }` 以保持前向兼容。
|
||||
|
||||
### 3.8 流式 SSE 事件映射
|
||||
|
||||
| Response API SSE event | → StreamEvent |
|
||||
|------------------------|---------------|
|
||||
| `response.created` | `MessageStart { id, model }` |
|
||||
| `response.output_item.added` (type: message) | `ContentBlockStart { index, block_type: Text }` |
|
||||
| `response.output_text.delta` | `TextDelta { text }` |
|
||||
| `response.output_text.done` | `ContentBlockEnd { index }` |
|
||||
| `response.refusal.delta` | `RefusalDelta { text }` |
|
||||
| `response.refusal.done` | `ContentBlockEnd { index }` |
|
||||
| `response.function_call_arguments.delta` | `ToolCallArgumentsDelta { index, arguments }` |
|
||||
| `response.function_call_arguments.done` | `ToolCallEnd { index }` |
|
||||
| `response.completed` | `MessageComplete { full_response }` |
|
||||
| `response.failed` | `Error { message }` |
|
||||
|
||||
### 3.9 流式 SSE 状态机
|
||||
|
||||
`ResponseSseEventStream` 维护以下状态:
|
||||
|
||||
```
|
||||
字段:
|
||||
- byte_stream: reqwest 的 bytes_stream
|
||||
- buffer: Vec<u8>(SSE 行缓冲)
|
||||
- partial: PartialMessageResponse(累积响应状态)
|
||||
- block_index: u32(输出 block 序号计数器)
|
||||
- saw_terminal: bool(是否已见到 response.completed / response.failed)
|
||||
|
||||
流程:
|
||||
line 级解析 → event: + data: 配对
|
||||
→ 反序列化 ResponseSseEvent
|
||||
→ try_into_stream_event() 映射为 StreamEvent
|
||||
→ StreamEvent::apply_to(&mut partial)
|
||||
→ yield StreamEvent
|
||||
response.completed → partial.finalize() → yield MessageComplete
|
||||
response.failed → yield Error
|
||||
```
|
||||
|
||||
### 3.10 错误映射
|
||||
|
||||
复用 `GenericOpenaiProvider` 的 `handle_error_response()` 逻辑:
|
||||
|
||||
| HTTP 状态码 | → LlmError |
|
||||
|------------|------------|
|
||||
| 401 | `LlmError::Authentication(body)` |
|
||||
| 429 | `LlmError::RateLimit { retry_after }` |
|
||||
| 5xx | `LlmError::Request { status, body }` |
|
||||
| 400 + `context_length_exceeded` | `LlmError::ContextLength` |
|
||||
|
||||
### 3.11 Provider 结构体
|
||||
|
||||
```rust
|
||||
pub(crate) struct OpenaiResponseProvider {
|
||||
http_client: Client,
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
timeout_secs: u64,
|
||||
}
|
||||
```
|
||||
|
||||
#### 工厂方法
|
||||
|
||||
```rust
|
||||
impl OpenaiResponseProvider {
|
||||
pub(crate) fn from_parts(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
http_client: Client,
|
||||
timeout_secs: u64,
|
||||
) -> Self {
|
||||
Self { http_client, base_url, api_key, model, timeout_secs }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.12 LlmProvider trait 实现
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
impl LlmProvider for OpenaiResponseProvider {
|
||||
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
|
||||
self.chat_blocking(request).await
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
|
||||
self.chat_stream_inner(request).await
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> ProviderCapabilities { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 3.13 Capabilities
|
||||
|
||||
```rust
|
||||
ProviderCapabilities {
|
||||
provider_name: "openai-response",
|
||||
supported_models: Some(vec![model]),
|
||||
features: ProviderFeatures {
|
||||
streaming: true,
|
||||
thinking: true, // o-series reasoning
|
||||
vision: true, // image input
|
||||
audio_input: false,
|
||||
tool_use: true,
|
||||
parallel_tool_calls: true,
|
||||
system_prompt_in_messages: false,
|
||||
max_context_window: 200_000,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 3.14 工厂函数注册
|
||||
|
||||
```rust
|
||||
ProviderType::OpenaiResponse => {
|
||||
let client = build_client_with_timeout(config.timeout_secs)?;
|
||||
Ok(Box::new(openai_response::OpenaiResponseProvider::from_parts(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
client,
|
||||
config.timeout_secs,
|
||||
)))
|
||||
}
|
||||
```
|
||||
|
||||
### 3.15 多轮接续方案
|
||||
|
||||
第一版走**全量消息历史模式(模式 A)**:
|
||||
|
||||
1. `convert_request()` 把 `MessageRequest.messages` 全部转换为 `input` items
|
||||
2. System 消息拼接到 `instructions`
|
||||
3. User / Assistant / ToolResult 消息转换为对应的 input items
|
||||
4. 如果 `extra` 中有 `previous_response_id`,也传入请求体
|
||||
|
||||
此模式与 `LlmCycle::submit_with_tools()` 完全兼容——`LlmCycle` 在每次提交时都会填充完整的历史 messages,`OpenaiResponseProvider` 只是把这些 messages 全部序列化为 Response API 格式。无需改动 `LlmCycle`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 实施计划
|
||||
|
||||
实施拆分为 3 个 Phase,9 个 Step。
|
||||
|
||||
### Phase 28:Feature gate + Wire 类型 + Provider 骨架(~140 行)
|
||||
|
||||
#### Step 28.1:Cargo.toml feature 定义
|
||||
**文件操作**:修改 `Cargo.toml`
|
||||
|
||||
```toml
|
||||
# 在 [features] 的 Provider features 区域追加
|
||||
provider-openai-response = ["llm", "reqwest", "bytes", "futures-util"]
|
||||
|
||||
# 在 full 快捷组合中追加
|
||||
full = [
|
||||
"...",
|
||||
"provider-openai-response",
|
||||
]
|
||||
```
|
||||
|
||||
**验证**:`cargo build --features "provider-openai-response"` 编译通过
|
||||
|
||||
#### Step 28.2:Wire 类型定义
|
||||
**文件操作**:新建 `src/llm/provider/openai_response.rs`
|
||||
|
||||
定义 §3.5 中的所有 Wire 类型:
|
||||
- `OpenaiResponseRequest`
|
||||
- `ResponseInputItem`(untagged 枚举:Message / FunctionCall / FunctionCallOutput)
|
||||
- `ResponseInputContent`(tagged 枚举:InputText / InputImage)
|
||||
- `ResponseTool`
|
||||
- `OpenaiResponseBody`
|
||||
- `ResponseOutputItem`
|
||||
- `ResponseContentPart`
|
||||
- `ResponseSseEvent`(完整时序事件枚举)
|
||||
- `ResponseSseMeta`
|
||||
|
||||
**无逻辑代码**,只有 `#[derive(Debug, Clone, Serialize, Deserialize)]` 的结构体和枚举。
|
||||
|
||||
**验证**:`cargo build --features "provider-openai-response"` 编译通过
|
||||
|
||||
#### Step 28.3:Provider 结构体 + from_parts
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||
|
||||
- `OpenaiResponseProvider` 结构体
|
||||
- `from_parts()` 工厂方法
|
||||
- 基础 HTTP 工具函数(`build_request_builder`、`handle_error_response`、`map_reqwest_error`)
|
||||
|
||||
**验证**:`cargo build --features "provider-openai-response"` 编译通过
|
||||
|
||||
#### Step 28.4:Factory 注册 + 模块门控
|
||||
**文件操作**:
|
||||
1. 修改 `src/llm.rs` — 在 cfg 条件中追加 `feature = "provider-openai-response"`
|
||||
2. 修改 `src/llm/provider.rs` — 注册 factory
|
||||
|
||||
在 `src/llm.rs` 中修改现有 Provider features cfg 条件:
|
||||
|
||||
```rust
|
||||
#[cfg(any(
|
||||
feature = "provider-openai",
|
||||
feature = "provider-anthropic",
|
||||
feature = "provider-deepseek",
|
||||
feature = "provider-qwen",
|
||||
feature = "provider-ollama",
|
||||
feature = "provider-openai-response",
|
||||
))]
|
||||
pub mod provider;
|
||||
```
|
||||
|
||||
以及在 `src/llm/provider.rs` 的 `create_provider()` match 中替换当前 `Err` 为真实构造。
|
||||
|
||||
**验证**:
|
||||
- `cargo build --features "provider-openai-response"` 编译通过
|
||||
- `cargo build --features "full"` 编译通过
|
||||
|
||||
---
|
||||
|
||||
### Phase 29:核心 Provider 实现(~680 行)
|
||||
|
||||
#### Step 29.1:convert_request(~200 行)
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||
|
||||
实现 `OpenaiResponseProvider::convert_request(&self, request: MessageRequest) -> Result<OpenaiResponseRequest, LlmError>`。
|
||||
|
||||
处理逻辑:
|
||||
1. 遍历 `request.messages`,按 §3.6 消息类型映射表转换
|
||||
2. Assistant 消息回传时设置 `item_type: Some("message".to_string())`,使序列化结果为 `{type: "message", role: "assistant", content: [...]}`;User 消息保持 `item_type: None`,序列化为 `{role: "user", content: [...]}`(无 `type` 字段)
|
||||
3. `request.tools` → `tools` 数组(`ToolDef` → `ResponseTool::Function`)
|
||||
4. `request.extra` → 解析 `previous_response_id` / `store` / `metadata` / `truncation` / `reasoning` 等
|
||||
5. 标准字段映射(model / max_tokens / temperature / top_p / stop / stream)
|
||||
|
||||
#### Step 29.2:convert_response(~100 行)
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||
|
||||
实现 `OpenaiResponseProvider::convert_response(&self, response: OpenaiResponseBody) -> Result<MessageResponse, LlmError>`。
|
||||
|
||||
处理逻辑:
|
||||
1. 遍历 `response.output`,找到第一个 `type: "message"` 的 item,提取 text
|
||||
2. 其他 items(`function_call` → `ContentBlock::ToolUse`,内置工具 → `ContentBlock::Extension`)
|
||||
3. `response.status` → `StopReason`
|
||||
4. `response.usage` → `Usage`
|
||||
|
||||
#### Step 29.3:非流式 chat()(~80 行)
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||
|
||||
实现 `OpenaiResponseProvider::chat_blocking()`:
|
||||
- `convert_request()` → serde 序列化 → HTTP POST `{base_url}/responses`
|
||||
- Auth header: `Authorization: Bearer {api_key}`
|
||||
- 错误处理映射
|
||||
- 解析响应体 → `convert_response()`
|
||||
|
||||
#### Step 29.4:SSE 事件类型 + 状态机(~230 行)
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||
|
||||
实现 `ResponseSseEventStream` 结构体及其 `Stream` trait:
|
||||
- 字段:`byte_stream`, `buffer`, `partial: PartialMessageResponse`, `block_index: u32`, `saw_terminal: bool`
|
||||
- 行级 SSE 解析:`event:` + `data:` 配对
|
||||
- 事件 → `StreamEvent` 映射
|
||||
- `PartialMessageResponse::apply_to()` 累积
|
||||
- 流结束时 `finalize()` → `MessageComplete`
|
||||
|
||||
#### Step 29.5:流式 chat_stream()(~50 行)
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||
|
||||
实现 `OpenaiResponseProvider::chat_stream_inner()`:
|
||||
- `convert_request()` 设置 `stream: true`
|
||||
- HTTP POST → bytes_stream → 包装为 `ResponseSseEventStream`
|
||||
|
||||
#### Step 29.6:LlmProvider impl(~50 行)
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
|
||||
|
||||
实现 `LlmProvider for OpenaiResponseProvider`:
|
||||
- `chat()` → `chat_blocking()`
|
||||
- `chat_stream()` → `chat_stream_inner()`
|
||||
- `capabilities()` → 返回 `ProviderCapabilities`
|
||||
|
||||
#### Step 29.7:单元测试(~70 行)
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs` 的 `#[cfg(test)] mod tests {}`
|
||||
|
||||
| 测试 | 场景 |
|
||||
|------|------|
|
||||
| `convert_request_text_only` | 纯文本输入转换 |
|
||||
| `convert_request_vision` | Vision 输入转换 |
|
||||
| `convert_request_tool_call` | 工具调用输入转换 |
|
||||
| `convert_response_message` | 响应 message item 转换 |
|
||||
| `convert_response_tool_use` | 响应 function_call item 转换 |
|
||||
|
||||
---
|
||||
|
||||
### Phase 30:测试 + CI + 文档(~520 行)
|
||||
|
||||
#### Step 30.1:wiremock 非流式测试(~200 行)
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs` 内联测试
|
||||
|
||||
| 测试 | 场景 | 验证 |
|
||||
|------|------|------|
|
||||
| `response_api_basic_text` | 纯文本响应 | `response.text()` 正确 |
|
||||
| `response_api_tool_call` | 工具调用 | `stop_reason == ToolUse` |
|
||||
| `response_api_multi_turn` | 两轮对话 | 第二轮携带历史 |
|
||||
| `response_api_vision` | 图片输入 | 正确构造 `input_image` |
|
||||
| `response_api_unauthorized` | 401 错误 | `LlmError::Authentication` |
|
||||
| `response_api_rate_limit` | 429 错误 | `LlmError::RateLimit` |
|
||||
| `response_api_server_error` | 500 错误 | `LlmError::Request` |
|
||||
|
||||
#### Step 30.2:wiremock 流式测试(~200 行)
|
||||
**文件操作**:追加到 `src/llm/provider/openai_response.rs` 内联测试
|
||||
|
||||
| 测试 | 场景 | 验证 |
|
||||
|------|------|------|
|
||||
| `response_api_stream_text` | 流式文本 | 完整 SSE 事件序列 |
|
||||
| `response_api_stream_tool` | 流式工具调用 | `FunctionCallArgumentsDelta` 序列 |
|
||||
| `response_api_stream_error` | 流中途失败 | `StreamEvent::Error` |
|
||||
| `response_api_stream_multi_turn` | 流式多轮接续 | 第二轮携带历史消息时的完整 SSE 事件序列 |
|
||||
|
||||
#### Step 30.3:CI 矩阵(~10 行)
|
||||
**文件操作**:修改 `.github/workflows/ci.yml`
|
||||
|
||||
新增测试组合:
|
||||
```yaml
|
||||
- "chat,provider-openai,provider-openai-response"
|
||||
```
|
||||
|
||||
#### Step 30.4:文档更新(~50 行)
|
||||
**文件操作**:修改 `README.md` + `docs/roadmap.md`
|
||||
|
||||
- README feature 表新增 `provider-openai-response`
|
||||
- `docs/roadmap.md` 或 `docs/roadmap-unsorted.md` 新增 v0.3.3 或下版本条目
|
||||
|
||||
#### Step 30.5:Example(~60 行)
|
||||
**文件操作**:新建 `examples/response_api_demo.rs`
|
||||
|
||||
```toml
|
||||
[[example]]
|
||||
name = "response_api_demo"
|
||||
required-features = ["llm", "provider-openai-response"]
|
||||
```
|
||||
|
||||
基础对话示例,展示 Response API 的基本用法:
|
||||
|
||||
```
|
||||
cargo run --example response_api_demo --features "full"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 实施汇总
|
||||
|
||||
| Phase | 内容 | 代码行数估算 | 验证入口 |
|
||||
|-------|------|------------|---------|
|
||||
| 28 | Feature gate + Wire 类型 + Provider 骨架 | ~140 | `cargo build --features "provider-openai-response"` |
|
||||
| 29 | 核心 Provider 实现(转换/HTTP/流式) | ~680 | 5 个单元测试 |
|
||||
| 30 | 测试 + CI + 文档 | ~520 | 10 个 wiremock 测试 + CI 新组合 |
|
||||
| **合计** | | **~1,340** | 全量 `cargo test --features "full"` |
|
||||
|
||||
---
|
||||
|
||||
## 5. 风险评估
|
||||
|
||||
| ID | 风险 | 影响 | 概率 | 缓解措施 |
|
||||
|----|------|------|------|---------|
|
||||
| R1 | Response API 协议快速迭代 | Wire 类型可能需更新 | 中 | Wire 类型集中在单个文件内,更新成本低 |
|
||||
| R2 | `ContentBlock::Extension` 承载内置工具结果 | 下游消费方需适配 | 低 | 这是既有的逃生舱机制,已有消费模式 |
|
||||
| R3 | 全量历史模式 token 开销 | 多轮时 input tokens 增长 | 低 | 功能正确,后续版本可优化为 `previous_response_id` 增量模式 |
|
||||
| R4 | `instructions` 拼接多个 system 消息 | 语义可能与单 system 消息不同 | 低 | 已确认按 OpenAI 推荐方式全量拼接(`\n` 分隔),行为等价 |
|
||||
| R5 | 与 `GenericOpenaiProvider` 的错误映射逻辑重复 | 维护两份相似逻辑 | 低 | 提取复用函数时需注意不影响现有 provider |
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证标准
|
||||
|
||||
### 6.1 编译验证
|
||||
|
||||
| # | 检查项 | 命令 |
|
||||
|---|--------|------|
|
||||
| C1 | 独立 feature 编译 | `cargo build --features "provider-openai-response"` |
|
||||
| C2 | full 组合编译 | `cargo build --features "full"` |
|
||||
| C3 | light 组合不受影响 | `cargo build --features "light"`(不包含新 feature) |
|
||||
| C4 | Clippy 合规 | `cargo clippy --all-features --lib -- -D warnings` |
|
||||
|
||||
### 6.2 测试验证
|
||||
|
||||
| # | 检查项 | 通过条件 |
|
||||
|---|--------|---------|
|
||||
| T1 | 单元测试 | `cargo test --features "full"` 全部通过(+15 新增测试) |
|
||||
| T2 | 非流式 wiremock | 7 个测试覆盖文本/工具/多轮/Vision/401/429/500 |
|
||||
| T3 | 流式 wiremock | 3 个测试覆盖文本流/工具流/错误流 |
|
||||
| T4 | 现有测试无回归 | 使用 `--features "full"` 时已有 427 测试全部通过 |
|
||||
|
||||
### 6.3 CI 验证
|
||||
|
||||
| # | 检查项 | 通过条件 |
|
||||
|---|--------|---------|
|
||||
| I1 | 新增 CI 组合 | 包含新 feature 的组合编译通过 |
|
||||
| I2 | clippy + format | `cargo clippy` + `cargo fmt --check` 通过 |
|
||||
|
||||
### 6.4 Example 验证
|
||||
|
||||
| # | 检查项 | 通过条件 |
|
||||
|---|--------|---------|
|
||||
| E1 | Example 编译 | `cargo build --example response_api_demo --features "full"` 通过 |
|
||||
| E2 | Example 运行 | `cargo run --example response_api_demo --features "full"` 可执行(需 API key) |
|
||||
@@ -0,0 +1,422 @@
|
||||
# OpenAI Response Provider 自定义请求头支持
|
||||
|
||||
## 背景
|
||||
|
||||
OpenAI Responses API 的部分实现(如火山引擎豆包)需要携带特殊的 HTTP 请求头(如 `ark-beta-doubao-app: true`)来启用平台特定功能。当前 `OpenaiResponseProvider` 在 `build_request_builder()` 中只设置了 `Authorization` 头,没有途径注入自定义请求头。
|
||||
|
||||
原方案只覆盖 OpenAI Response Provider。经讨论后扩展为**三 Provider 统一**方案:OpenAI Chat(`GenericOpenaiProvider`)、OpenAI Response(`OpenaiResponseProvider`)、Anthropic(`AnthropicProvider`)。
|
||||
|
||||
核心动机:
|
||||
|
||||
- OpenAI Responses API 的部分实现需要携带特殊 HTTP 请求头来启用平台特定功能
|
||||
- 三种基础协议中,自定义头注入能力不一致
|
||||
- 统一 API 让调用方用 `set_extra("custom_headers", ...)` 即可,与底层协议无关
|
||||
|
||||
## 需求
|
||||
|
||||
### 功能需求
|
||||
|
||||
双层自定义头机制:
|
||||
|
||||
- **Provider 级固定头**:`extra_headers: Vec<(String, String)>`,构造时注入,所有请求自动携带。用于该 provider 所有请求都需要的固定标识头(如平台接入标记)
|
||||
- **请求级临时头**:`extra.custom_headers: HashMap<String, String>`,通过 `set_extra` 注入。用于特定请求需要覆盖或追加的头
|
||||
|
||||
### 约束
|
||||
|
||||
- 不可引入任何平台特定逻辑(火山、豆包等字符串不得出现)
|
||||
- 自定义头仅运行时生效,不进入 JSON 序列化的请求体
|
||||
- 兼容已有的 extra 逃生舱机制(builtin_tools、text_format 等)
|
||||
- agcore 是支持库,不提供运行时敏感头过滤保护(如 Authorization/Cookie),但文档中应说明风险
|
||||
- 不修改 `LlmProvider` trait、`ProviderType` 枚举
|
||||
- `create_provider()` 工厂函数只传 `Vec::new()` 作为 extra_headers 默认值,不暴露配置能力;调用方如需 Provider 级固定头,直接构造 provider 后链式调用 `.with_extra_headers()`
|
||||
|
||||
### 用户故事
|
||||
|
||||
1. 作为集成者,我想对任意 provider 的请求注入自定义 HTTP 头,以启用平台特有功能(请求级)
|
||||
2. 作为集成者,我想在 provider 构造时注入固定头,让所有请求自动携带,避免每次重复指定(Provider 级)
|
||||
3. 作为维护者,我想三种基础协议使用统一的 API,调用方无需关心底层 provider 类型
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 统一设计原则
|
||||
|
||||
```
|
||||
调用方视角(统一 API):
|
||||
request.set_extra("custom_headers", json!({"X-Foo": "bar"}));
|
||||
// 不管底层是 OpenAI Chat / OpenAI Response / Anthropic,都能工作
|
||||
|
||||
构造方视角(Provider 级):
|
||||
OpenaiResponseProvider::from_parts(..., extra_headers).with_extra_headers(...);
|
||||
GenericOpenaiProvider::from_parts(..., extra_headers); // 已有
|
||||
AnthropicProvider::from_parts(..., extra_headers);
|
||||
|
||||
头融合顺序(三 provider 一致):
|
||||
认证头 (Authorization / x-api-key) → Provider 级 extra_headers → 请求级 custom_headers
|
||||
↑ 后者覆盖前者
|
||||
```
|
||||
|
||||
### 改动一:GenericOpenaiProvider(openai.rs)
|
||||
|
||||
**① `OpenaiChatRequest` 新增字段**
|
||||
|
||||
在 `extra_body`(第 147 行)之后:
|
||||
|
||||
```rust
|
||||
/// 请求级别自定义 HTTP 头。运行时注入,不进入 JSON 请求体。
|
||||
/// ⚠️ 与 struct 已有的 `extra_headers: Option<Value>`(OpenAI API 自身的 wire 格式字段)
|
||||
/// 不同——后者是 OpenAI API 参数,本字段是 reqwest 层的 HTTP 头注入。
|
||||
#[serde(skip)]
|
||||
pub custom_headers: HashMap<String, String>,
|
||||
```
|
||||
|
||||
`#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。
|
||||
|
||||
**② `convert_request()` 从 extra 提取**
|
||||
|
||||
在 `parallel_tool_calls`(第 559 行)之后:
|
||||
|
||||
```rust
|
||||
let custom_headers: HashMap<String, String> = request
|
||||
.get_extra_opt("custom_headers")
|
||||
.unwrap_or_default();
|
||||
```
|
||||
|
||||
**③ `build_request_builder()` 签名改具体类型 + 注入逻辑**
|
||||
|
||||
第 454 行,签名从 `&impl Serialize` 改为 `&OpenaiChatRequest`(两处调用点传入的均为该类型,安全):
|
||||
|
||||
```rust
|
||||
fn build_request_builder(
|
||||
&self,
|
||||
url: &str,
|
||||
body: &OpenaiChatRequest, // 从 &impl Serialize 改为具体类型
|
||||
) -> Result<reqwest::RequestBuilder, LlmError> {
|
||||
let mut builder = self
|
||||
.http_client
|
||||
.post(url)
|
||||
.header("Authorization", format!("Bearer {}", self.api_key));
|
||||
|
||||
// 头融合顺序见上方「统一设计原则」。
|
||||
// Provider 级固定头先注入,请求级临时头后注入(后者覆盖前者)。
|
||||
|
||||
Ok(builder.json(body))
|
||||
}
|
||||
```
|
||||
|
||||
两处调用点(`chat_blocking` 第 628 行、`chat_stream_inner` 第 669 行)传入的都是 `&OpenaiChatRequest`,零影响。
|
||||
|
||||
**④ `with_extra_headers()` builder 方法**
|
||||
|
||||
```rust
|
||||
/// 注入 Provider 级别固定头。返回 self 以支持链式调用。
|
||||
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
|
||||
self.extra_headers = headers;
|
||||
self
|
||||
}
|
||||
```
|
||||
|
||||
### 改动二:OpenaiResponseProvider(openai_response.rs)
|
||||
|
||||
**① struct 新增 `extra_headers` 字段**
|
||||
|
||||
第 287 行,`pub struct OpenaiResponseProvider` 增加:
|
||||
|
||||
```rust
|
||||
pub struct OpenaiResponseProvider {
|
||||
// ... 已有字段 ...
|
||||
extra_headers: Vec<(String, String)>,
|
||||
}
|
||||
```
|
||||
|
||||
**② `from_parts()` 新增参数**
|
||||
|
||||
第 299 行:
|
||||
|
||||
```rust
|
||||
pub(crate) fn from_parts(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
http_client: Client,
|
||||
timeout_secs: u64,
|
||||
extra_headers: Vec<(String, String)>, // 新增
|
||||
) -> Self { ... }
|
||||
```
|
||||
|
||||
**③ `with_extra_headers()` builder 方法**
|
||||
|
||||
```rust
|
||||
/// 注入 Provider 级别固定头。返回 self 以支持链式调用。
|
||||
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
|
||||
self.extra_headers = headers;
|
||||
self
|
||||
}
|
||||
```
|
||||
|
||||
**④ `OpenaiResponseRequest` 新增字段**
|
||||
|
||||
第 73 行,`reasoning` 之后:
|
||||
|
||||
```rust
|
||||
/// 请求级别自定义 HTTP 头。序列化时跳过,仅运行时由 build_request_builder 消费。
|
||||
/// stream 模式的修改不影响该字段——header 由 convert_request 在请求构造时注入。
|
||||
#[serde(skip)]
|
||||
pub custom_headers: HashMap<String, String>,
|
||||
```
|
||||
|
||||
`#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。
|
||||
|
||||
**⑤ `convert_request()` 从 extra 提取**
|
||||
|
||||
第 404 行,`reasoning` 之后:
|
||||
|
||||
```rust
|
||||
let custom_headers: HashMap<String, String> = extra
|
||||
.get("custom_headers")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
```
|
||||
|
||||
> **注意**:OpenaiResponseProvider 的 `convert_request` 在顶部 destructure 了 `request`,因此使用 `extra.get()` 而非 `request.get_extra_opt()`。两者语义一致,均反序列化为 `HashMap<String, String>`,失败时静默降级为空 HashMap。
|
||||
|
||||
**⑥ `build_request_builder()` 签名 + 注入逻辑**
|
||||
|
||||
第 319 行,签名从 `&impl Serialize` 改为 `&OpenaiResponseRequest`(两处调用点传入的均为该类型,安全):
|
||||
|
||||
```rust
|
||||
/// 构造 HTTP POST 请求 builder(含认证头与额外请求头)。
|
||||
///
|
||||
/// 头融合顺序:Authorization → Provider 级 extra_headers → 请求级 custom_headers
|
||||
/// 后者覆盖前者。
|
||||
fn build_request_builder(
|
||||
&self,
|
||||
body: &OpenaiResponseRequest, // 从 &impl Serialize 改为具体类型
|
||||
) -> Result<reqwest::RequestBuilder, LlmError> {
|
||||
let mut builder = self
|
||||
.http_client
|
||||
.post(self.endpoint_url())
|
||||
.header("Authorization", format!("Bearer {}", self.api_key));
|
||||
|
||||
for (k, v) in &self.extra_headers {
|
||||
builder = builder.header(k.as_str(), v.as_str());
|
||||
}
|
||||
|
||||
for (key, value) in &body.custom_headers {
|
||||
builder = builder.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
Ok(builder.json(body))
|
||||
}
|
||||
```
|
||||
|
||||
两处调用点(`chat_blocking` 第 708 行、`chat_stream_inner` 第 741 行)传入的都是 `&OpenaiResponseRequest`,零影响。
|
||||
|
||||
### 改动三:AnthropicProvider(anthropic.rs)
|
||||
|
||||
AnthropicProvider 是唯一没有统一 `build_request_builder` 方法的 provider,需要**前置重构**。
|
||||
|
||||
**① struct 新增 `extra_headers` 字段**
|
||||
|
||||
第 36 行:
|
||||
|
||||
```rust
|
||||
pub struct AnthropicProvider {
|
||||
// ... 已有字段 ...
|
||||
extra_headers: Vec<(String, String)>,
|
||||
}
|
||||
```
|
||||
|
||||
**② `from_parts()` 新增参数**
|
||||
|
||||
第 128 行:
|
||||
|
||||
```rust
|
||||
pub(crate) fn from_parts(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
http_client: Client,
|
||||
timeout_secs: u64,
|
||||
extra_headers: Vec<(String, String)>, // 新增
|
||||
) -> Self { ... }
|
||||
```
|
||||
|
||||
**③ `with_extra_headers()` builder 方法**
|
||||
|
||||
```rust
|
||||
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
|
||||
self.extra_headers = headers;
|
||||
self
|
||||
}
|
||||
```
|
||||
|
||||
**④ `AnthropicRequestBody` 新增字段**
|
||||
|
||||
第 450 行,`stream` 之后:
|
||||
|
||||
```rust
|
||||
struct AnthropicRequestBody {
|
||||
model: String,
|
||||
max_tokens: u32,
|
||||
// ... 已有字段 ...
|
||||
/// 请求级别自定义 HTTP 头。运行时注入,不进入 JSON 请求体。
|
||||
#[serde(skip)]
|
||||
custom_headers: HashMap<String, String>,
|
||||
}
|
||||
```
|
||||
|
||||
`#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。
|
||||
|
||||
**⑤ `build_request_body()` 从 extra 提取**
|
||||
|
||||
```rust
|
||||
let custom_headers: HashMap<String, String> = request
|
||||
.get_extra_opt("custom_headers")
|
||||
.unwrap_or_default();
|
||||
```
|
||||
|
||||
**⑥ 提取 `build_request_builder()` 统一方法(前置重构)**
|
||||
|
||||
```rust
|
||||
/// 构造 HTTP POST 请求 builder(含认证头 + 自定义头)。
|
||||
/// 认证头(x-api-key / anthropic-version)已由 Client 的 default_headers 提供。
|
||||
fn build_request_builder(
|
||||
&self,
|
||||
body: &AnthropicRequestBody,
|
||||
) -> Result<reqwest::RequestBuilder, LlmError> {
|
||||
let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
|
||||
let mut builder = self.http_client.post(&url).json(body);
|
||||
|
||||
for (k, v) in &self.extra_headers {
|
||||
builder = builder.header(k.as_str(), v.as_str());
|
||||
}
|
||||
|
||||
for (key, value) in &body.custom_headers {
|
||||
builder = builder.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
Ok(builder)
|
||||
}
|
||||
```
|
||||
|
||||
**⑦ 改造 `chat_blocking()` 和 `chat_stream_inner()`**
|
||||
|
||||
改造前(`chat_blocking`,第 263-269 行):
|
||||
|
||||
```rust
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| self.map_reqwest_error(e))?;
|
||||
```
|
||||
|
||||
改造后:
|
||||
|
||||
```rust
|
||||
let response = self
|
||||
.build_request_builder(&body)?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| self.map_reqwest_error(e))?;
|
||||
```
|
||||
|
||||
`chat_stream_inner`(第 298-304 行)同理。
|
||||
|
||||
### 改动四:create_provider()(provider.rs)
|
||||
|
||||
依据约束「`create_provider()` 工厂函数不暴露配置能力」,三处分支适配 `from_parts` 的新签名时全部传 `Vec::new()`:
|
||||
|
||||
```rust
|
||||
// OpenaiResponse(第 199-207 行)
|
||||
openai_response::OpenaiResponseProvider::from_parts(
|
||||
config.base_url, config.api_key, config.model,
|
||||
client, config.timeout_secs,
|
||||
Vec::new(), // extra_headers 默认空
|
||||
)
|
||||
|
||||
// Anthropic(第 215-221 行)
|
||||
anthropic::AnthropicProvider::from_parts(
|
||||
config.base_url, config.api_key, config.model,
|
||||
client, config.timeout_secs,
|
||||
Vec::new(), // extra_headers 默认空
|
||||
)
|
||||
|
||||
// OpenAI Chat(第 185-194 行)— 已有 Vec::new(),无需改动
|
||||
```
|
||||
|
||||
### 调用方式
|
||||
|
||||
**请求级临时头**(统一 API,三 provider 通用):
|
||||
|
||||
```rust
|
||||
request.set_extra("custom_headers", serde_json::json!({
|
||||
"ark-beta-doubao-app": "true"
|
||||
}));
|
||||
```
|
||||
|
||||
**Provider 级固定头**(构造时注入):
|
||||
|
||||
```rust
|
||||
let provider = OpenaiResponseProvider::from_parts(...)
|
||||
.with_extra_headers(vec![
|
||||
("ark-beta-doubao-app".into(), "true".into()),
|
||||
]);
|
||||
```
|
||||
|
||||
## 风险评估
|
||||
|
||||
### 风险点与缓解措施
|
||||
|
||||
| 风险 | 等级 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| 用户通过 `custom_headers` 覆盖 `Authorization` 等认证头 | 中 | 文档说明:自定义头按遍历顺序注入,同 key 后注入覆盖前注入。agcore 作为支持库不做运行时拦截 |
|
||||
| `serde_json::from_value` 类型错误静默降级为空 HashMap | 低 | 与已有 extra 字段(builtin_tools、text_format)一致的模式,保持行为统一。类型错误时请求正常发出,只是不携带自定义头 |
|
||||
| HashMap 迭代顺序不确定影响测试确定性 | 低 | HTTP 协议不要求 header 顺序,wiremock 按名匹配。无需特殊处理 |
|
||||
| AnthropicProvider 前置重构引入回归 | 低 | 提取 `build_request_builder` 是纯重构,现有测试覆盖其请求构造行为。重构后运行现有测试套件即可验证 |
|
||||
| `build_request_builder` 签名从泛型改为具体类型 | 低 | 已确认两处调用点(chat_blocking / chat_stream_inner)传入的均为具体类型,零影响 |
|
||||
| AnthropicProvider 的 `default_headers`(x-api-key / anthropic-version)与 `extra_headers` 同名头合并行为取决于 reqwest 实现 | 低 | 明确约定 Provider 级固定头不应意图覆盖认证头;`build_request_builder` 的 doc comment 中标注认证头来源 |
|
||||
|
||||
### 设计取舍记录
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| Provider 级 vs 请求级 | 双层都支持 | 满足固定头和临时头两种场景 |
|
||||
| `create_provider` 是否暴露 extra_headers | 不暴露,只传 `Vec::new()` | 保持工厂函数签名简洁,固定头通过 builder 方法注入 |
|
||||
| 敏感头保护 | 不做运行时拦截,文档说明 | agcore 是支持库,不替调用方做保护 |
|
||||
| `OpenaiChatRequest.custom_headers` 命名 | 用 `custom_headers` 而非 `extra_headers` | 避免与已有的 `extra_headers: Option<Value>`(OpenAI API wire 字段)混淆 |
|
||||
|
||||
## 验证标准
|
||||
|
||||
### 单元测试(每 provider 4 个)
|
||||
|
||||
| 测试 | 验证点 |
|
||||
|------|--------|
|
||||
| `*_custom_headers_from_extra` | `convert_request` / `build_request_body` 能从 extra 提取 `custom_headers` |
|
||||
| `*_custom_headers_skipped_in_json` | `#[serde(skip)]` 确保 custom_headers 不进入序列化 JSON body |
|
||||
| `*_custom_headers_invalid_type_fallback` | 传入错误类型(如字符串而非对象)时静默降级为空 HashMap |
|
||||
| `*_extra_headers_from_constructor` | 验证 `from_parts` / `new_with_name_and_headers` 传入的 `extra_headers` 在 `build_request_builder` 中被正确注入到 HTTP 请求头 |
|
||||
|
||||
### 集成测试(每 provider 4 个,wiremock)
|
||||
|
||||
| 测试 | 验证点 |
|
||||
|------|--------|
|
||||
| `*_custom_headers_are_sent` | mock 匹配器验证 HTTP 请求确实携带自定义头 |
|
||||
| `*_provider_level_headers_are_sent` | 验证 Provider 级固定头(通过 `with_extra_headers` 注入)确实出现在 HTTP 请求中 |
|
||||
| `*_custom_headers_override_provider_headers` | 当 Provider 级和请求级设置了相同 key 但不同值时,最终 HTTP 请求携带的是请求级的值 |
|
||||
| `*_custom_headers_can_override_auth_header` | 注入含 `Authorization` 同 key 的 `custom_headers`,验证最终认证头值被覆盖(使行为可见、可预测,与文档风险说明一致) |
|
||||
|
||||
### 回归验证
|
||||
|
||||
1. 运行 `cargo test --features full` 确保所有现有测试通过
|
||||
2. `cargo clippy --features full` 无新警告
|
||||
3. `cargo fmt --check` 格式一致
|
||||
|
||||
## 不涉及的改动
|
||||
|
||||
- 不新增 Feature gate
|
||||
- 不修改 `LlmProvider` trait
|
||||
- 不修改 `ProviderType` 枚举
|
||||
- 不新增任何平台相关代码
|
||||
@@ -0,0 +1,183 @@
|
||||
# LangChain & LangGraph 功能调研笔记
|
||||
|
||||
> 调研时间:2026-07-06
|
||||
> 两者关系:同一公司(LangChain Inc.)维护的堆栈上下两层,不是竞品
|
||||
|
||||
---
|
||||
|
||||
## 两者关系
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ LangChain (v1.0 GA) │ ← 高层框架:模型抽象、工具、提示词、600+集成
|
||||
│ create_agent / LCEL / 组件库 │
|
||||
├──────────────────────────────────────────┤
|
||||
│ LangGraph (v1.0 GA) │ ← 底层运行时:有向图执行引擎
|
||||
│ StateGraph / Checkpointing / HITL │
|
||||
├──────────────────────────────────────────┤
|
||||
│ LangSmith (可观测性) │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
2025年10月22日同时达到 v1.0 GA,官方分工:
|
||||
|
||||
> **LangChain** = agent framework:abstractions and integrations for models, tools, and agent loops.
|
||||
> **LangGraph** = orchestration runtime:durable execution, streaming, human-in-the-loop, and persistence.
|
||||
|
||||
LangChain v1.0 的 `create_agent` 内部已运行在 LangGraph 引擎上。
|
||||
|
||||
---
|
||||
|
||||
## LangChain v1.0
|
||||
|
||||
### 定位
|
||||
高层应用框架,提供 agent 所需的**组件抽象**和**集成生态**。
|
||||
|
||||
### 精简后的核心模块
|
||||
|
||||
| 模块 | 功能 |
|
||||
|------|------|
|
||||
| `langchain.agents` | `create_agent`, `AgentState`(取代旧 AgentExecutor) |
|
||||
| `langchain.chat_models` | `init_chat_model`, `BaseChatModel`(统一模型初始化) |
|
||||
| `langchain.tools` | `@tool`, `BaseTool` |
|
||||
| `langchain.messages` | 消息类型、内容块、`trim_messages` |
|
||||
| `langchain.embeddings` | `init_embeddings`, `Embeddings` |
|
||||
|
||||
旧组件(`LLMChain`、`ConversationChain` 等)移入 `langchain-classic`。
|
||||
|
||||
### 七大组件类别
|
||||
|
||||
| 类别 | 关键组件 |
|
||||
|------|----------|
|
||||
| **Models** | Chat models, LLMs, Embeddings — 统一接口跨 provider 切换 |
|
||||
| **Tools** | 600+ provider 集成:API、数据库、搜索引擎等 |
|
||||
| **Agents** | `create_agent`, ReAct agents, Tool-calling agents |
|
||||
| **Memory** | 消息历史、自定义状态 |
|
||||
| **Retrievers** | 向量检索器、网络检索器 |
|
||||
| **Document** | 加载器、分割器、转换器 |
|
||||
| **Vector Stores** | Chroma, Pinecone, FAISS 等集成 |
|
||||
|
||||
### v1.0 关键新特性
|
||||
|
||||
**1. Middleware 中间件系统** — `create_agent` 的钩子系统:
|
||||
- `before_model` — 模型调用前注入/修改
|
||||
- `after_model` — 模型调用后验证/后处理
|
||||
- `wrap_tool_call` — 拦截工具调用错误
|
||||
|
||||
**2. Standard Message Content** — 跨 provider 标准化消息内容格式:
|
||||
- 推理/思维链、引用、多模态(图片/音视频/文档)
|
||||
- 工具调用、provider 特有工具(web search, code execution)
|
||||
- 通过 `.content_blocks` 属性访问,向后兼容
|
||||
|
||||
**3. `create_agent`** — 取代旧 AgentExecutor,内部运行在 LangGraph 运行时上
|
||||
|
||||
### 成熟度
|
||||
|
||||
| 维度 | 状态 |
|
||||
|------|------|
|
||||
| 版本 | v1.0 GA(2025-10) |
|
||||
| 稳定性 | 稳定,agent 层经重构后已稳定 |
|
||||
| 生产证明 | Replit, Clay, Rippling, Cloudflare, Workday |
|
||||
| 支持 | LTS-style support track |
|
||||
| 适用场景 | RAG、信息提取、单 agent 助手、快速原型 |
|
||||
|
||||
---
|
||||
|
||||
## LangGraph v1.0
|
||||
|
||||
### 定位
|
||||
底层编排运行时,专为**有状态、长时间运行、多步骤**工作流设计。
|
||||
|
||||
### 核心抽象链
|
||||
|
||||
```
|
||||
StateGraph → Nodes (纯 Python 函数) → Edges (路由逻辑)
|
||||
↓
|
||||
Shared State (TypedDict / Pydantic)
|
||||
↓
|
||||
Checkpointer (每个 super-step 快照)
|
||||
```
|
||||
|
||||
- **StateGraph**: 有状态图,参数化 State 类型
|
||||
- **Nodes**: 纯函数,`(State) → updates`
|
||||
- **Edges**: `add_conditional_edges`,支持循环/分支/合并
|
||||
- **State**: `TypedDict` 或 Pydantic,带 reducer 处理并发更新
|
||||
- **Reducers**: `add_messages` 等,自动处理追加 vs 覆盖
|
||||
|
||||
### 完整功能矩阵
|
||||
|
||||
| 功能 | 状态 | 细节 |
|
||||
|------|------|------|
|
||||
| **StateGraph** | ✅ 稳定 | 循环图(非 DAG),条件边缘,并行 fan-out |
|
||||
| **Checkpointing** | ✅ v4.1.1 | SQLite / PostgreSQL / Redis 后端 |
|
||||
| **Durable Execution** | ✅ 稳定 | 跨失败自动恢复,从精确断点继续 |
|
||||
| **Human-in-the-loop** | ✅ 一等公民 | `interrupt()` + `Command(resume=...)` |
|
||||
| **Time-travel 调试** | ✅ 稳定 | 回滚任意 checkpoint,fork 重放 |
|
||||
| **流式输出** | ✅ 稳定 | Token 级 + State 级 + Event 级 |
|
||||
| **多 Agent 编排** | ✅ 稳定 | Supervisor / Swarm / 层级 / Subgraph |
|
||||
| **Comprehensive Memory** | ✅ 稳定 | 短时工作记忆 + 长时持久记忆 |
|
||||
| **增量状态存储** | 🧪 DeltaChannel beta (v4.1.0+) | 长消息列表只存 delta |
|
||||
| **跨进程状态同步** | 🧪 RemoteCheckpointer (v4.1.0+) | 分布式多 agent 架构 |
|
||||
| **自动 checkpoint 清理** | ✅ keep_latest TTL (v4.0.2) | 避免无限制积累历史 |
|
||||
| **LangGraph Platform** | ✅ 稳定 | Agent Server:持久化、任务队列、版本管理 |
|
||||
| **LangGraph Studio** | ✅ 稳定 | 可视化 agent 工作流 |
|
||||
|
||||
### 成熟度
|
||||
|
||||
| 维度 | 状态 |
|
||||
|------|------|
|
||||
| 版本 | v1.0 GA(2025-10),checkpointer v4.1.1 (2026-05) |
|
||||
| 稳定性 | 高,持久化为架构一等公民 |
|
||||
| 生产证明 | Klarna, Replit, Elastic |
|
||||
| 支持 | LTS-style support track |
|
||||
| 适用场景 | 多步骤 agent、多 agent 系统、人工审批、长时间运行任务 |
|
||||
|
||||
---
|
||||
|
||||
## 功能边界对比
|
||||
|
||||
| 维度 | LangChain | LangGraph |
|
||||
|------|-----------|-----------|
|
||||
| **层次** | 高层应用框架 | 底层编排运行时 |
|
||||
| **核心抽象** | `create_agent`, LCEL, 组件库 | `StateGraph`, Nodes, Edges, State |
|
||||
| **思维模型** | 线性或 DAG 管道 | 节点 + 边缘的循环有向图 |
|
||||
| **循环/分支** | 受限 | **一等公民**:任意循环、分支、合并 |
|
||||
| **状态持久化** | 无原生支持 | **一等公民**:Checkpointer |
|
||||
| **Human-in-loop** | 需手动编排 | **一等公民**:`interrupt()` + `Command` |
|
||||
| **Time-travel 调试** | 无 | **一等公民**:回滚 fork 重放 |
|
||||
| **Durable Execution** | 无 | **一等公民**:跨故障自动恢复 |
|
||||
| **流式** | Token 级 | Token + State + Event 每节点流式 |
|
||||
| **多 Agent 编排** | 需手动组合 | **原生**:Supervisor/Swarm/Subgraph |
|
||||
| **模型抽象** | **核心优势** | 复用 LangChain |
|
||||
| **600+ 集成** | **核心优势** | 可复用 LangChain 集成 |
|
||||
| **LCEL 线性链** | **有** | 无 |
|
||||
| **Middleware** | **v1.0 特有** | 无 |
|
||||
| **学习曲线** | 中等 | 较陡(需图思维) |
|
||||
| **部署平台** | 无独立平台 | LangGraph Platform + Studio |
|
||||
|
||||
---
|
||||
|
||||
## 决策路线
|
||||
|
||||
```
|
||||
你的 workflow 需要什么?
|
||||
│
|
||||
├─ 线性、始终相同步骤 → LangChain (LCEL / create_agent)
|
||||
├─ 需要循环/分支/重试 → LangGraph (StateGraph)
|
||||
├─ 需要持久化/故障恢复 → LangGraph (Checkpointer)
|
||||
├─ 需要人工审批 → LangGraph (interrupt())
|
||||
├─ 需要 time-travel 调试 → LangGraph (checkpoint + fork)
|
||||
├─ 需要多 agent 协作 → LangGraph (Supervisor/Swarm/Subgraph)
|
||||
└─ 不确定 → 先用 create_agent,遇到瓶颈下钻到 StateGraph
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 参考来源
|
||||
|
||||
- [LangChain Blog: v1.0 Milestone](https://www.langchain.com/blog/langchain-langgraph-1dot0)
|
||||
- [LangChain Documentation](https://docs.langchain.com/oss/python/langchain/overview)
|
||||
- [LangGraph Documentation](https://docs.langchain.com/oss/python/langgraph/overview)
|
||||
- [LangGraph GitHub](https://github.com/langchain-ai/langgraph)
|
||||
- [Atlan: LangChain vs LangGraph 2026](https://atlan.com/know/ai-agent/ai-agent-memory/langchain-vs-langgraph/)
|
||||
- [truefoundry: LangChain vs LangGraph](https://www.truefoundry.com/blog/langchain-vs-langgraph)
|
||||
@@ -0,0 +1,344 @@
|
||||
# 笔记:opencode 子代理调度、分发与合并及工作流推进
|
||||
|
||||
> 基于 `/Users/midnite/Samples/opencode` 源码调研,2026-07-04
|
||||
|
||||
---
|
||||
|
||||
## 一、整体架构
|
||||
|
||||
```
|
||||
LLM(主 Agent)
|
||||
│
|
||||
├── 调用 Task tool(tool call)
|
||||
│ ↓
|
||||
│ TaskTool.execute() ← packages/opencode/src/tool/task.ts
|
||||
│ │
|
||||
│ ├── agent.get() ← 查找 Agent 定义(agent.ts)
|
||||
│ ├── deriveSubagentPermission() ← 权限合并(subagent-permissions.ts)
|
||||
│ ├── sessions.create() ← 创建子 session
|
||||
│ │
|
||||
│ ├── [前台] background.wait() + background.waitForPromotion() race
|
||||
│ │ ↓ 完成
|
||||
│ │ renderOutput() → XML <task> 标签返回
|
||||
│ │
|
||||
│ └── [后台] background.start() → notify() 异步注入结果
|
||||
│
|
||||
└── 会话循环(runLoop) ← prompt.ts
|
||||
│
|
||||
├── 检测 subtask type part → handleSubtask()
|
||||
├── 检测 compaction → compaction.process()
|
||||
└── 正常流程 → LLM.stream() → processor.handleEvent()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、子代理调度(Dispatch)
|
||||
|
||||
### 2.1 三种触发入口
|
||||
|
||||
| 入口 | 触发方式 | 调用链路 |
|
||||
|------|---------|---------|
|
||||
| A — LLM 自主 | LLM 调用 `task` tool | 系统提示词中注入了 Task tool 描述 + `describeTask()` 输出子代理列表 → LLM 决策 |
|
||||
| B — `subtask` part | 消息中有 `type: "subtask"` 的 part | `handleSubtask()` 直接执行 TaskTool,不走 LLM |
|
||||
| C — `agent` part | 消息中有 `type: "agent"` 的 part | 转为"调用 task tool 带 subagent: XXX"的提示词,引导 LLM |
|
||||
|
||||
### 2.2 TaskTool.execute() 完整流程(task.ts)
|
||||
|
||||
```
|
||||
execute(params, ctx):
|
||||
1. background 开关检查(需 experimental flag)
|
||||
2. ctx.ask() 权限询问
|
||||
3. agent.get(subagent_type) 查找子代理定义
|
||||
4. task_id 存在 → sessions.get(task_id) 恢复已有子 session
|
||||
task_id 不存在 → sessions.create() 创建新子 session
|
||||
5. deriveSubagentSessionPermission() 合并权限
|
||||
6. 添加默认 deny 规则(todowrite / task)
|
||||
7. 确定 model(继承或子代理自定义)
|
||||
8. 执行 runTask() → ops.resolvePromptParts() + ops.prompt()
|
||||
9. 结果格式化为 XML ← renderOutput()
|
||||
```
|
||||
|
||||
### 2.3 关键:子 session 创建(task.ts lines 121-158)
|
||||
|
||||
```typescript
|
||||
// 权限继承
|
||||
const childPermission = deriveSubagentSessionPermission({
|
||||
parentSessionPermission: parent.permission ?? [],
|
||||
subagent: next,
|
||||
})
|
||||
|
||||
// 默认 deny 规则
|
||||
const childToolDenies = [
|
||||
// 子代理自己的 permission 没允许 todowrite → 默认 deny
|
||||
...(next.permission.some(r => r.permission === "todowrite") ? []
|
||||
: [{ permission: "todowrite", pattern: "*", action: "deny" }]),
|
||||
// 子代理自己的 permission 没允许 task → 默认 deny(防嵌套)
|
||||
...(next.permission.some(r => r.permission === "task") ? []
|
||||
: [{ permission: "task", pattern: "*", action: "deny" }]),
|
||||
// 主 agent 专有工具也不给子代理
|
||||
...(cfg.experimental?.primary_tools?.map(p => ({ permission: p, ... })) ?? []),
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、通信格式:Tool Call / Tool Result
|
||||
|
||||
### 3.1 父→子:Task tool 参数
|
||||
|
||||
```
|
||||
{
|
||||
subagent_type: "explore" | "general" | ...,
|
||||
description: "简短描述(3-5词)",
|
||||
prompt: "子代理的完整任务描述",
|
||||
task_id?: "恢复已有子 session 时使用",
|
||||
command?: "触发该调用的 CLI 命令(可选)",
|
||||
background?: true // 后台模式(需 experimental flag)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 子→父:XML 包装的纯文本(renderOutput)
|
||||
|
||||
```xml
|
||||
<task id="ses_xxxxx" state="completed">
|
||||
<summary>任务简述</summary>
|
||||
<task_result>
|
||||
子 agent 输出的完整文本内容...
|
||||
</task_result>
|
||||
</task>
|
||||
```
|
||||
|
||||
错误时:
|
||||
|
||||
```xml
|
||||
<task id="ses_xxxxx" state="error">
|
||||
<summary>任务失败</summary>
|
||||
<task_error>
|
||||
Error: 具体错误信息...
|
||||
</task_error>
|
||||
</task>
|
||||
```
|
||||
|
||||
### 3.3 传递给 LLM 的方式
|
||||
|
||||
**前台模式**:
|
||||
```
|
||||
TaskTool.execute() 返回 { output: "<task>...</task>" }
|
||||
↓
|
||||
AI SDK 将其转为 tool result,存入数据库 tool part
|
||||
↓
|
||||
下一轮 LLM 调用时,tool result 作为消息历史的一部分传入
|
||||
↓
|
||||
LLM 看到 XML,自行解析使用
|
||||
```
|
||||
|
||||
**后台模式**:
|
||||
```
|
||||
TaskTool.execute() 立即返回 <task state="running">...
|
||||
↓
|
||||
子 agent 完成后 → background.wait() 触发 → inject()
|
||||
↓
|
||||
向父 session 注入合成 text part(synthetic: true)
|
||||
携带 <task state="completed">... 结果
|
||||
↓
|
||||
父 LLM 在下一轮循环中看到该消息
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、分发与合并(Distribution & Merge)
|
||||
|
||||
### 4.1 并行分发
|
||||
|
||||
- **无专用分发层**。依赖 LLM 在单条消息中发出多个 tool call
|
||||
- `task.txt` 引导 LLM:*"Launch multiple agents concurrently whenever possible"*
|
||||
- 底层通过 Effect.ts 的 `Effect.forkIn(scope, { startImmediately: true })` 实现同一消息内多 tool call 并发
|
||||
- **子 agent 之间完全隔离**,无直接通信
|
||||
|
||||
### 4.2 结果合并
|
||||
|
||||
**无专用合并逻辑。** 合并完全通过 LLM 的上下文理解完成:
|
||||
|
||||
- 前台:tool result 自然进入消息历史,LLM 下一轮读取
|
||||
- CLI 命令:额外注入 "Summarize the task tool output above and continue with your task." 引导 LLM 总结
|
||||
- LLM 自主调用:无额外引导,LLM 自行决定如何使用
|
||||
|
||||
### 4.3 前台/后台切换机制(task.ts lines 303-333)
|
||||
|
||||
```typescript
|
||||
// 前台执行
|
||||
return yield* Effect.raceFirst(
|
||||
background.wait({ id: nextSession.id }), // 等完成
|
||||
background.waitForPromotion(nextSession.id), // 等 promote 到后台
|
||||
)
|
||||
```
|
||||
|
||||
当用户将前台任务 promote 到后台时,`waitForPromotion` 先返回(标记 `metadata.background = true`),TaskTool 转而返回后台模式的输出。
|
||||
|
||||
### 4.4 后台作业引擎(core/background-job.ts)
|
||||
|
||||
纯内存、非持久化注册表。使用 Effect.ts 的 `SynchronizedRef` 做并发控制。
|
||||
|
||||
| 操作 | 行为 |
|
||||
|------|------|
|
||||
| `start()` | 创建 job,fork run effect,返回 info |
|
||||
| `extend()` | 追加顺序执行的 run(通过 `Deferred` 链式等待前一个完成) |
|
||||
| `wait()` | `Deferred.await(done)`,可选 timeout |
|
||||
| `waitForPromotion()` | 等待 `promoted` Deferred 或检测 `background` 标记 |
|
||||
| `promote()` | 标记 `background = true`,触发 `onPromote` callback |
|
||||
| `cancel()` | 设置 `cancelled`,close scope(中断所有子 fork) |
|
||||
|
||||
---
|
||||
|
||||
## 五、工作流推进(Workflow Progression)
|
||||
|
||||
### 5.1 核心循环(prompt.ts → runLoop)
|
||||
|
||||
```
|
||||
runLoop(sessionID):
|
||||
while true:
|
||||
1. MessageV2.filterCompactedEffect() 获取消息
|
||||
2. MessageV2.latest() 取最近 user/assistant/tasks
|
||||
3. 检查 finish 状态
|
||||
- 不是 tool-calls 且有 finish → break(退出循环)
|
||||
4. 取 tasks(subtask / compaction 队列)
|
||||
- subtask → handleSubtask() → continue
|
||||
- compaction → compaction.process() → continue/break
|
||||
5. 检查 overflow → 自动创建 compaction task → continue
|
||||
6. 构建 assistant message
|
||||
7. SessionProcessor.create() 创建 handle
|
||||
8. SessionTools.resolve() 解析所有工具
|
||||
9. 构建 system prompt(环境信息 + skills + MCP + instructions)
|
||||
10. handle.process() — 启动 LLM stream
|
||||
11. 检查 result:
|
||||
- "compact" → 返回给外层触发 compaction
|
||||
- "stop" → break
|
||||
- "continue" → 继续循环
|
||||
```
|
||||
|
||||
### 5.2 SessionProcessor 事件处理(processor.ts)
|
||||
|
||||
| Stream 事件 | 处理逻辑 |
|
||||
|------------|---------|
|
||||
| `reasoning-start/delta/end` | 创建 reasoning part → 增量追加 → 最终持久化 |
|
||||
| `tool-input-start/delta/end` | 创建/更新 tool part(pending 状态) |
|
||||
| `tool-call` | 标记 running → 设置 input → **doom loop 检测** |
|
||||
| `tool-result` | `completeToolCall()` → 持久化结果 + 附件 |
|
||||
| `tool-error` | `failToolCall()` → 标记错误 |
|
||||
| `provider-error` | 抛出异常 → 触发重试 |
|
||||
| `text-start/delta/end` | 流式文本 → `updatePartDelta()` **增量持久化** |
|
||||
| `step-start` | 创建快照(snapshot) |
|
||||
| `step-finish` | 生成 patch diff → 更新 usage/tokens → **overflow 检测** → 触发 summary |
|
||||
| `finish` | stream 结束 |
|
||||
|
||||
### 5.3 Doom Loop 检测(processor.ts lines 351-377)
|
||||
|
||||
连续 3 次**完全相同的 tool call**(相同名称 + 相同输入)触发权限询问:
|
||||
|
||||
```typescript
|
||||
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD) // DOOM_LOOP_THRESHOLD = 3
|
||||
if (recentParts.length === DOOM_LOOP_THRESHOLD &&
|
||||
recentParts.every(part =>
|
||||
part.type === "tool" &&
|
||||
part.tool === value.name &&
|
||||
part.state.status !== "pending" &&
|
||||
JSON.stringify(part.state.input) === JSON.stringify(input)
|
||||
)) {
|
||||
yield* permission.ask({ permission: "doom_loop", ... })
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Compaction 工作流
|
||||
|
||||
两种触发方式:
|
||||
|
||||
| 触发条件 | 行为 |
|
||||
|---------|------|
|
||||
| step-finish 检测到 `isOverflow()` + `auto: true` | 创建 compaction task → 下一轮循环执行 → 压缩后 continue |
|
||||
| step-finish 检测到 `isOverflow()` + `auto: false` | 标记 `assistantMessage.error` → idle 等待用户干预 |
|
||||
|
||||
Compaction 使用专门的 `compaction` agent(hidden, mode=primary, `*=deny`)执行。
|
||||
压缩后的消息标记 `compacted: true`,后续通过 `MessageV2.filterCompactedEffect()` 过滤。
|
||||
|
||||
### 5.5 重试机制(processor.ts lines 658-672)
|
||||
|
||||
```typescript
|
||||
Effect.retry(
|
||||
SessionRetry.policy({
|
||||
provider: input.model.providerID,
|
||||
parse, // 错误解析(区分可重试/不可重试)
|
||||
set: (info) => status.set(sessionID, { type: "retry", ... }),
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
遇 provider 错误自动重试,LLM stream 完成后 `Effect.ensuring(cleanup)` 保证资源释放。
|
||||
|
||||
---
|
||||
|
||||
## 六、六种内置 Agent
|
||||
|
||||
| 名称 | Mode | Hidden | 用途 | 核心权限特征 |
|
||||
|------|------|--------|------|-------------|
|
||||
| `build` | primary | 否 | 默认 agent,全部工具 | question/plan_enter=allow |
|
||||
| `plan` | primary | 否 | 计划模式,禁用编辑 | edit=deny(除 plans), task(general)=deny |
|
||||
| `general` | subagent | 否 | 通用子代理 | todowrite=deny(默认禁止改 todo) |
|
||||
| `explore` | subagent | 否 | 只读代码探索 | `*=deny`,仅 read/grep/glob/bash/webfetch/websearch |
|
||||
| `compaction` | primary | 是 | 会话压缩(自动) | `*=deny` |
|
||||
| `title` | primary | 是 | 生成会话标题 | `*=deny`(step=1 时异步 fork) |
|
||||
| `summary` | primary | 是 | 生成消息摘要 | `*=deny`(每个 step-finish 时异步 fork) |
|
||||
|
||||
用户可通过 `config.agent` 自定义 agent(支持 `mode: "all"`),也可通过 `agent.generate` 让 LLM 辅助生成。
|
||||
|
||||
---
|
||||
|
||||
## 七、权限模型总结
|
||||
|
||||
```
|
||||
父 session permission
|
||||
│
|
||||
├── 仅继承 deny 规则 + external_directory 规则 ← subagent-permissions.ts
|
||||
│ (父 agent 的 allow 规则不传播到子代理)
|
||||
│
|
||||
├── 子代理自身 permission(来自 agent 定义)
|
||||
│
|
||||
├── 默认 deny:
|
||||
│ - todowrite(除非子代理明确允许)
|
||||
│ - task(除非子代理明确允许,默认防嵌套)
|
||||
│
|
||||
└── 主 agent 专有工具 deny(来自 config.experimental.primary_tools)
|
||||
```
|
||||
|
||||
子代理的 session 权限 = **父 deny + 父 external_directory + 自身 permission - 默认 deny - primary_tools deny**。
|
||||
|
||||
---
|
||||
|
||||
## 八、关键设计决策
|
||||
|
||||
| 决策 | 意图 | 效果/局限 |
|
||||
|------|------|----------|
|
||||
| 结果以 XML 纯文本嵌入上下文 | 简单、LLM 可直接理解 | LLM 自行解析 XML;大结果可能被截断 |
|
||||
| 无专用 merge 逻辑 | 简洁,不引入额外抽象 | 依赖 LLM 的理解能力处理返回结果 |
|
||||
| 默认禁止子代理嵌套 task | 防止无限递归 | 限制了多级分解场景 |
|
||||
| 同一消息多 tool call 并发 | 利用 LLM 并行能力 | 子 agent 隔离,无法协作 |
|
||||
| Effect.ts 贯穿全程 | 类型安全、结构化并发 | 学习曲线陡峭 |
|
||||
| session 作为隔离边界 | 天然权限/消息隔离 | 每个子 session 独立数据库记录,开销较大 |
|
||||
| 后台引擎纯内存 | 有意识取舍(注释说明) | 进程重启丢失状态 |
|
||||
|
||||
---
|
||||
|
||||
## 九、参考源码路径
|
||||
|
||||
| 文件 | 角色 |
|
||||
|------|------|
|
||||
| `packages/opencode/src/tool/task.ts` | Task tool 核心实现(调度入口) |
|
||||
| `packages/opencode/src/tool/task.txt` | Task tool 的 LLM 使用说明 |
|
||||
| `packages/opencode/src/agent/agent.ts` | Agent 定义注册中心 |
|
||||
| `packages/opencode/src/agent/subagent-permissions.ts` | 子代理权限推导 |
|
||||
| `packages/opencode/src/tool/registry.ts` | 工具注册 + `describeTask()` 列出可用子代理 |
|
||||
| `packages/opencode/src/session/prompt.ts` | 会话循环 + `handleSubtask()` + 提示词构建 |
|
||||
| `packages/opencode/src/session/processor.ts` | LLM stream 事件处理器 |
|
||||
| `packages/opencode/src/session/tools.ts` | Tool ↔ AI SDK 桥接 |
|
||||
| `packages/opencode/src/session/system.ts` | 系统提示词生成(含 Task tool 说明) |
|
||||
| `packages/opencode/src/background/job.ts` | 后台作业包装层 |
|
||||
| `packages/core/src/background-job.ts` | 后台作业核心引擎(内存注册表) |
|
||||
@@ -0,0 +1,109 @@
|
||||
# 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(13-18 已完成,19 待实施)
|
||||
>
|
||||
> 返回总入口:[`roadmap.md`](./roadmap.md)
|
||||
|
||||
---
|
||||
|
||||
## 全局愿景
|
||||
|
||||
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
|
||||
|
||||
**当前状态**:v0.2.0-rc.1 已打标签。Phase 0-18 全部完成。v0.3.0 实施中,Phase 19 共 1 个增量 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(流式接口前置) |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 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 新增 `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.3.0 Phase 19 启动**:KnowledgeGraph + 双通道检索,落地 `docs/note-knowledge-graph-design.md` 中记录的知识图谱设计
|
||||
2. **Phase 19 收尾**:完成 v0.3.0 最后一个 Phase 后准备 rc.1 标签 + CHANGELOG
|
||||
3. **示例先行**:完成 Phase 19 后立即创建对应的 knowledge_graph_demo 示例,确保 `cargo run --example` 可验证
|
||||
4. **里程碑追踪**:以 M13(Phase 17)+ M14(Phase 18)为已达成里程碑,逐 Phase 推进 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]` 预留扩展);`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 里程碑达成**
|
||||
@@ -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)
|
||||
+21
-362
@@ -1,364 +1,23 @@
|
||||
# AG Core Roadmap
|
||||
|
||||
> 定稿日期:2026-05-11
|
||||
> 最后更新:2026-07-04(v0.1 发布完成)
|
||||
|
||||
## 愿景
|
||||
|
||||
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
|
||||
|
||||
**当前状态**:Phase 0-4c 全部完成;Provider IR 重构(统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen Provider)已完成;LlmCycle 简化(IR 消息类型切换 + 桥接层移除)已完成;v0.1 发布就绪(**182 个测试通过、0 clippy 警告、7 个离线示例可运行**)。
|
||||
|
||||
---
|
||||
|
||||
## 模块完整性评估
|
||||
|
||||
| 功能领域 | 方案状态 | 文档位置 | 实现优先级 |
|
||||
|---------|---------|---------|-----------|
|
||||
| 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+)
|
||||
|
||||
> 以下功能在已完成的 phase 中已实现基础能力或在 Phase 4 阶段明确了边界,后续可按维度增量扩展。
|
||||
> 设计参考:见 `docs/note-agent-harness-references.md`(OpenClaw / Hermes / OpenHuman / OpenHarness 横向对比)。
|
||||
> OpenCode 借鉴:见 `docs/note-opencode-agent-switching.md`(Agent 切换 + System Prompt 拼接机制)。
|
||||
|
||||
### 已有扩展项(沿用)
|
||||
|
||||
| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 |
|
||||
|-------|---------|------|--------|------|
|
||||
| Prompt Optimizer | `prompt` | 提示词自动优化 | P3 | 待实现 |
|
||||
| 流式接口优化 | `llm/stream` | 流式响应解析与事件化 | P0 | ✅ 已完成基础实现 |
|
||||
|
||||
### v0.2+ 新增扩展项
|
||||
|
||||
> 以下为基于 Phase 4 设计讨论确定的 v0.2+ 候选扩展方向,按维度分组。
|
||||
> 标注为"v0.2 待评估"表示在 Phase 4 完成后再决定是否启动。
|
||||
|
||||
#### Multi-Agent / 协同
|
||||
|
||||
| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 |
|
||||
|-------|---------|------|--------|------|
|
||||
| Multi-Agent 协同(Swarm) | `agent` | 子 Agent 委派、并行子任务、结果聚合 | P2 | v0.2 待评估 |
|
||||
|
||||
#### 技能(Skills)
|
||||
|
||||
| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 |
|
||||
|-------|---------|------|--------|------|
|
||||
| Markdown 技能按需加载 | `agent` / `prompt` | 兼容 `SKILL.md` 格式(Hermes / OpenHarness 风格),按 prompt 上下文动态加载 | P2 | v0.2 待评估 |
|
||||
|
||||
#### 记忆(Memory)
|
||||
|
||||
| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 |
|
||||
|-------|---------|------|--------|------|
|
||||
| 多通道检索(hybrid) | `memory/retriever` | 在 TextOverlap 之上叠加向量检索通道 | P2 | v0.2 待评估 |
|
||||
| KnowledgeGraph 深度记忆 | `memory` | 实体-关系图、`note-knowledge-graph-design.md` 已记录设计 | P3 | v0.2 待评估 |
|
||||
| TokenJuice 智能压缩 | `memory` / `llm/compact` | 借鉴 OpenHuman TokenJuice,对工具结果做语义压缩而非字节截断 | P3 | v0.2 待评估 |
|
||||
|
||||
#### 交互层(TUI / Gateway)
|
||||
|
||||
| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 |
|
||||
|-------|---------|------|--------|------|
|
||||
| TUI / 多平台 Gateway | 应用层 | OpenClaw / Hermes 风格的消息平台桥接(Feishu / Telegram / Discord 等) | P3 | v0.2+ 应用层 |
|
||||
|
||||
#### 训练基础设施
|
||||
|
||||
| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 |
|
||||
|-------|---------|------|--------|------|
|
||||
| RL 轨迹导出 | `agent` | ShareGPT 格式轨迹、Atropos 集成(Hermes 风格) | P3 | v0.3+ 探索 |
|
||||
|
||||
#### 安全治理
|
||||
|
||||
| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 |
|
||||
|-------|---------|------|--------|------|
|
||||
| Human-in-the-loop 审批 | `agent` / `tools/permission` | 高危工具执行前的异步审批回调(OpenHarness `permission_prompt` 模式) | P2 | v0.2 待评估 |
|
||||
|
||||
#### 流式 / 实时
|
||||
|
||||
| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 |
|
||||
|-------|---------|------|--------|------|
|
||||
| 流式 `submit_turn` | `agent/session` | Phase 4 v1 只暴露非流式 `submit_turn()`;v0.2 包装 `LlmCycle::submit_stream` 暴露流式入口 | P2 | v0.2 待评估 |
|
||||
|
||||
#### Agent 切换 / Prompt 动态(OpenCode 借鉴)
|
||||
|
||||
| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 |
|
||||
|-------|---------|------|--------|------|
|
||||
| Agent 身份切换(角色轮换) | `agent` | 借鉴 OpenCode Tab 键切换 build/plan:同一 `AgentSession` 持有可热替换的 `Agent` 引用,切换时不重置消息历史,在末尾追加 `synthetic: true` 的状态变更消息。详见 `docs/note-opencode-agent-switching.md` §4 | P2 | v0.2 待评估 |
|
||||
| System Prompt 多层动态拼接 | `agent/session` | 借鉴 OpenCode `request.ts:58-66`:拆分 `base_prompt + agent_prompt + env_context` 三层,`AgentSession::submit_turn` 每轮重算(不缓存),便于按 agent 类型动态切换 | P2 | v0.2 待评估 |
|
||||
| **多 Context 切换** | `agent` | **Phase 4c 的 SessionMemory 数据结构已预留信息桥接通道,v0.2+ 在其上包装 `ContextManager` 实现完整的多 context 切换:创建/销毁/切换 context、通过 SessionMemory 桥接关键信息。详见 `docs/note-context-switch-design.md`** | P2 | v0.2 待评估 |
|
||||
|
||||
---
|
||||
|
||||
## 风险与建议
|
||||
|
||||
1. **Phase 0 已完成**:LLM 调用周期基础设施已全部实现,可以支撑后续模块开发
|
||||
2. **并行可能性**:Phase 0 和 Phase 1 可并行开展(无相互依赖),可加速早期交付
|
||||
3. **MCP 协议复杂性**:MCP 涉及协议握手、session 管理、长期连接,建议预留充足时间调研协议细节
|
||||
4. **Scope 蔓延风险**:当前 specs 只有 1 份文档,建议每个模块上线前都产出对应 spec,避免边实现边设计
|
||||
5. **Phase 4 抽象化边界**:AG Core 定位为"支持库"而非"Agent 产品",Phase 4(4a/4b/4c)需严格控制范围——只暴露 trait + 最小 reference impl,业务循环(多轮 turn 编排、对话记忆自动回写、Task 拆解策略)留给上层应用。`SessionMemory`(Phase 4c)提供信息桥接通道但不实现 context 切换逻辑。多 context 切换管理延后至 v0.2+。详细设计决策见 `docs/7-agent-runtime.md`
|
||||
6. **参考项目语言差异**:OpenClaw / Hermes / OpenHarness 均为 Python/TypeScript 实现,OpenHuman 虽是 Rust + Tauri 但定位是桌面应用。借鉴时**只取架构模式**,不照搬具体实现(如 Pydantic 工具校验、SQLite Memory Tree、Node+Python 双进程等)
|
||||
|
||||
---
|
||||
|
||||
## 下一步行动
|
||||
|
||||
1. **Phase 4c 已完成**:Phase 4a + 4b + 4c 已交付(116 测试通过,0 clippy 警告)。可启动 v0.2+ 扩展评估(如多 Context 切换、Multi-Agent 协同等)
|
||||
2. **Context 切换备忘**:`docs/note-context-switch-design.md` 记录了多 context 切换方案讨论,作为 v0.2+ 扩展项的输入
|
||||
3. **参考项目调研沉淀**:已完成 OpenClaw / Hermes / OpenHuman / OpenHarness 横向调研,结果沉淀至 `docs/note-agent-harness-references.md`,作为 v0.2+ 扩展项的输入
|
||||
4. **Phase 3 备用设计就绪**:`docs/note-knowledge-graph-design.md` 记录了 KnowledgeGraph、高级评分、RecallBased 淘汰等设计,v0.2+ 记忆扩展可直接参考
|
||||
|
||||
**已完成 / 进行中阶段**:
|
||||
- ✅ Phase 0 Foundation — 全部交付物已完成
|
||||
- ✅ Phase 1 Prompt Engineering — 全部交付物已完成
|
||||
- ✅ Phase 2 Tool System — 全部交付物已完成
|
||||
- ✅ Phase 3 Memory System — 全部交付物已完成
|
||||
- ✅ Phase 4a Core Glue — 全部交付物已完成
|
||||
- ✅ Phase 4b Task Execution — 全部交付物已完成
|
||||
- ✅ Phase 4c Session Memory — 全部交付物已完成
|
||||
- ✅ Provider IR 重构 — 统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen 适配(方案:`docs/10-llm-provider-refinement.md`、`docs/10a-phase0-types-and-trait.md`、`docs/10b-phase1-provider-adaptation.md`)
|
||||
- ✅ LlmCycle 简化 — IR 消息类型切换 + Phase 0 桥接层移除(方案:`docs/10c-phase2-llm-cycle-simplify.md`)
|
||||
- ✅ v0.1 Release — 技术债扫清、MockProvider 公开化、7 个离线示例、README + 错误消息友好化、Roadmap 同步、CHANGELOG 初始化(计划:`docs/11-v0.1-release-plan.md`)
|
||||
|
||||
---
|
||||
|
||||
## v0.1 发布里程碑(2026-07-04)
|
||||
|
||||
**质量基线**:
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| `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
|
||||
> 拆分式 roadmap:按版本归档 + 未归类内容
|
||||
> 最后更新:2026-07-20(Phase 28-30 OpenAI Response API Provider 交付 — 新增独立 feature `provider-openai-response`,450 测试通过)
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 范围 | 状态 |
|
||||
|------|------|------|
|
||||
| [`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-unsorted.md`](./roadmap-unsorted.md) | 未归到任何版本的内容 — 全局愿景、当前状态、模块完整性、v0.4+ 展望、风险与建议、下一步行动、阶段总回顾 | — |
|
||||
|
||||
## 阅读建议
|
||||
|
||||
- **按版本顺序追溯历史**:v0.1.0 → v0.2.0 → v0.3.0
|
||||
- **了解产品演进全貌**:从 `roadmap-unsorted.md` 顶部开始读
|
||||
- **查找特定 Phase**:每个版本文件内按 Phase 编号顺序排列
|
||||
- **了解项目当前关注点**:从 `roadmap-unsorted.md` 的「下一步行动」开始
|
||||
- **未来规划视野**:从 `roadmap-unsorted.md` 的「v0.4+ 展望」开始
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! agent_session_demo —— Agent 装配 + 会话链路 + SessionMemory 桥接。
|
||||
//! Required features: cargo run --example agent_session_demo --features "agent"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. 实现 `Agent` trait(定义角色 + system prompt)
|
||||
@@ -15,9 +16,9 @@ use std::sync::Arc;
|
||||
use agcore::agent::{Agent, AgentBuilder, AgentSession};
|
||||
use agcore::llm::hooks::HookExecutor;
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::types::Usage;
|
||||
use agcore::llm::types::message::{ContentBlock, Message};
|
||||
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
|
||||
use agcore::llm::types::Usage;
|
||||
use agcore::tools::ToolRegistry;
|
||||
|
||||
/// 计算器角色 Agent。
|
||||
@@ -72,7 +73,10 @@ async fn main() {
|
||||
|
||||
// 4. 提交第一轮
|
||||
println!("=== 提交第 1 轮 ===");
|
||||
let resp = session.submit_turn("1+1=?").await.expect("submit_turn 失败");
|
||||
let resp = session
|
||||
.submit_turn("1+1=?")
|
||||
.await
|
||||
.expect("submit_turn 失败");
|
||||
println!("LLM: {}", resp.text());
|
||||
session
|
||||
.set_session_data("last_q", "1+1=?")
|
||||
@@ -107,11 +111,7 @@ async fn main() {
|
||||
|
||||
// 8. 跨 session 数据隔离验证
|
||||
println!("=== 数据隔离验证 ===");
|
||||
let other = AgentSession::new(
|
||||
Arc::new(CalculatorAgent),
|
||||
"other-session",
|
||||
bundle,
|
||||
);
|
||||
let other = AgentSession::new(Arc::new(CalculatorAgent), "other-session", bundle);
|
||||
assert!(
|
||||
other.get_session_data("last_q").await.unwrap().is_none(),
|
||||
"新会话不应看到旧 session 的 last_q"
|
||||
|
||||
@@ -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 ===");
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! context_slot_demo —— 多上下文槽位管理示例。
|
||||
//! Required features: cargo run --example context_slot_demo --features "agent"
|
||||
//!
|
||||
//! 场景:法律咨询入口 → 派生两个独立探索方向 → 切换 → 隔离验证 → 删除。
|
||||
//!
|
||||
//! 展示:
|
||||
//! - 默认 slot 自动创建
|
||||
//! - 多 slot 间的消息隔离
|
||||
//! - 派生 slot 从父 slot 复制消息
|
||||
//! - 删除非 default slot 后自动回退到 default
|
||||
//!
|
||||
//! 运行:`cargo run --example context_slot_demo`(离线,零配置)
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agcore::agent::{Agent, AgentBuilder, AgentSession};
|
||||
use agcore::llm::LlmProvider;
|
||||
use agcore::llm::hooks::HookExecutor;
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::types::Usage;
|
||||
use agcore::llm::types::message::{ContentBlock, Message};
|
||||
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
|
||||
use agcore::tools::ToolRegistry;
|
||||
|
||||
struct LegalAdvisor;
|
||||
|
||||
impl Agent for LegalAdvisor {
|
||||
fn name(&self) -> &str {
|
||||
"legal-advisor"
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
Some("你是法律顾问。请用一句话回答用户问题。")
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造一个简单的 Assistant 响应(用于 MockProvider)。
|
||||
fn assistant_resp(text: &str) -> MessageResponse {
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: "mock".into(),
|
||||
message: Message::Assistant {
|
||||
content: vec![ContentBlock::Text { text: text.into() }],
|
||||
},
|
||||
usage: Usage::from_input_output(5, 5),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// 1. 构造 session(自动包含 default slot)
|
||||
let provider: Arc<dyn LlmProvider> = Arc::new(MockProvider::new(vec![
|
||||
assistant_resp("您好,我可以帮您处理法律问题。"),
|
||||
assistant_resp("管辖权问题:建议选择合同签订地法院。"),
|
||||
assistant_resp("条款修改:建议将上限调整为 80 万。"),
|
||||
assistant_resp("已回到主对话。"),
|
||||
]));
|
||||
let bundle = Arc::new(
|
||||
AgentBuilder::new()
|
||||
.provider(provider)
|
||||
.tool_registry(Arc::new(ToolRegistry::new()))
|
||||
.hook_executor(Arc::new(HookExecutor::new()))
|
||||
.build()
|
||||
.unwrap(),
|
||||
);
|
||||
let mut session = AgentSession::new(Arc::new(LegalAdvisor), "legal-001", bundle);
|
||||
|
||||
println!("=== 1. 默认 slot 自动创建 ===");
|
||||
assert_eq!(session.current_slot_id(), "default");
|
||||
let slots: Vec<_> = session.list_slots().collect();
|
||||
println!("初始 slots: {slots:?}");
|
||||
assert_eq!(slots.len(), 1);
|
||||
assert!(slots.contains(&&"default".to_string()));
|
||||
|
||||
println!("\n=== 2. 在 default slot 中提交一轮 ===");
|
||||
let r1 = session.submit_turn("我需要法律援助").await.unwrap();
|
||||
println!("default slot response: {}", r1.text());
|
||||
|
||||
println!("\n=== 3. 派生两个独立探索方向的 slot ===");
|
||||
session
|
||||
.derive_slot(
|
||||
"option_jurisdiction",
|
||||
"default",
|
||||
agcore::agent::DeriveStrategy::Full,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
session
|
||||
.derive_slot(
|
||||
"option_amendment",
|
||||
"default",
|
||||
agcore::agent::DeriveStrategy::Full,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let slots: Vec<_> = session.list_slots().cloned().collect();
|
||||
println!("派生后 slots: {slots:?}");
|
||||
assert_eq!(slots.len(), 3);
|
||||
|
||||
println!("\n=== 4. 切到 option_jurisdiction 并提交 ===");
|
||||
session.switch_slot("option_jurisdiction").await.unwrap();
|
||||
assert_eq!(session.current_slot_id(), "option_jurisdiction");
|
||||
let r2 = session.submit_turn("如果用户质疑管辖权?").await.unwrap();
|
||||
println!("option_jurisdiction response: {}", r2.text());
|
||||
|
||||
println!("\n=== 5. 切到 option_amendment 并提交 ===");
|
||||
session.switch_slot("option_amendment").await.unwrap();
|
||||
let r3 = session.submit_turn("用户要求提高赔偿上限?").await.unwrap();
|
||||
println!("option_amendment response: {}", r3.text());
|
||||
|
||||
println!("\n=== 6. 切回 default,验证消息隔离 ===");
|
||||
session.switch_slot("default").await.unwrap();
|
||||
let r4 = session.submit_turn("汇总一下我们的讨论").await.unwrap();
|
||||
println!("default response: {}", r4.text());
|
||||
// 验证 default slot 不包含 option_jurisdiction 的"管辖权"问题
|
||||
let (_, default_slot) = session.slots().find(|(id, _)| *id == "default").unwrap();
|
||||
let default_has_jurisdiction = default_slot
|
||||
.messages
|
||||
.iter()
|
||||
.any(|m| message_contains(m, "管辖权"));
|
||||
assert!(
|
||||
!default_has_jurisdiction,
|
||||
"default slot 不应包含 option_jurisdiction 的消息"
|
||||
);
|
||||
|
||||
println!("\n=== 7. 删除 option_amendment,验证回退到 default ===");
|
||||
session.delete_slot("option_amendment").await.unwrap();
|
||||
let slots: Vec<_> = session.list_slots().cloned().collect();
|
||||
println!("删除后 slots: {slots:?}");
|
||||
assert!(!slots.contains(&"option_amendment".to_string()));
|
||||
assert_eq!(slots.len(), 2);
|
||||
|
||||
println!("\n=== 8. 切到 option_jurisdiction 并删除,验证 current 回退 ===");
|
||||
session.switch_slot("option_jurisdiction").await.unwrap();
|
||||
session.delete_slot("option_jurisdiction").await.unwrap();
|
||||
assert_eq!(session.current_slot_id(), "default");
|
||||
let slots: Vec<_> = session.list_slots().cloned().collect();
|
||||
println!("删除后 slots: {slots:?}");
|
||||
assert_eq!(slots.len(), 1);
|
||||
assert_eq!(slots[0], "default");
|
||||
|
||||
println!("\n=== 9. 验证 delete_slot 保护逻辑 ===");
|
||||
let err = session.delete_slot("default").await.unwrap_err();
|
||||
println!("删除 default 返回错误: {err}");
|
||||
assert!(matches!(err, agcore::agent::AgentError::Config(_)));
|
||||
|
||||
println!("\n✓ context_slot_demo 完成");
|
||||
}
|
||||
|
||||
/// 检查 Message 是否包含指定文本(提取第一个 Text block)。
|
||||
fn message_contains(msg: &Message, needle: &str) -> bool {
|
||||
use agcore::llm::types::message::ContentBlock;
|
||||
let blocks = match msg {
|
||||
Message::System { content }
|
||||
| Message::User { content }
|
||||
| Message::Assistant { content } => content,
|
||||
Message::UserImage { .. } => return false,
|
||||
Message::ToolResult { content, .. } => content,
|
||||
_ => return false,
|
||||
};
|
||||
for block in blocks {
|
||||
if let ContentBlock::Text { text } = block
|
||||
&& text.contains(needle)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! conversation_memory_demo —— 对话记忆滑动窗口与隔离。
|
||||
//! Required features: cargo run --example conversation_memory_demo --features "memory"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. `ConversationMemoryConfig` 构造(SlidingWindow / Full 策略)
|
||||
@@ -29,6 +30,7 @@ fn message_text(msg: &Message) -> &str {
|
||||
.next()
|
||||
.unwrap_or(""),
|
||||
Message::UserImage { .. } => "[image]",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,11 +82,8 @@ async fn main() {
|
||||
// 3. 多角色混合 + clear
|
||||
println!("\n=== 多角色写入 + clear ===");
|
||||
let store3 = Arc::new(InMemoryStore::new());
|
||||
let mut memory3 = ConversationMemory::new(
|
||||
store3,
|
||||
"session-3",
|
||||
ConversationMemoryConfig::default(),
|
||||
);
|
||||
let mut memory3 =
|
||||
ConversationMemory::new(store3, "session-3", ConversationMemoryConfig::default());
|
||||
memory3
|
||||
.add_message(Message::user_text("你好"))
|
||||
.await
|
||||
@@ -98,7 +97,9 @@ async fn main() {
|
||||
.await
|
||||
.unwrap();
|
||||
memory3
|
||||
.add_message(Message::assistant("我无法查询实时天气,但你可以查看天气应用。"))
|
||||
.add_message(Message::assistant(
|
||||
"我无法查询实时天气,但你可以查看天气应用。",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
println!(
|
||||
@@ -119,16 +120,8 @@ async fn main() {
|
||||
// 4. Session 隔离
|
||||
println!("\n=== Session 隔离(共用 InMemoryStore)===");
|
||||
let store4 = Arc::new(InMemoryStore::new());
|
||||
let mut a = ConversationMemory::new(
|
||||
store4.clone(),
|
||||
"s-a",
|
||||
ConversationMemoryConfig::default(),
|
||||
);
|
||||
let mut b = ConversationMemory::new(
|
||||
store4.clone(),
|
||||
"s-b",
|
||||
ConversationMemoryConfig::default(),
|
||||
);
|
||||
let mut a = ConversationMemory::new(store4.clone(), "s-a", ConversationMemoryConfig::default());
|
||||
let mut b = ConversationMemory::new(store4.clone(), "s-b", ConversationMemoryConfig::default());
|
||||
a.add_message(Message::user_text("A 的消息")).await.unwrap();
|
||||
b.add_message(Message::user_text("B 的消息")).await.unwrap();
|
||||
println!(
|
||||
|
||||
+11
-15
@@ -1,4 +1,5 @@
|
||||
//! custom_tool —— 自定义工具注册、单次 / 并行调用、权限检查。
|
||||
//! Required features: cargo run --example custom_tool --features "tools,llm"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. 实现 `BaseTool` trait(WeatherTool + DeleteFileTool)
|
||||
@@ -17,7 +18,7 @@ use agcore::tools::{
|
||||
ToolRegistry,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// 天气查询工具 —— 模拟根据城市返回天气数据。
|
||||
struct WeatherTool;
|
||||
@@ -42,11 +43,7 @@ impl BaseTool for WeatherTool {
|
||||
fn required_permissions(&self) -> Vec<Permission> {
|
||||
vec![Permission::Network]
|
||||
}
|
||||
async fn execute(
|
||||
&self,
|
||||
args: Value,
|
||||
_ctx: &ToolContext<'_>,
|
||||
) -> Result<Value, ToolError> {
|
||||
async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<Value, ToolError> {
|
||||
let city = args["city"].as_str().unwrap_or("未知");
|
||||
// 模拟查询:根据城市名给出不同温度
|
||||
let (temperature, condition) = match city {
|
||||
@@ -84,11 +81,7 @@ impl BaseTool for DeleteFileTool {
|
||||
fn required_permissions(&self) -> Vec<Permission> {
|
||||
vec![Permission::Delete]
|
||||
}
|
||||
async fn execute(
|
||||
&self,
|
||||
_args: Value,
|
||||
_ctx: &ToolContext<'_>,
|
||||
) -> Result<Value, ToolError> {
|
||||
async fn execute(&self, _args: Value, _ctx: &ToolContext<'_>) -> Result<Value, ToolError> {
|
||||
Ok(json!({"deleted": true}))
|
||||
}
|
||||
}
|
||||
@@ -138,9 +131,8 @@ async fn main() {
|
||||
|
||||
// 5. 权限检查:默认 PermissionConfig 黑名单含 Delete
|
||||
println!("\n=== 权限检查(默认 PermissionConfig,denied = [Delete, Shell])===");
|
||||
let mut registry_with_checker = ToolRegistry::new().with_permission_checker(PermissionChecker::new(
|
||||
PermissionConfig::default(),
|
||||
));
|
||||
let mut registry_with_checker = ToolRegistry::new()
|
||||
.with_permission_checker(PermissionChecker::new(PermissionConfig::default()));
|
||||
registry_with_checker
|
||||
.register(Arc::new(WeatherTool) as ToolRef)
|
||||
.unwrap();
|
||||
@@ -155,7 +147,11 @@ async fn main() {
|
||||
.unwrap();
|
||||
println!(
|
||||
"get_weather 权限检查: {}",
|
||||
if r.output.is_ok() { "通过 ✓" } else { "阻断 ✗" }
|
||||
if r.output.is_ok() {
|
||||
"通过 ✓"
|
||||
} else {
|
||||
"阻断 ✗"
|
||||
}
|
||||
);
|
||||
|
||||
// delete_file 声明 Delete → 在 denied 列表 → 阻断
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! document_demo —— Document + RecursiveCharacterSplitter + MockEmbedding + RagPipeline 完整衔接示例。
|
||||
//! Required features: cargo run --example document_demo --features "memory,tracing-init"
|
||||
//!
|
||||
//! 演示 RAG 管线:
|
||||
//! 1. 创建多段落 Document
|
||||
//! 2. RecursiveCharacterSplitter 分割为 chunk
|
||||
//! 3. RagPipeline.ingest() 自动嵌入并存储
|
||||
//! 4. RagPipeline.retrieve() 做语义检索
|
||||
//!
|
||||
//! 运行:`cargo run --example document_demo`(离线,零配置)
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agcore::document::{Document, RecursiveCharacterSplitter};
|
||||
use agcore::llm::embedding::{Embedding, MockEmbedding};
|
||||
use agcore::memory::{InMemoryVectorStore, RagPipeline, VectorStore};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
agcore::init_tracing();
|
||||
|
||||
// 1. 创建多段落 Document(含中英文混合)
|
||||
let doc = Document::new(
|
||||
"rust-intro",
|
||||
"Rust 是一门系统编程语言,注重安全、并发和性能。\n\n\
|
||||
Rust 通过所有权系统管理内存,无需垃圾回收器。\
|
||||
所有权规则让内存安全在编译期就能得到保证。\n\n\
|
||||
Rust 的并发模型通过类型系统区分线程间共享与独占数据,\
|
||||
避免数据竞争。Send 和 Sync 两个 trait 标记了类型的线程安全性。\n\n\
|
||||
Rust 的性能与 C/C++ 相当,但提供了更现代的开发体验。\
|
||||
Cargo 是官方的构建系统和包管理器,使用简单直观。",
|
||||
"text/markdown",
|
||||
);
|
||||
|
||||
println!("输入文档: {} 字符", doc.content.chars().count());
|
||||
|
||||
// 2. 构造 RAG 管线(嵌入器 + 向量存储 + 分割器)
|
||||
let embedder: Arc<dyn Embedding> = Arc::new(MockEmbedding::new(4));
|
||||
let store: Arc<dyn VectorStore> = Arc::new(InMemoryVectorStore::new());
|
||||
let splitter = RecursiveCharacterSplitter::new(200, 30);
|
||||
let pipeline = RagPipeline::new(Arc::clone(&embedder), Arc::clone(&store), Some(splitter));
|
||||
|
||||
// 3. 一次性 ingest:自动 split → embed → add
|
||||
pipeline.ingest(std::slice::from_ref(&doc)).await.unwrap();
|
||||
|
||||
// 4. 模拟查询:复用第一个 chunk 的 content 作为查询文本
|
||||
let chunks_in_store = store.search(&[1.0, 0.0, 0.0, 0.0], 1).await.unwrap();
|
||||
assert!(!chunks_in_store.is_empty(), "ingest 后 store 应有数据");
|
||||
let query_text = &chunks_in_store[0].0.content;
|
||||
|
||||
let results = pipeline.retrieve(query_text, 3).await.unwrap();
|
||||
println!("\nTop 3 检索结果(与第一个 chunk 相似):");
|
||||
for (doc, score) in &results {
|
||||
println!(
|
||||
" id={}, score={:.4}, content={}",
|
||||
doc.id, score, doc.content
|
||||
);
|
||||
}
|
||||
|
||||
assert!(!results.is_empty(), "至少应返回 1 条检索结果");
|
||||
assert!(
|
||||
results[0].0.id.starts_with("rust-intro:chunk:0000"),
|
||||
"Top 1 应为 chunk 0 自身"
|
||||
);
|
||||
|
||||
println!("\n✓ document_demo 完成");
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! end_to_end —— 3 工具 + 3 轮对话 + SqliteStore 持久化跨连接验证。
|
||||
//! Required features: cargo run --example end_to_end --features "agent,memory-sqlite,provider-openai"
|
||||
//!
|
||||
//! 运行:`cargo run --example end_to_end`(离线,零配置)
|
||||
//!
|
||||
//! ## 真实 LLM Provider 切换
|
||||
//!
|
||||
//! 设置环境变量即可使用真实 LLM Provider:
|
||||
//! - `AG_LLM_BASE_URL` —— API 端点(如 `https://api.openai.com/v1`)
|
||||
//! - `AG_LLM_API_KEY` —— API key
|
||||
//! - `AG_LLM_MODEL` —— 模型名(如 `gpt-4o-mini`)
|
||||
//! - `AG_LLM_PROVIDER`(可选)—— Provider 类型,默认 OpenaiChat(OpenAI / DeepSeek / Qwen / Ollama)
|
||||
//!
|
||||
//! 未设置上述变量时自动降级为 MockProvider,零配置可运行。
|
||||
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
|
||||
use agcore::agent::{Agent, AgentBuilder, AgentSession};
|
||||
use agcore::llm::LlmProvider;
|
||||
use agcore::llm::hooks::HookExecutor;
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::provider::{ProviderConfig, ProviderType, create_provider};
|
||||
use agcore::llm::types::{
|
||||
Usage,
|
||||
message::{ContentBlock, Message},
|
||||
response_v2::{MessageResponse, StopReason},
|
||||
};
|
||||
use agcore::memory::store::{MemoryStore, SqliteStore};
|
||||
use agcore::memory::types::{MemoryFilter, MemoryItem};
|
||||
use agcore::tools::{BaseTool, ToolContext, ToolError, ToolRegistry};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
use tempfile::TempDir;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
// === Agent ===
|
||||
|
||||
struct AssistantAgent;
|
||||
impl Agent for AssistantAgent {
|
||||
fn name(&self) -> &str {
|
||||
"end-to-end assistant"
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
Some("简洁助手,必要时调用工具完成任务。")
|
||||
}
|
||||
}
|
||||
|
||||
// === Tools ===
|
||||
|
||||
struct EchoTool;
|
||||
#[async_trait]
|
||||
impl BaseTool for EchoTool {
|
||||
fn name(&self) -> &str {
|
||||
"echo"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"回显输入文本"
|
||||
}
|
||||
fn parameters(&self) -> Value {
|
||||
json!({"type":"object","properties":{"text":{"type":"string"}},"required":["text"]})
|
||||
}
|
||||
async fn execute(&self, args: Value, _: &ToolContext<'_>) -> Result<Value, ToolError> {
|
||||
let text = args.get("text").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
ToolError::InvalidArguments("text".into(), "需要 string 类型的 text 参数".into())
|
||||
})?;
|
||||
Ok(json!({"echoed": format!("收到: {text}")}))
|
||||
}
|
||||
}
|
||||
|
||||
/// 四则运算:'a op b' 格式(ponytail: 基础 +-*/ 不引入 rhai 依赖)。
|
||||
struct CalcTool;
|
||||
#[async_trait]
|
||||
impl BaseTool for CalcTool {
|
||||
fn name(&self) -> &str {
|
||||
"calc"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"四则运算:'a op b' 格式,op ∈ {+, -, *, /}"
|
||||
}
|
||||
fn parameters(&self) -> Value {
|
||||
json!({"type":"object","properties":{"expr":{"type":"string"}},"required":["expr"]})
|
||||
}
|
||||
async fn execute(&self, args: Value, _: &ToolContext<'_>) -> Result<Value, ToolError> {
|
||||
let expr = args["expr"].as_str().unwrap_or("");
|
||||
let parts: Vec<&str> = expr.split_whitespace().collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(ToolError::InvalidArguments(
|
||||
"expr".into(),
|
||||
"需要 'a op b' 三段式".into(),
|
||||
));
|
||||
}
|
||||
let a: i64 = parts[0].parse().map_err(|_| {
|
||||
ToolError::InvalidArguments("expr".into(), format!("无法解析 '{}'", parts[0]))
|
||||
})?;
|
||||
let b: i64 = parts[2].parse().map_err(|_| {
|
||||
ToolError::InvalidArguments("expr".into(), format!("无法解析 '{}'", parts[2]))
|
||||
})?;
|
||||
let result = match parts[1] {
|
||||
"+" => a + b,
|
||||
"-" => a - b,
|
||||
"*" => a * b,
|
||||
"/" => a
|
||||
.checked_div(b)
|
||||
.ok_or_else(|| ToolError::InvalidArguments("expr".into(), "除数不能为 0".into()))?,
|
||||
op => {
|
||||
return Err(ToolError::InvalidArguments(
|
||||
"expr".into(),
|
||||
format!("不支持的运算符: {op}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(json!({"result": result}))
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过 MemoryStore trait 读写笔记:直接持有 Arc<dyn MemoryStore>,
|
||||
/// 绕开 AgentSession 封装(NoteTool 在 tool.execute 中直接操作 store)。
|
||||
/// 关键前缀 "note:" 用于 list 过滤。
|
||||
struct NoteTool {
|
||||
store: Arc<dyn MemoryStore>,
|
||||
}
|
||||
impl NoteTool {
|
||||
const PREFIX: &'static str = "note:";
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BaseTool for NoteTool {
|
||||
fn name(&self) -> &str {
|
||||
"note"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"笔记 save/query: save(key, content) / query()"
|
||||
}
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"action":{"type":"string","enum":["save","query"]},
|
||||
"key":{"type":"string"},
|
||||
"content":{"type":"string"}
|
||||
},
|
||||
"required":["action"]
|
||||
})
|
||||
}
|
||||
async fn execute(&self, args: Value, _: &ToolContext<'_>) -> Result<Value, ToolError> {
|
||||
let action = args["action"].as_str().unwrap_or("");
|
||||
match action {
|
||||
"save" => {
|
||||
let key = args["key"].as_str().unwrap_or("");
|
||||
let content = args["content"].as_str().unwrap_or("");
|
||||
let item = MemoryItem {
|
||||
id: format!("{}{}", Self::PREFIX, key),
|
||||
content: content.to_string(),
|
||||
metadata: json!({}),
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
};
|
||||
self.store
|
||||
.save(item)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed("note".into(), e.to_string()))?;
|
||||
Ok(json!({"saved": key}))
|
||||
}
|
||||
"query" => {
|
||||
let filter = MemoryFilter {
|
||||
prefix: Some(Self::PREFIX.into()),
|
||||
..Default::default()
|
||||
};
|
||||
let items = self
|
||||
.store
|
||||
.list(&filter)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed("note".into(), e.to_string()))?;
|
||||
let notes: Vec<String> = items.into_iter().map(|i| i.content).collect();
|
||||
Ok(json!({"notes": notes}))
|
||||
}
|
||||
_ => Err(ToolError::InvalidArguments(
|
||||
"action".into(),
|
||||
format!("未知 action: {action}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Mock response helper ===
|
||||
|
||||
fn resp(content: Vec<ContentBlock>, stop: StopReason, u: (u32, u32)) -> MessageResponse {
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: "mock".into(),
|
||||
message: Message::Assistant { content },
|
||||
usage: Usage::from_input_output(u.0, u.1),
|
||||
stop_reason: stop,
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_responses() -> Vec<MessageResponse> {
|
||||
vec![
|
||||
// 第 1 轮:calc(25 * 4) → tool_result(100) → 文本回答
|
||||
resp(
|
||||
vec![ContentBlock::ToolUse {
|
||||
id: "t1".into(),
|
||||
name: "calc".into(),
|
||||
input: json!({"expr": "25 * 4"}),
|
||||
}],
|
||||
StopReason::ToolUse,
|
||||
(5, 8),
|
||||
),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "25 * 4 = 100".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(8, 12),
|
||||
),
|
||||
// 第 2 轮:note(save, last_calc, "100") → tool_result(saved) → 文本回答
|
||||
resp(
|
||||
vec![ContentBlock::ToolUse {
|
||||
id: "t2".into(),
|
||||
name: "note".into(),
|
||||
input: json!({"action": "save", "key": "last_calc", "content": "100"}),
|
||||
}],
|
||||
StopReason::ToolUse,
|
||||
(10, 14),
|
||||
),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "已记录:last_calc = 100".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(12, 16),
|
||||
),
|
||||
// 第 3 轮:note(query) → tool_result([100]) → 文本回答
|
||||
resp(
|
||||
vec![ContentBlock::ToolUse {
|
||||
id: "t3".into(),
|
||||
name: "note".into(),
|
||||
input: json!({"action": "query"}),
|
||||
}],
|
||||
StopReason::ToolUse,
|
||||
(8, 8),
|
||||
),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "您刚才的计算结果是 100".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(10, 14),
|
||||
),
|
||||
// 后续冗余响应(防止队列耗尽报错)
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "done".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(1, 1),
|
||||
),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "done".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(1, 1),
|
||||
),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "done".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(1, 1),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// === Provider selection ===
|
||||
|
||||
fn select_provider() -> Arc<dyn LlmProvider> {
|
||||
if env::var("AG_LLM_BASE_URL").is_ok() && env::var("AG_LLM_API_KEY").is_ok() {
|
||||
let cfg = ProviderConfig::from_env("AG_LLM").expect("AG_LLM_* 环境变量解析失败");
|
||||
let provider_type = env::var("AG_LLM_PROVIDER")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<ProviderType>().ok())
|
||||
.unwrap_or(ProviderType::OpenaiChat);
|
||||
Arc::from(create_provider(provider_type, cfg).expect("Provider 创建失败"))
|
||||
} else {
|
||||
let found: Vec<&str> = ["AG_LLM_BASE_URL", "AG_LLM_API_KEY", "AG_LLM_MODEL"]
|
||||
.iter()
|
||||
.filter(|k| env::var(k).is_ok())
|
||||
.copied()
|
||||
.collect();
|
||||
eprintln!(
|
||||
"AG_LLM_* 环境变量不完整(检测到: {:?}),回退到 MockProvider",
|
||||
found
|
||||
);
|
||||
Arc::new(MockProvider::new(mock_responses()))
|
||||
}
|
||||
}
|
||||
|
||||
// === Main ===
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
println!("=== agcore 端到端演示 ===");
|
||||
let dir = TempDir::new().expect("TempDir 创建失败");
|
||||
let db_path = dir.path().join("agcore.db");
|
||||
let backend: Arc<dyn MemoryStore> =
|
||||
Arc::new(SqliteStore::open(&db_path).expect("SqliteStore 打开失败"));
|
||||
println!("💾 SqliteStore: {}", db_path.display());
|
||||
let provider_label =
|
||||
if env::var("AG_LLM_BASE_URL").is_ok() && env::var("AG_LLM_API_KEY").is_ok() {
|
||||
"真实 LLM Provider"
|
||||
} else {
|
||||
"MockProvider (离线回退模式)"
|
||||
};
|
||||
println!("🔄 Provider: {provider_label}");
|
||||
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Arc::new(EchoTool)).unwrap();
|
||||
registry.register(Arc::new(CalcTool)).unwrap();
|
||||
registry
|
||||
.register(Arc::new(NoteTool {
|
||||
store: backend.clone(),
|
||||
}))
|
||||
.unwrap();
|
||||
println!("🔧 注册工具: {:?}", registry.list_tools());
|
||||
|
||||
let bundle = Arc::new(
|
||||
AgentBuilder::new()
|
||||
.provider(select_provider())
|
||||
.tool_registry(Arc::new(registry))
|
||||
.hook_executor(Arc::new(HookExecutor::new()))
|
||||
.build()
|
||||
.expect("RuntimeBundle 装配失败"),
|
||||
);
|
||||
|
||||
let mut session = AgentSession::new(Arc::new(AssistantAgent), "e2e-1", bundle.clone());
|
||||
|
||||
println!("\n第 1 轮 用户: 帮我算 25 * 4");
|
||||
let r1 = session
|
||||
.submit_turn("帮我算 25 * 4")
|
||||
.await
|
||||
.expect("turn 1 失败");
|
||||
println!(" → 回答: {}", r1.text());
|
||||
|
||||
println!("\n第 2 轮 用户: 记下来:结果是 100");
|
||||
let r2 = session
|
||||
.submit_turn("记下来:结果是 100")
|
||||
.await
|
||||
.expect("turn 2 失败");
|
||||
println!(" → 回答: {}", r2.text());
|
||||
|
||||
println!("\n第 3 轮 用户: 我刚才算了什么?");
|
||||
let r3 = session
|
||||
.submit_turn("我刚才算了什么?")
|
||||
.await
|
||||
.expect("turn 3 失败");
|
||||
println!(" → 回答: {}", r3.text());
|
||||
|
||||
let total = session.usage().total();
|
||||
println!(
|
||||
"\n📊 用量: prompt={}, completion={}, total={}",
|
||||
total.prompt_tokens, total.completion_tokens, total.total_tokens
|
||||
);
|
||||
|
||||
println!("\n=== 持久化验证 ===");
|
||||
// 显式释放所有对 backend 的 Arc 引用,确保 SqliteStore Connection 真正关闭。
|
||||
// 释放顺序:session → bundle(间接持有 NoteTool → backend clone)→ backend 局部变量。
|
||||
drop(session); // session.bundle Arc 计数 -1
|
||||
drop(bundle); // bundle Arc 计数归零 → registry → NoteTool → backend clone Arc 计数 2→1
|
||||
drop(backend); // backend 局部变量 Arc 计数 1→0 → SqliteStore::drop → Connection 自动 close
|
||||
let backend2: Arc<dyn MemoryStore> =
|
||||
Arc::new(SqliteStore::open(&db_path).expect("重开 SqliteStore 失败"));
|
||||
let filter = MemoryFilter {
|
||||
prefix: Some("note:".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let items = backend2.list(&filter).await.expect("list 失败");
|
||||
println!("✓ 跨连接数据存活: 找到 {} 条 note", items.len());
|
||||
assert!(!items.is_empty(), "持久化验证失败:重开后无数据");
|
||||
for i in &items {
|
||||
println!(" - {} = {}", i.id, i.content);
|
||||
}
|
||||
|
||||
println!("\n✓ 端到端演示完成");
|
||||
}
|
||||
@@ -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`
|
||||
@@ -38,14 +39,26 @@ async fn main() {
|
||||
let ks = KnowledgeStore::new(store);
|
||||
|
||||
let pages = vec![
|
||||
make_page("rust-1", "Rust 入门", "Rust 是一门系统级编程语言,注重安全性与并发。"),
|
||||
make_page("python-1", "Python 简介", "Python 是一门动态类型的高级编程语言。"),
|
||||
make_page(
|
||||
"rust-1",
|
||||
"Rust 入门",
|
||||
"Rust 是一门系统级编程语言,注重安全性与并发。",
|
||||
),
|
||||
make_page(
|
||||
"python-1",
|
||||
"Python 简介",
|
||||
"Python 是一门动态类型的高级编程语言。",
|
||||
),
|
||||
make_page(
|
||||
"langgraph-1",
|
||||
"LangGraph 框架",
|
||||
"LangGraph 是 LangChain 的状态图扩展,用于构建多步 Agent。",
|
||||
),
|
||||
make_page("rust-async", "Rust 异步编程", "Rust 异步基于 tokio 与 futures 抽象。"),
|
||||
make_page(
|
||||
"rust-async",
|
||||
"Rust 异步编程",
|
||||
"Rust 异步基于 tokio 与 futures 抽象。",
|
||||
),
|
||||
];
|
||||
for p in &pages {
|
||||
ks.add_page(p.clone()).await.expect("保存页面失败");
|
||||
@@ -62,14 +75,15 @@ async fn main() {
|
||||
let result = retriever.retrieve("Rust 异步").await.unwrap();
|
||||
println!("query: {}", result.query);
|
||||
for item in &result.items {
|
||||
println!(
|
||||
" 命中: {} (score={:.3})",
|
||||
item.page.title, item.score
|
||||
);
|
||||
assert!(
|
||||
(0.0..=1.0).contains(&item.score),
|
||||
"score 应在 [0, 1] 区间"
|
||||
);
|
||||
match item {
|
||||
agcore::memory::RetrievalItem::KnowledgePage { page, score } => {
|
||||
println!(" 命中: {} (score={:.3})", page.title, score);
|
||||
assert!((0.0..=1.0).contains(score), "score 应在 [0, 1] 区间");
|
||||
}
|
||||
agcore::memory::RetrievalItem::GraphEntity { entity, score, .. } => {
|
||||
println!(" 命中实体: {} (score={:.3})", entity.name, score);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(!result.items.is_empty(), "应至少命中一个页面");
|
||||
|
||||
@@ -83,16 +97,11 @@ async fn main() {
|
||||
let cfg = RetrieverConfig {
|
||||
max_results: 20,
|
||||
min_score: 0.5,
|
||||
graph_depth: 2,
|
||||
};
|
||||
let retriever2 = MemoryRetriever::new(ks2, cfg);
|
||||
let result = retriever2
|
||||
.retrieve("完全不相关的火锅配方")
|
||||
.await
|
||||
.unwrap();
|
||||
println!(
|
||||
"无关 query → items.len = {} (期望 0)",
|
||||
result.items.len()
|
||||
);
|
||||
let result = retriever2.retrieve("完全不相关的火锅配方").await.unwrap();
|
||||
println!("无关 query → items.len = {} (期望 0)", result.items.len());
|
||||
assert!(result.items.is_empty());
|
||||
|
||||
// 4. max_results 截断
|
||||
@@ -111,6 +120,7 @@ async fn main() {
|
||||
let cfg = RetrieverConfig {
|
||||
max_results: 2,
|
||||
min_score: 0.0,
|
||||
graph_depth: 2,
|
||||
};
|
||||
let retriever3 = MemoryRetriever::new(ks3, cfg);
|
||||
let result = retriever3.retrieve("Rust").await.unwrap();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! prompt_composer —— 提示词模板与组合器离线示例。
|
||||
//! Required features: cargo run --example prompt_composer --features "prompt,llm"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. `PromptTemplate::compile` + `render` 变量插值(`{{var}}` 语法)
|
||||
@@ -11,7 +12,7 @@
|
||||
|
||||
use agcore::llm::types::message::{ContentBlock, Message};
|
||||
use agcore::prompt::{
|
||||
validate_messages, PromptComposer, PromptTemplate, PromptTemplateRegistry, TemplateContext,
|
||||
PromptComposer, PromptTemplate, PromptTemplateRegistry, TemplateContext, validate_messages,
|
||||
};
|
||||
|
||||
fn message_text(msg: &Message) -> String {
|
||||
@@ -27,15 +28,15 @@ fn message_text(msg: &Message) -> String {
|
||||
})
|
||||
.collect(),
|
||||
Message::UserImage { .. } => "[image]".into(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// 1. PromptTemplate::compile + render —— 直接构造模板
|
||||
println!("=== PromptTemplate::compile + render ===");
|
||||
let tpl = PromptTemplate::compile(
|
||||
"今日 {{location}} 天气:{{condition}},温度 {{temperature}}",
|
||||
)
|
||||
let tpl =
|
||||
PromptTemplate::compile("今日 {{location}} 天气:{{condition}},温度 {{temperature}}")
|
||||
.expect("编译失败");
|
||||
let mut ctx = TemplateContext::new();
|
||||
ctx.insert("location", "北京");
|
||||
@@ -58,7 +59,10 @@ fn main() {
|
||||
.register("weather", "今日 {{location}}:{{condition}}")
|
||||
.expect("注册失败");
|
||||
registry
|
||||
.register("greet", "你好 {{name}}!{{#if formal}} 见到您很荣幸。{{/if}}")
|
||||
.register(
|
||||
"greet",
|
||||
"你好 {{name}}!{{#if formal}} 见到您很荣幸。{{/if}}",
|
||||
)
|
||||
.expect("注册失败");
|
||||
|
||||
let mut ctx = TemplateContext::new();
|
||||
@@ -88,6 +92,7 @@ fn main() {
|
||||
Message::User { .. } | Message::UserImage { .. } => "user",
|
||||
Message::Assistant { .. } => "assistant",
|
||||
Message::ToolResult { .. } => "tool",
|
||||
_ => "unknown",
|
||||
};
|
||||
println!("[{i}] {role}: {}", message_text(m));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! quick_start —— 30 行最小可运行示例,展示 Agent / BaseTool / Builder / Session 四层抽象。
|
||||
//! Required features: cargo run --example quick_start --features "agent"
|
||||
//!
|
||||
//! 运行:`cargo run --example quick_start`(离线,零配置)
|
||||
|
||||
use agcore::agent::{Agent, AgentBuilder, AgentSession};
|
||||
use agcore::llm::LlmProvider;
|
||||
use agcore::llm::hooks::HookExecutor;
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::types::{
|
||||
Usage,
|
||||
message::{ContentBlock, Message},
|
||||
response_v2::{MessageResponse, StopReason},
|
||||
};
|
||||
use agcore::tools::{BaseTool, ToolContext, ToolError, ToolRegistry};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Greeter;
|
||||
impl Agent for Greeter {
|
||||
fn name(&self) -> &str {
|
||||
"greeter"
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
Some("中文助手,先调用 echo 工具,再总结。")
|
||||
}
|
||||
}
|
||||
|
||||
struct EchoTool;
|
||||
#[async_trait]
|
||||
impl BaseTool for EchoTool {
|
||||
fn name(&self) -> &str {
|
||||
"echo"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"回显文本"
|
||||
}
|
||||
fn parameters(&self) -> Value {
|
||||
json!({"type":"object","properties":{"text":{"type":"string"}},"required":["text"]})
|
||||
}
|
||||
async fn execute(&self, args: Value, _: &ToolContext<'_>) -> Result<Value, ToolError> {
|
||||
let text = args.get("text").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
ToolError::InvalidArguments("text".into(), "需要 string 类型的 text 参数".into())
|
||||
})?;
|
||||
Ok(json!({"echoed": format!("收到: {text}")}))
|
||||
}
|
||||
}
|
||||
|
||||
fn resp(content: Vec<ContentBlock>, stop: StopReason, u: (u32, u32)) -> MessageResponse {
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: "mock".into(),
|
||||
message: Message::Assistant { content },
|
||||
usage: Usage::from_input_output(u.0, u.1),
|
||||
stop_reason: stop,
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Arc::new(EchoTool)).unwrap();
|
||||
let provider: Arc<dyn LlmProvider> = Arc::new(MockProvider::new(vec![
|
||||
resp(
|
||||
vec![ContentBlock::ToolUse {
|
||||
id: "c1".into(),
|
||||
name: "echo".into(),
|
||||
input: json!({"text": "你好"}),
|
||||
}],
|
||||
StopReason::ToolUse,
|
||||
(5, 8),
|
||||
),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "EchoTool 已收到您的消息并完成回传。".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(8, 16),
|
||||
),
|
||||
]));
|
||||
let bundle = Arc::new(
|
||||
AgentBuilder::new()
|
||||
.provider(provider)
|
||||
.tool_registry(Arc::new(registry))
|
||||
.hook_executor(Arc::new(HookExecutor::new()))
|
||||
.build()
|
||||
.unwrap(),
|
||||
);
|
||||
let mut session = AgentSession::new(Arc::new(Greeter), "qs", bundle);
|
||||
let resp = session.submit_turn("你好").await.unwrap();
|
||||
let text = resp.text();
|
||||
println!("LLM: {text}");
|
||||
assert!(text.contains("收到"), "响应应包含'收到'字样: {text}");
|
||||
println!("\n✓ quick_start 完成");
|
||||
}
|
||||
@@ -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,9 +1,11 @@
|
||||
//! Required features: cargo run --example simple_visit --features "llm,provider-openai,tracing-init"
|
||||
|
||||
use std::env;
|
||||
|
||||
use agcore::init_tracing;
|
||||
use agcore::llm::{
|
||||
cycle::{CycleConfig, LlmCycle},
|
||||
provider::{create_provider, ProviderConfig, ProviderType},
|
||||
provider::{ProviderConfig, ProviderType, create_provider},
|
||||
types::{message::ContentBlock, message::Message, response_v2::MessageResponse},
|
||||
};
|
||||
|
||||
@@ -51,10 +53,11 @@ async fn main() {
|
||||
base_url,
|
||||
api_key,
|
||||
model: model.clone(),
|
||||
timeout_secs: 30,
|
||||
max_retries: 3,
|
||||
};
|
||||
|
||||
let provider = create_provider(provider_type, config)
|
||||
.expect("创建 Provider 失败");
|
||||
let provider = create_provider(provider_type, config).expect("创建 Provider 失败");
|
||||
|
||||
let cycle_config = CycleConfig {
|
||||
model,
|
||||
@@ -63,9 +66,9 @@ async fn main() {
|
||||
..CycleConfig::default()
|
||||
};
|
||||
|
||||
let mut cycle = LlmCycle::new(provider, cycle_config).with_messages(vec![
|
||||
Message::system("你是一个简洁的助手,对于任何问题都是用一句话回答。"),
|
||||
]);
|
||||
let mut cycle = LlmCycle::new(provider, cycle_config).with_messages(vec![Message::system(
|
||||
"你是一个简洁的助手,对于任何问题都是用一句话回答。",
|
||||
)]);
|
||||
|
||||
println!("发送请求...");
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! streaming_events_demo —— LLM 流式响应事件流消费(含错误路径)。
|
||||
//! Required features: cargo run --example streaming_events_demo --features "llm,provider-openai"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. `MockProvider::chat_stream` 输出标准 `StreamEvent` 流(离线可跑)
|
||||
@@ -14,12 +15,12 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agcore::llm::LlmProvider;
|
||||
use agcore::llm::cycle::{CycleConfig, LlmCycle};
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::provider::LlmProvider;
|
||||
use agcore::llm::types::Usage;
|
||||
use agcore::llm::types::message::{ContentBlock, Message};
|
||||
use agcore::llm::types::response_v2::{MessageResponse, StopReason, StreamEvent};
|
||||
use agcore::llm::types::Usage;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
/// 构造预设的纯文本响应。
|
||||
@@ -99,9 +100,7 @@ async fn main() {
|
||||
// 上层 Agent 通过 `match` 或 `?` 处理 `AgentError::Llm(_)`。
|
||||
println!("\n=== 阶段 2:错误路径(队列耗尽)===");
|
||||
let mut cycle = LlmCycle::new_with_arc(dyn_provider, CycleConfig::default());
|
||||
let result = cycle
|
||||
.submit_stream("第二次提问".to_string(), vec![])
|
||||
.await;
|
||||
let result = cycle.submit_stream("第二次提问".to_string(), vec![]).await;
|
||||
match result {
|
||||
Ok(_) => panic!("阶段 2 必须失败(队列耗尽)"),
|
||||
Err(e) => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+26
-24
@@ -1,4 +1,5 @@
|
||||
//! task_agent_demo —— Plan 解析、Step 状态机、错误路径。
|
||||
//! Required features: cargo run --example task_agent_demo --features "agent"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. `JsonPlanParser::parse` 解析合法 JSON 输入
|
||||
@@ -8,27 +9,13 @@
|
||||
//! 5. 错误路径:非法 JSON / 空 steps / 缺字段 → `AgentError::PlanParse`
|
||||
//!
|
||||
//! 运行:`cargo run --example task_agent_demo`
|
||||
//!
|
||||
//! ## 已知技术债(v0.2 迁移指南)
|
||||
//!
|
||||
//! 本示例使用 `#[deprecated]` 标记的旧 wire-format 类型:
|
||||
//! - `ChatResponse`、`OpenaiChatMessage`、`FinishReason` —— `OpenaiChatProvider::chat_inner()`
|
||||
//! 内部转换层仍在使用(参见 `docs/10a-phase0-types-and-trait.md` §2.5.1),
|
||||
//! 故结构体定义保留。
|
||||
//! - `StepStatus::Completed(ChatResponse)` —— 因为 `Step` 的"已完成"变体需携带
|
||||
//! provider 响应,目前沿用旧的 `ChatResponse`。
|
||||
//!
|
||||
//! **触发迁移的条件**:v0.2 引入 IR 层的 `StepResult` / 切换为 `MessageResponse`。
|
||||
//! **迁移路径**:将本文件 `ChatResponse`/`OpenaiChatMessage`/`FinishReason` 替换为
|
||||
//! `MessageResponse`/`Message`/`StopReason`,移除顶部 `#![allow(deprecated)]`。
|
||||
//! 上层应用代码(`TaskAgent` 消费者)也可同步迁移。
|
||||
|
||||
#![allow(deprecated)]
|
||||
use std::collections::HashMap;
|
||||
|
||||
use agcore::agent::{AgentError, JsonPlanParser, PlanParser, Step, StepStatus};
|
||||
use agcore::llm::types::openai_message::OpenaiChatMessage;
|
||||
use agcore::llm::types::shared::FinishReason;
|
||||
use agcore::llm::types::{ChatResponse, Usage};
|
||||
use agcore::llm::types::Usage;
|
||||
use agcore::llm::types::message::Message;
|
||||
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@@ -67,21 +54,36 @@ async fn main() {
|
||||
assert!(step.status.is_pending());
|
||||
|
||||
step.status = StepStatus::Running;
|
||||
println!("Running: pending={}, terminal={}", step.status.is_pending(), step.status.is_terminal());
|
||||
println!(
|
||||
"Running: pending={}, terminal={}",
|
||||
step.status.is_pending(),
|
||||
step.status.is_terminal()
|
||||
);
|
||||
|
||||
step.status = StepStatus::Completed(ChatResponse {
|
||||
message: OpenaiChatMessage::assistant_text("天气:晴,22°C"),
|
||||
step.status = StepStatus::Completed(MessageResponse {
|
||||
id: String::new(),
|
||||
model: "mock".into(),
|
||||
message: Message::assistant("天气:晴,22°C"),
|
||||
usage: Usage::from_input_output(5, 10),
|
||||
stop_reason: Some(FinishReason::Stop),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: HashMap::new(),
|
||||
});
|
||||
println!("Completed: pending={}, terminal={}", step.status.is_pending(), step.status.is_terminal());
|
||||
println!(
|
||||
"Completed: pending={}, terminal={}",
|
||||
step.status.is_pending(),
|
||||
step.status.is_terminal()
|
||||
);
|
||||
assert!(step.status.is_terminal());
|
||||
|
||||
// 3. 失败路径
|
||||
println!("\n=== Step 状态机:失败路径 ===");
|
||||
let mut fail_step = Step::new(0, "调用天气 API");
|
||||
fail_step.status = StepStatus::Failed(AgentError::Other("API 不可用".into()));
|
||||
println!("Failed: pending={}, terminal={}", fail_step.status.is_pending(), fail_step.status.is_terminal());
|
||||
println!(
|
||||
"Failed: pending={}, terminal={}",
|
||||
fail_step.status.is_pending(),
|
||||
fail_step.status.is_terminal()
|
||||
);
|
||||
assert!(fail_step.status.is_terminal());
|
||||
|
||||
// 4. 跳过路径
|
||||
|
||||
+8
-1
@@ -11,18 +11,25 @@
|
||||
|
||||
pub mod agent;
|
||||
pub mod builder;
|
||||
pub mod context;
|
||||
pub mod error;
|
||||
pub mod runtime;
|
||||
pub mod session;
|
||||
pub mod session_memory;
|
||||
pub mod summary;
|
||||
pub mod task;
|
||||
|
||||
// 重导出公共 API(按使用频度排序)
|
||||
pub use agent::Agent;
|
||||
pub use builder::AgentBuilder;
|
||||
pub use context::{
|
||||
ContextBudget, ContextSlot, DeriveStrategy, FocusedConfig, MergeStrategy, SlotConfig, SlotMeta,
|
||||
SlotMode, SlotSource,
|
||||
};
|
||||
pub use error::AgentError;
|
||||
pub use runtime::{AgentConfig, RuntimeBundle};
|
||||
pub use session::AgentSession;
|
||||
pub use session_memory::SessionMemory;
|
||||
pub use task::{Plan, PlanParser, Step, StepStatus, TaskAgent};
|
||||
pub use summary::SummaryConfig;
|
||||
pub use task::JsonPlanParser;
|
||||
pub use task::{Plan, PlanParser, Step, StepStatus, TaskAgent};
|
||||
|
||||
+2
-4
@@ -7,14 +7,12 @@
|
||||
//! - **不绑定业务循环**:`submit_turn` 在 `AgentSession` 上,不在 trait 上
|
||||
|
||||
use crate::agent::runtime::RuntimeBundle;
|
||||
#[allow(deprecated)]
|
||||
use crate::llm::types::ToolDefinition;
|
||||
use crate::llm::types::tool::ToolDef;
|
||||
|
||||
/// Agent 角色抽象。
|
||||
///
|
||||
/// 实现此 trait 即可接入 Agent Runtime。典型实现是 struct 持有静态配置(name、system prompt 模板),
|
||||
/// 也可以是基于配置动态生成的轻量实现。
|
||||
#[allow(deprecated)]
|
||||
pub trait Agent: Send + Sync {
|
||||
/// 角色名(用于日志、调试、UI 展示)。
|
||||
fn name(&self) -> &str;
|
||||
@@ -26,7 +24,7 @@ pub trait Agent: Send + Sync {
|
||||
///
|
||||
/// **默认实现**:从 `bundle.tool_registry` 取全部工具(最常用模式)。
|
||||
/// **子 trait / 具体实现可覆盖**:做白名单、过滤、按状态动态调整等。
|
||||
fn tool_definitions(&self, bundle: &RuntimeBundle) -> Vec<ToolDefinition> {
|
||||
fn tool_definitions(&self, bundle: &RuntimeBundle) -> Vec<ToolDef> {
|
||||
bundle.tool_registry.definitions()
|
||||
}
|
||||
}
|
||||
|
||||
+20
-8
@@ -11,8 +11,9 @@ use std::sync::Arc;
|
||||
|
||||
use crate::agent::error::AgentError;
|
||||
use crate::agent::runtime::{AgentConfig, RuntimeBundle};
|
||||
use crate::agent::summary::SummaryConfig;
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::llm::hooks::HookExecutor;
|
||||
use crate::llm::provider::LlmProvider;
|
||||
use crate::memory::retriever::MemoryRetriever;
|
||||
use crate::memory::store::MemoryStore;
|
||||
use crate::tools::ToolRegistry;
|
||||
@@ -86,21 +87,32 @@ impl AgentBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置摘要自动生成配置(覆盖字段,而非整体覆盖 config)。
|
||||
/// 不传则沿用现有 `config.summary_config`(默认 `None`,即关闭)。
|
||||
pub fn summary_config(mut self, cfg: SummaryConfig) -> Self {
|
||||
let mut config = self.config.take().unwrap_or_default();
|
||||
config.summary_config = Some(cfg);
|
||||
self.config = Some(config);
|
||||
self
|
||||
}
|
||||
|
||||
/// 构造 `RuntimeBundle`,校验必填字段。
|
||||
///
|
||||
/// **错误**:`provider` / `tool_registry` / `hook_executor` 任一缺失则返回
|
||||
/// `AgentError::Config(...)`,提示调用 `.provider(...)` / `.tool_registry(...)` /
|
||||
/// `.hook_executor(...)` 补齐。不 panic。
|
||||
pub fn build(self) -> Result<RuntimeBundle, AgentError> {
|
||||
let provider = self
|
||||
.provider
|
||||
.ok_or_else(|| AgentError::Config("缺少 LLM provider,请先调用 .provider(...)".into()))?;
|
||||
let provider = self.provider.ok_or_else(|| {
|
||||
AgentError::Config("缺少 LLM provider,请先调用 .provider(...)".into())
|
||||
})?;
|
||||
let tool_registry = self
|
||||
.tool_registry
|
||||
.ok_or_else(|| AgentError::Config("缺少 tool_registry,请先调用 .tool_registry(...)(即使是空 ToolRegistry 也需要传入)".into()))?;
|
||||
let hook_executor = self
|
||||
.hook_executor
|
||||
.ok_or_else(|| AgentError::Config("缺少 hook_executor,请先调用 .hook_executor(...)(空 HookExecutor 也可)".into()))?;
|
||||
let hook_executor = self.hook_executor.ok_or_else(|| {
|
||||
AgentError::Config(
|
||||
"缺少 hook_executor,请先调用 .hook_executor(...)(空 HookExecutor 也可)".into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let config = self.config.unwrap_or_default();
|
||||
|
||||
@@ -120,9 +132,9 @@ impl AgentBuilder {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
|
||||
use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
use async_trait::async_trait;
|
||||
use futures_core::Stream;
|
||||
use std::pin::Pin;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+54
-3
@@ -18,6 +18,7 @@ use crate::tools::error::ToolError;
|
||||
/// **不实现 `Clone`**:透传内层 `LlmError` / `MemoryError`,两者均未派生 `Clone`(保留
|
||||
/// 完整错误信息,传递所有权)。如需在多 session 间共享错误状态,用 `Arc<AgentError>` 包装。
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum AgentError {
|
||||
/// LLM 调用错误(透传 Phase 0)。
|
||||
#[error("LLM 错误: {0}")]
|
||||
@@ -35,6 +36,18 @@ pub enum AgentError {
|
||||
#[error("Plan 解析错误: {0}")]
|
||||
PlanParse(String),
|
||||
|
||||
/// Readonly slot 不允许写入(Phase 10 新增)。
|
||||
#[error("Readonly slot 不允许写入: {0}")]
|
||||
SlotReadonly(String),
|
||||
|
||||
/// Slot 不存在(Phase 10 新增)。
|
||||
#[error("Slot '{0}' 不存在")]
|
||||
SlotNotFound(String),
|
||||
|
||||
/// Slot 已存在(Phase 10 新增)。
|
||||
#[error("Slot '{0}' 已存在")]
|
||||
SlotAlreadyExists(String),
|
||||
|
||||
/// 钩子阻断操作(Agent 层特有)。
|
||||
#[error("钩子阻断: {0}")]
|
||||
HookBlocked(String),
|
||||
@@ -59,6 +72,7 @@ impl AgentError {
|
||||
/// - `Tool`:由内层 `is_recoverable()` 决定
|
||||
/// - `HookBlocked` / `LimitExceeded`:不可恢复(需人工介入或终止循环)
|
||||
/// - `Config` / `Other`:不可恢复
|
||||
/// - `SlotReadonly` / `SlotNotFound` / `SlotAlreadyExists`:不可恢复(结构性错误)
|
||||
pub fn is_recoverable(&self) -> bool {
|
||||
match self {
|
||||
Self::Llm(e) => matches!(
|
||||
@@ -68,9 +82,13 @@ impl AgentError {
|
||||
Self::Tool(e) => e.is_recoverable(),
|
||||
Self::Memory(e) => e.is_recoverable(),
|
||||
Self::PlanParse(_) => false,
|
||||
Self::HookBlocked(_) | Self::LimitExceeded(_) | Self::Config(_) | Self::Other(_) => {
|
||||
false
|
||||
}
|
||||
Self::SlotReadonly(_)
|
||||
| Self::SlotNotFound(_)
|
||||
| Self::SlotAlreadyExists(_)
|
||||
| Self::HookBlocked(_)
|
||||
| Self::LimitExceeded(_)
|
||||
| Self::Config(_)
|
||||
| Self::Other(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,4 +198,37 @@ mod tests {
|
||||
let err = caller().unwrap_err();
|
||||
assert!(matches!(err, AgentError::Memory(_)));
|
||||
}
|
||||
|
||||
// ====== Phase 10: Slot 错误变体测试 ======
|
||||
|
||||
#[test]
|
||||
fn slot_readonly_not_recoverable() {
|
||||
assert!(!AgentError::SlotReadonly("readonly".into()).is_recoverable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_not_found_not_recoverable() {
|
||||
assert!(!AgentError::SlotNotFound("missing".into()).is_recoverable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_already_exists_not_recoverable() {
|
||||
assert!(!AgentError::SlotAlreadyExists("dup".into()).is_recoverable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_error_messages() {
|
||||
assert_eq!(
|
||||
format!("{}", AgentError::SlotReadonly("readonly".into())),
|
||||
"Readonly slot 不允许写入: readonly"
|
||||
);
|
||||
assert_eq!(
|
||||
format!("{}", AgentError::SlotNotFound("foo".into())),
|
||||
"Slot 'foo' 不存在"
|
||||
);
|
||||
assert_eq!(
|
||||
format!("{}", AgentError::SlotAlreadyExists("bar".into())),
|
||||
"Slot 'bar' 已存在"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::agent::summary::SummaryConfig;
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::llm::compact::CompactConfig;
|
||||
use crate::llm::provider::LlmProvider;
|
||||
use crate::llm::hooks::HookExecutor;
|
||||
use crate::memory::retriever::MemoryRetriever;
|
||||
use crate::memory::store::MemoryStore;
|
||||
@@ -33,6 +34,10 @@ pub struct AgentConfig {
|
||||
pub session_ttl: Option<Duration>,
|
||||
/// 上下文压缩配置(None 表示不启用自动压缩),默认 None。
|
||||
pub compact_config: Option<CompactConfig>,
|
||||
/// 摘要自动生成配置(`None` = 不启用)。
|
||||
/// 设置后 `AgentSession` 每轮 OnTurnEnd 之后进行水位 + 防抖检查,触发时调 LLM
|
||||
/// 生成摘要并写入 `FocusedConfig.summary_override` 与 `SessionMemory["conversation_summary"]`。
|
||||
pub summary_config: Option<SummaryConfig>,
|
||||
}
|
||||
|
||||
impl Default for AgentConfig {
|
||||
@@ -42,6 +47,7 @@ impl Default for AgentConfig {
|
||||
max_tool_turns: 10,
|
||||
session_ttl: None,
|
||||
compact_config: None,
|
||||
summary_config: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1418
-100
File diff suppressed because it is too large
Load Diff
+58
-12
@@ -44,12 +44,38 @@ impl SessionMemory {
|
||||
}
|
||||
|
||||
/// 写入一条 key-value 条目(覆盖同名 key)。
|
||||
///
|
||||
/// **不保留 metadata 和 created_at** —— 写入时 metadata 为空 JSON `{}`,created_at 为 `now_utc()`。
|
||||
/// 若需保留这两个字段(如 checkpoint rollback),使用 [`Self::set_with_meta`]。
|
||||
pub async fn set(&self, key: &str, value: &str) -> Result<(), AgentError> {
|
||||
self.set_with_meta(key, value, serde_json::json!({}), None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 写入一条 key-value 条目(含完整 metadata + created_at)。
|
||||
///
|
||||
/// Phase 17 新增:供 `AgentSession::restore_memory()` 使用,保证 checkpoint rollback 时
|
||||
/// 恢复完整的 session_memory 条目(包括原 metadata 和创建时间戳)。
|
||||
///
|
||||
/// - `metadata`: 通常为 `serde_json::Value`(快照中保留的 metadata JSON)
|
||||
/// - `created_at`: 快照中的原始时间戳(Unix 秒);若为 `None` 则用 `now_utc()`(默认行为)
|
||||
pub async fn set_with_meta(
|
||||
&self,
|
||||
key: &str,
|
||||
value: &str,
|
||||
metadata: serde_json::Value,
|
||||
created_at: Option<i64>,
|
||||
) -> Result<(), AgentError> {
|
||||
let created_at_dt = match created_at {
|
||||
Some(secs) => OffsetDateTime::from_unix_timestamp(secs)
|
||||
.unwrap_or_else(|_| OffsetDateTime::now_utc()),
|
||||
None => OffsetDateTime::now_utc(),
|
||||
};
|
||||
let item = MemoryItem {
|
||||
id: self.internal_key(key),
|
||||
content: value.to_string(),
|
||||
metadata: serde_json::json!({}),
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
metadata,
|
||||
created_at: created_at_dt,
|
||||
};
|
||||
self.store.save(item).await.map_err(AgentError::Memory)
|
||||
}
|
||||
@@ -78,11 +104,7 @@ impl SessionMemory {
|
||||
prefix: Some(format!("{}:", self.namespace)),
|
||||
..Default::default()
|
||||
};
|
||||
let items = self
|
||||
.store
|
||||
.list(&filter)
|
||||
.await
|
||||
.map_err(AgentError::Memory)?;
|
||||
let items = self.store.list(&filter).await.map_err(AgentError::Memory)?;
|
||||
|
||||
let mut lines = Vec::with_capacity(items.len() + 2);
|
||||
lines.push("<session-context>".to_string());
|
||||
@@ -107,17 +129,41 @@ impl SessionMemory {
|
||||
.map_err(AgentError::Memory)
|
||||
}
|
||||
|
||||
/// 列出当前 namespace 下所有条目(含完整 `MemoryItem`:value / metadata / created_at)。
|
||||
///
|
||||
/// Phase 17 新增:供 `AgentSession::to_snapshot()` 拍平 session_memory 时使用,
|
||||
/// 保留 metadata 和 created_at 时间戳(用 `set/get/remove` 三个 API 会丢字段)。
|
||||
///
|
||||
/// 返回 `Vec<(原始 key, value, metadata, created_at_unix_secs)>`,原始 key 已剥离 namespace 前缀。
|
||||
pub async fn list_entries(
|
||||
&self,
|
||||
) -> Result<Vec<(String, String, serde_json::Value, i64)>, AgentError> {
|
||||
let filter = MemoryFilter {
|
||||
prefix: Some(format!("{}:", self.namespace)),
|
||||
..Default::default()
|
||||
};
|
||||
let items = self.store.list(&filter).await.map_err(AgentError::Memory)?;
|
||||
let prefix_with_colon = format!("{}:", self.namespace);
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
let key = item
|
||||
.id
|
||||
.strip_prefix(&prefix_with_colon)
|
||||
.unwrap_or(&item.id)
|
||||
.to_string();
|
||||
let created_at_unix = item.created_at.unix_timestamp();
|
||||
out.push((key, item.content, item.metadata, created_at_unix));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 清空当前 namespace 下所有条目。
|
||||
pub async fn clear(&self) -> Result<(), AgentError> {
|
||||
let filter = MemoryFilter {
|
||||
prefix: Some(format!("{}:", self.namespace)),
|
||||
..Default::default()
|
||||
};
|
||||
let items = self
|
||||
.store
|
||||
.list(&filter)
|
||||
.await
|
||||
.map_err(AgentError::Memory)?;
|
||||
let items = self.store.list(&filter).await.map_err(AgentError::Memory)?;
|
||||
|
||||
for item in items {
|
||||
self.store
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! 摘要自动生成 —— 在长对话中自动压缩上下文。
|
||||
//!
|
||||
//! 通过 `AgentSession` 内联检查点检测 token 水位,调用 LLM 生成摘要,
|
||||
//! 写入 `FocusedConfig.summary_override` 与 `SessionMemory["conversation_summary"]`。
|
||||
//!
|
||||
//! 关闭端位于 `FocusedConfig::filter_focused`(见 `agent/context.rs`)。
|
||||
|
||||
use crate::llm::types::message::{ContentBlock, Message};
|
||||
|
||||
/// 默认摘要 prompt(含 `{messages}` 占位符,运行期替换为对话历史文本)。
|
||||
pub const DEFAULT_SUMMARY_PROMPT: &str = "请为以下对话生成一个简洁的中文摘要,突出关键结论、用户偏好和重要上下文信息。保持客观,不要添加对话中不存在的信息。\n\n{messages}";
|
||||
|
||||
/// 摘要自动生成配置(opt-in:通过 `AgentBuilder::summary_config(cfg)` 启用)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SummaryConfig {
|
||||
/// Token 水位触发比例(0.0 ~ 1.0)。
|
||||
pub trigger_token_ratio: f64,
|
||||
|
||||
/// 模型上下文窗口大小(token)。
|
||||
/// ⚠️ 设置为超过模型实际窗口的值会导致摘要永远不触发。
|
||||
pub max_context_tokens: u32,
|
||||
|
||||
/// 摘要 prompt 模板。`{messages}` 将被替换为对话历史纯文本。
|
||||
pub summary_prompt: String,
|
||||
|
||||
/// 摘要间隔防抖(轮次):两次摘要至少间隔这么多次 `submit_turn`。
|
||||
pub debounce_turns: u32,
|
||||
|
||||
/// 摘要生成使用的模型(`None` = 沿用主 provider 默认模型)。
|
||||
/// 推荐设为便宜模型(如 `"gpt-4o-mini"`)以节省摘要成本。
|
||||
pub summary_model: Option<String>,
|
||||
|
||||
/// 单个 `ToolResult` 在摘要输入中保留的最大 Unicode 字符数。
|
||||
/// 超过此值从开头截断(`chars().take(n)`,字符级安全)。
|
||||
pub max_tool_result_chars: usize,
|
||||
}
|
||||
|
||||
impl Default for SummaryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
trigger_token_ratio: 0.75,
|
||||
max_context_tokens: 32_000,
|
||||
summary_prompt: DEFAULT_SUMMARY_PROMPT.into(),
|
||||
debounce_turns: 3,
|
||||
summary_model: None,
|
||||
max_tool_result_chars: 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把消息列表格式化为摘要 LLM 所需的纯文本(简洁版)。
|
||||
///
|
||||
/// 每行一条消息:
|
||||
/// - `System/User/Assistant` 取首个 `Text` block 拼接
|
||||
/// - `Assistant` 中的 `ToolUse` 标记为 `[Tool: {name}]`
|
||||
/// - `ToolResult` 标记为 `Tool Result [{tool_call_id}]:`(含 tool_call_id 以便多工具场景关联)
|
||||
/// - 长 `ToolResult` 截断到 `max_tool_result_chars` 个字符
|
||||
///
|
||||
/// 整段对话若超过 `30_000` 字符,从前面截断,**优先保留最新消息**,
|
||||
/// 因为新近交互对摘要而言更有信息量。
|
||||
pub fn format_messages_as_text(messages: &[Message], max_tool_result_chars: usize) -> String {
|
||||
let mut lines = Vec::with_capacity(messages.len());
|
||||
for msg in messages {
|
||||
match msg {
|
||||
Message::System { content } => {
|
||||
if let Some(text) = first_text(content) {
|
||||
lines.push(format!("System: {}", text));
|
||||
}
|
||||
}
|
||||
Message::User { content } => {
|
||||
if let Some(text) = first_text(content) {
|
||||
lines.push(format!("User: {}", text));
|
||||
}
|
||||
}
|
||||
Message::Assistant { content } => {
|
||||
let mut parts = Vec::new();
|
||||
for block in content {
|
||||
match block {
|
||||
ContentBlock::Text { text } => parts.push(text.clone()),
|
||||
ContentBlock::ToolUse { name, .. } => {
|
||||
parts.push(format!("[Tool: {}]", name));
|
||||
}
|
||||
ContentBlock::Thinking { text, .. } => {
|
||||
parts.push(format!("[Thinking: {}]", truncate_chars(text, 100)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
lines.push(format!("Assistant: {}", parts.join(" ")));
|
||||
}
|
||||
}
|
||||
Message::UserImage { .. } => {
|
||||
lines.push("User: [image]".to_string());
|
||||
}
|
||||
Message::ToolResult {
|
||||
tool_call_id,
|
||||
content,
|
||||
is_error,
|
||||
} => {
|
||||
let label = if *is_error {
|
||||
"Tool Error"
|
||||
} else {
|
||||
"Tool Result"
|
||||
};
|
||||
if let Some(text) = first_text(content) {
|
||||
let truncated = truncate_chars(text, max_tool_result_chars);
|
||||
lines.push(format!("{} [{}]: {}", label, tool_call_id, truncated));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let joined = lines.join("\n");
|
||||
truncate_total_chars(&joined, MAX_TOTAL_CHARS)
|
||||
}
|
||||
|
||||
/// 整段对话输出字符上限。超过时从前面截断,保留尾部最新消息。
|
||||
const MAX_TOTAL_CHARS: usize = 30_000;
|
||||
|
||||
fn truncate_total_chars(s: &str, max_chars: usize) -> String {
|
||||
let total = s.chars().count();
|
||||
if total <= max_chars {
|
||||
return s.to_string();
|
||||
}
|
||||
// 计算需要从前面丢弃的字符数。保留窗口从 (total - max_chars) 开始。
|
||||
let skip = total - max_chars;
|
||||
let dropped: String = s.chars().take(skip).collect();
|
||||
let mut kept = String::with_capacity(max_chars + 8);
|
||||
kept.push_str("[... earlier messages truncated ...]\n");
|
||||
// 字节切安全:dropped 由 s.chars().take(skip).collect() 构建,
|
||||
// 每个 char 的 UTF-8 字节序列完整保留,故 dropped.len() 恰好是 s 的某个 char 边界字节偏移。
|
||||
kept.push_str(&s[dropped.len()..]);
|
||||
kept
|
||||
}
|
||||
|
||||
fn first_text(content: &[ContentBlock]) -> Option<&str> {
|
||||
content.iter().find_map(|b| match b {
|
||||
ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn truncate_chars(s: &str, max_chars: usize) -> String {
|
||||
if s.chars().count() <= max_chars {
|
||||
return s.to_string();
|
||||
}
|
||||
let truncated: String = s.chars().take(max_chars).collect();
|
||||
format!("{}...", truncated)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_values() {
|
||||
let cfg = SummaryConfig::default();
|
||||
assert_eq!(cfg.trigger_token_ratio, 0.75);
|
||||
assert_eq!(cfg.max_context_tokens, 32_000);
|
||||
assert_eq!(cfg.debounce_turns, 3);
|
||||
assert_eq!(cfg.max_tool_result_chars, 500);
|
||||
assert!(cfg.summary_model.is_none());
|
||||
assert!(cfg.summary_prompt.contains("{messages}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_skips_empty_input() {
|
||||
let text = format_messages_as_text(&[], 500);
|
||||
assert!(text.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_user_assistant_round_trip() {
|
||||
let msgs = vec![
|
||||
Message::system("you are a translator"),
|
||||
Message::user_text("hello"),
|
||||
Message::assistant("hi"),
|
||||
];
|
||||
let text = format_messages_as_text(&msgs, 500);
|
||||
assert!(text.contains("System: you are a translator"));
|
||||
assert!(text.contains("User: hello"));
|
||||
assert!(text.contains("Assistant: hi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tool_result_includes_tool_call_id() {
|
||||
let msgs = vec![Message::tool_result("call_42", "ok", false)];
|
||||
let text = format_messages_as_text(&msgs, 500);
|
||||
assert_eq!(text, "Tool Result [call_42]: ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tool_result_error_label() {
|
||||
let msgs = vec![Message::tool_result("call_9", "boom", true)];
|
||||
let text = format_messages_as_text(&msgs, 500);
|
||||
assert_eq!(text, "Tool Error [call_9]: boom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tool_use_in_assistant() {
|
||||
let msgs = vec![Message::Assistant {
|
||||
content: vec![
|
||||
ContentBlock::Text {
|
||||
text: "let me search".into(),
|
||||
},
|
||||
ContentBlock::ToolUse {
|
||||
id: "c1".into(),
|
||||
name: "search".into(),
|
||||
input: serde_json::json!({"q": "rust"}),
|
||||
},
|
||||
],
|
||||
}];
|
||||
let text = format_messages_as_text(&msgs, 500);
|
||||
assert_eq!(text, "Assistant: let me search [Tool: search]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_truncates_long_tool_result_at_unicode_boundary() {
|
||||
let long = "a".repeat(1000);
|
||||
let msgs = vec![Message::tool_result("c", &long, false)];
|
||||
let text = format_messages_as_text(&msgs, 100);
|
||||
// 100 chars + "..."
|
||||
assert!(text.contains("..."));
|
||||
let truncated_part = text.split("...").next().unwrap();
|
||||
// "Tool Result [c]: " is 18 chars, plus 100 a's
|
||||
let a_count = truncated_part.chars().filter(|c| *c == 'a').count();
|
||||
assert_eq!(a_count, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_total_charset_truncation_keeps_recent() {
|
||||
// 50 段 user 消息,每段 1000 字符 = ~50K,触发 30K 整体截断
|
||||
let mut msgs = Vec::new();
|
||||
for _ in 0..50 {
|
||||
msgs.push(Message::user_text("x".repeat(1000)));
|
||||
}
|
||||
let text = format_messages_as_text(&msgs, 500);
|
||||
// 总字符数 ≤ 30K + prefix "[... earlier messages truncated ...]\n"
|
||||
assert!(text.chars().count() <= 30_000 + 40);
|
||||
// 头部有截断标记
|
||||
assert!(text.contains("[... earlier messages truncated ...]"));
|
||||
// 最后一行的标记字符 (30 个 x) 应保留在末尾
|
||||
assert!(text.ends_with("xxxxxxxxxx"));
|
||||
}
|
||||
}
|
||||
+5
-11
@@ -10,8 +10,7 @@
|
||||
//! - 重试由上层新建 `Plan` 实现,`TaskAgent` 不做自动重试
|
||||
|
||||
use crate::agent::error::AgentError;
|
||||
#[allow(deprecated)]
|
||||
use crate::llm::types::ChatResponse;
|
||||
use crate::llm::types::response_v2::MessageResponse;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
@@ -56,14 +55,14 @@ impl Step {
|
||||
/// 均未派生 `Clone`(保留原始错误信息,传递所有权而非克隆)。如需复制 `Plan`,
|
||||
/// 只能 clone 处于 `Pending` / `Running` / `Completed` / `Skipped` 状态的步骤。
|
||||
#[derive(Debug)]
|
||||
#[allow(deprecated)]
|
||||
#[non_exhaustive]
|
||||
pub enum StepStatus {
|
||||
/// 初始状态 —— 等待执行。
|
||||
Pending,
|
||||
/// 正在执行(`TaskAgent::execute_plan` 进入)。
|
||||
Running,
|
||||
/// 已完成(含 LLM 响应)。
|
||||
Completed(ChatResponse),
|
||||
Completed(MessageResponse),
|
||||
/// 失败(含错误)。
|
||||
Failed(AgentError),
|
||||
/// 跳过(上层主动跳过)。
|
||||
@@ -130,9 +129,7 @@ impl PlanParser for JsonPlanParser {
|
||||
.collect::<Result<Vec<_>, AgentError>>()?;
|
||||
|
||||
if steps.is_empty() {
|
||||
return Err(AgentError::PlanParse(
|
||||
"Plan 至少需要一个步骤".into(),
|
||||
));
|
||||
return Err(AgentError::PlanParse("Plan 至少需要一个步骤".into()));
|
||||
}
|
||||
|
||||
Ok(Plan {
|
||||
@@ -203,10 +200,7 @@ mod tests {
|
||||
let plan = Plan {
|
||||
id: "p1".into(),
|
||||
goal: "test goal".into(),
|
||||
steps: vec![
|
||||
Step::new(0, "first"),
|
||||
Step::new(1, "second"),
|
||||
],
|
||||
steps: vec![Step::new(0, "first"), Step::new(1, "second")],
|
||||
};
|
||||
assert_eq!(plan.steps.len(), 2);
|
||||
assert_eq!(plan.steps[0].index, 0);
|
||||
|
||||
+630
@@ -0,0 +1,630 @@
|
||||
//! Document 系统 —— 文本分割与文档类型。
|
||||
//!
|
||||
//! 提供 [`Document`] 数据结构和 [`RecursiveCharacterSplitter`] 分割器,
|
||||
//! 作为 RAG 管线(split → embed → store)的前置步骤。
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 默认分隔符优先级列表(按优先级降序)。
|
||||
///
|
||||
/// 段落级 → 行级 → 句子级(含 CJK 标点) → 词级 → 字符级(兜底)。
|
||||
/// 在 LangChain 基础上扩充了 CJK 句号 `"。"`、问号 `"?"`、感叹号 `"!"`,
|
||||
/// 确保中文文本在句子边界有更高分割质量。
|
||||
const DEFAULT_SEPARATORS: &[&str] = &["\n\n", "\n", "。", "?", "!", ".", " ", ""];
|
||||
|
||||
/// 文档片段 —— RAG 管线的基本数据载体。
|
||||
///
|
||||
/// 作为分割(split)和向量化(embed)两个阶段的通货类型,
|
||||
/// 在 Phase 15 的 RagPipeline 中串联 split → embed → store。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Document {
|
||||
/// 文档唯一标识。
|
||||
pub id: String,
|
||||
/// 文档文本内容。
|
||||
pub content: String,
|
||||
/// 元数据标签(键值对,可用作过滤、溯源、分类)。
|
||||
pub metadata: HashMap<String, String>,
|
||||
/// MIME 类型,标识内容格式(如 "text/plain", "text/markdown")。
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// 创建一个新文档。元数据默认初始化为空。
|
||||
///
|
||||
/// 分割器产生的 chunks 会自动继承源文档 mime_type,
|
||||
/// 并在 metadata 中追加 source_id / chunk_index / chunk_count。
|
||||
pub fn new(
|
||||
id: impl Into<String>,
|
||||
content: impl Into<String>,
|
||||
mime_type: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
content: content.into(),
|
||||
metadata: HashMap::new(),
|
||||
mime_type: mime_type.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 快速构造纯文本文档(mime_type 默认为 "text/plain")。
|
||||
/// 适用于大多数无需指定媒体类型的场景。
|
||||
pub fn from_raw(id: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
content: content.into(),
|
||||
metadata: HashMap::new(),
|
||||
mime_type: "text/plain".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归字符级文档分割器。
|
||||
///
|
||||
/// 使用可配置的分隔符优先级列表,递归地将文档分割为
|
||||
/// 接近 chunk_size 的块。
|
||||
///
|
||||
/// # 算法(两阶段)
|
||||
///
|
||||
/// 1. **递归分割**:按分隔符优先级从高到低递归切割文本,
|
||||
/// 产生初始片段(均 ≤ chunk_size,按字符数计算)。
|
||||
///
|
||||
/// 2. **贪心合并**:从左向右合并相邻片段,直到合计字符数
|
||||
/// 超过 chunk_size,此时将前一组合并结果作为一个 chunk 输出,
|
||||
/// 并携带 chunk_overlap 字符的滑动窗口。
|
||||
///
|
||||
/// 所有长度比较均以 Unicode 字符数为单位(`text.chars().count()`),
|
||||
/// 而非字节数。CJK 文本每个字算 1 个 char。
|
||||
///
|
||||
/// # 升级路径
|
||||
///
|
||||
/// - 如需自定义分割函数,可在上层通过 `with_custom_splitter`
|
||||
/// 扩展(当前未实现,预留升级路径)。
|
||||
/// - 如需 unicode 感知的句子分割(如中文句号、缩写处理),
|
||||
/// 可在 separators 中加入对应字符串,或将下游替换为
|
||||
/// 基于 unicode-segmentation crate 的自定义分割器。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecursiveCharacterSplitter {
|
||||
chunk_size: usize,
|
||||
chunk_overlap: usize,
|
||||
separators: Vec<String>,
|
||||
}
|
||||
|
||||
impl RecursiveCharacterSplitter {
|
||||
/// 创建分割器。
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// - 如果 `chunk_size == 0`
|
||||
/// - 如果 `chunk_size ≤ chunk_overlap`(无法形成有效滑动窗口)
|
||||
pub fn new(chunk_size: usize, chunk_overlap: usize) -> Self {
|
||||
if chunk_size == 0 {
|
||||
panic!("chunk_size must be greater than 0");
|
||||
}
|
||||
if chunk_size <= chunk_overlap {
|
||||
panic!("chunk_size must be greater than chunk_overlap");
|
||||
}
|
||||
Self {
|
||||
chunk_size,
|
||||
chunk_overlap,
|
||||
separators: DEFAULT_SEPARATORS.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建分割器的安全版本。
|
||||
///
|
||||
/// 验证失败时返回 `Err` 而非 panic。
|
||||
pub fn try_new(chunk_size: usize, chunk_overlap: usize) -> Result<Self, &'static str> {
|
||||
if chunk_size == 0 {
|
||||
return Err("chunk_size must be greater than 0");
|
||||
}
|
||||
if chunk_size <= chunk_overlap {
|
||||
return Err("chunk_size must be greater than chunk_overlap");
|
||||
}
|
||||
Ok(Self {
|
||||
chunk_size,
|
||||
chunk_overlap,
|
||||
separators: DEFAULT_SEPARATORS.iter().map(|s| s.to_string()).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 覆盖默认分隔符优先级列表。
|
||||
///
|
||||
/// **重要**:建议保留 `""` 作为最后一个 separator,
|
||||
/// 作为字符级兜底防止任何文本都能被分割。
|
||||
pub fn with_separators(mut self, separators: Vec<String>) -> Self {
|
||||
self.separators = separators;
|
||||
self
|
||||
}
|
||||
|
||||
/// 返回 chunk_size(字符数)。
|
||||
pub fn chunk_size(&self) -> usize {
|
||||
self.chunk_size
|
||||
}
|
||||
|
||||
/// 返回 chunk_overlap(字符数)。
|
||||
pub fn chunk_overlap(&self) -> usize {
|
||||
self.chunk_overlap
|
||||
}
|
||||
|
||||
/// 批量分割。
|
||||
///
|
||||
/// 每个输入文档独立分割。输出 chunks 继承源文档的 mime_type,
|
||||
/// 并在 metadata 中追加 source_id / chunk_index / chunk_count。
|
||||
///
|
||||
/// Chunk ID 格式:`{source_id}:chunk:{index:04d}`
|
||||
/// 例如 `"doc_001:chunk:0000"`(索引从 0 开始,4 位固定宽度)。
|
||||
///
|
||||
/// **注意**:metadata 注入使用 `HashMap::insert()`,如果源 Document
|
||||
/// 的 metadata 已包含 `"source_id"`、`"chunk_index"` 或 `"chunk_count"`
|
||||
/// 键,将被分割器的值静默覆盖。
|
||||
pub fn split(&self, documents: &[Document]) -> Vec<Document> {
|
||||
tracing::debug!(
|
||||
input_count = documents.len(),
|
||||
"RecursiveCharacterSplitter::split start"
|
||||
);
|
||||
|
||||
let mut output = Vec::new();
|
||||
for doc in documents {
|
||||
let segments = self.split_text(&doc.content, &self.separators);
|
||||
let chunks = self.merge_with_overlap(segments);
|
||||
debug_assert!(
|
||||
chunks.len() < 10_000,
|
||||
"单个文档产生超过 9999 个 chunk,索引格式溢出"
|
||||
);
|
||||
|
||||
tracing::trace!(
|
||||
doc_id = %doc.id,
|
||||
chunk_count = chunks.len(),
|
||||
"document split into chunks"
|
||||
);
|
||||
|
||||
for (idx, chunk_text) in chunks.iter().enumerate() {
|
||||
let mut metadata = doc.metadata.clone();
|
||||
metadata.insert("source_id".to_string(), doc.id.clone());
|
||||
metadata.insert("chunk_index".to_string(), idx.to_string());
|
||||
metadata.insert("chunk_count".to_string(), chunks.len().to_string());
|
||||
|
||||
let id = format!("{}:chunk:{:04}", doc.id, idx);
|
||||
tracing::trace!(chunk_id = %id, "chunk produced");
|
||||
|
||||
output.push(Document {
|
||||
id,
|
||||
content: chunk_text.clone(),
|
||||
metadata,
|
||||
mime_type: doc.mime_type.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// 递归分割(Phase 1)。
|
||||
///
|
||||
/// 按 separator 优先级从高到低切割文本。每个输出片段的字符数
|
||||
/// 均 ≤ chunk_size(除非最终降到 `""` 字符级兜底)。
|
||||
///
|
||||
/// Phase 1 只做"切分",不做合并——合并由 Phase 2 (`merge_with_overlap`) 处理。
|
||||
///
|
||||
/// **关键行为**:当文本中存在 separator 时,按 separator 切分。
|
||||
/// 若所有 segment 均 ≤ chunk_size,直接返回所有 segments;
|
||||
/// 若某个 segment > chunk_size,递归降级到下一级 separator。
|
||||
///
|
||||
/// **早返回守卫**:如果整段文本 ≤ chunk_size(含恰好等于),直接
|
||||
/// 返回 `[text.to_string()]`,避免在 Phase 2 合并时丢失 separator
|
||||
/// 边界信息。
|
||||
fn split_text(&self, text: &str, separators: &[String]) -> Vec<String> {
|
||||
if text.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// 早返回:整段文本 ≤ chunk_size 时整体返回,避免分割后再
|
||||
// 合并时丢失 separator 边界
|
||||
if chars_len(text) <= self.chunk_size {
|
||||
return vec![text.to_string()];
|
||||
}
|
||||
if separators.is_empty() {
|
||||
// 防御:理论上不应到达这里(DEFAULT_SEPARATORS 末尾有 `""`)
|
||||
return self.split_by_chars(text);
|
||||
}
|
||||
|
||||
let sep = &separators[0];
|
||||
if sep.is_empty() {
|
||||
// 字符级兜底
|
||||
return self.split_by_chars(text);
|
||||
}
|
||||
|
||||
// 检查文本中是否包含当前 separator
|
||||
if !text.contains(sep.as_str()) {
|
||||
// 不含此 separator,降级到下一级
|
||||
return self.split_text(text, &separators[1..]);
|
||||
}
|
||||
|
||||
// 文本中存在 separator,按 separator 切分
|
||||
let raw_segments: Vec<&str> = text.split(sep.as_str()).collect();
|
||||
let mut result = Vec::new();
|
||||
|
||||
for seg in raw_segments {
|
||||
if seg.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if chars_len(seg) > self.chunk_size {
|
||||
// 当前片段超长:递归降级到下一级 separator
|
||||
result.extend(self.split_text(seg, &separators[1..]));
|
||||
} else {
|
||||
// 当前片段符合 chunk_size,直接输出
|
||||
result.push(seg.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 字符级兜底分割(确保任何文本都能被切到 chunk_size 以内)。
|
||||
///
|
||||
/// 使用 `char_indices()` 步进,避免截断在多字节 UTF-8 字符中间。
|
||||
fn split_by_chars(&self, text: &str) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
let mut current = String::new();
|
||||
|
||||
for (_, ch) in text.char_indices() {
|
||||
current.push(ch);
|
||||
if chars_len(¤t) >= self.chunk_size {
|
||||
result.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
result.push(current);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 贪心合并 + overlap 滑动窗口(Phase 2)。
|
||||
///
|
||||
/// 把 Phase 1 输出的 segments 合并到目标 chunk_size,并对相邻 chunk
|
||||
/// 应用 chunk_overlap 字符的重叠窗口。
|
||||
///
|
||||
/// **已知行为**:合并时使用空字符串 `""` 连接相邻 segments
|
||||
/// (即 `current.join("")`),不保留 Phase 1 切分时消耗的 separator
|
||||
/// 边界信息。这意味着跨 chunk 的结构化边界(如段落、句子)会
|
||||
/// 在合并点"塌缩"——但对 RAG 语义检索影响通常较小。如需保留
|
||||
/// separator 边界,可重构此方法接受 separator 参数。
|
||||
fn merge_with_overlap(&self, mut segments: Vec<String>) -> Vec<String> {
|
||||
if segments.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if segments.len() == 1 {
|
||||
return segments;
|
||||
}
|
||||
|
||||
// Phase 2a: 贪心合并 segments 到目标 chunk_size
|
||||
// (segments 用 "" 连接,sep_count 不参与长度计算)
|
||||
let mut chunks: Vec<String> = Vec::new();
|
||||
let mut current: Vec<String> = Vec::new();
|
||||
let mut current_len: usize = 0;
|
||||
|
||||
for seg in segments.drain(..) {
|
||||
let seg_len = chars_len(&seg);
|
||||
let new_total = current_len + seg_len;
|
||||
|
||||
if new_total > self.chunk_size && !current.is_empty() {
|
||||
chunks.push(current.join(""));
|
||||
current.clear();
|
||||
current_len = 0;
|
||||
}
|
||||
current.push(seg);
|
||||
current_len += seg_len;
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
chunks.push(current.join(""));
|
||||
}
|
||||
|
||||
if chunks.len() <= 1 {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// Phase 2b: 应用 overlap 滑动窗口(除第一个 chunk 外)
|
||||
let overlap = self.chunk_overlap;
|
||||
if overlap == 0 {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
for i in 1..chunks.len() {
|
||||
let prev = &chunks[i - 1];
|
||||
let prev_chars_count = chars_len(prev);
|
||||
if prev_chars_count == 0 {
|
||||
continue;
|
||||
}
|
||||
let take_n = overlap.min(prev_chars_count);
|
||||
|
||||
// 字符级安全地取 prev 末尾 take_n 个字符
|
||||
let tail: String = prev
|
||||
.chars()
|
||||
.rev()
|
||||
.take(take_n)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
chunks[i] = format!("{}{}", tail, chunks[i]);
|
||||
}
|
||||
|
||||
chunks
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RecursiveCharacterSplitter {
|
||||
fn default() -> Self {
|
||||
Self::new(1000, 200)
|
||||
}
|
||||
}
|
||||
|
||||
/// 字符数(Unicode 标量值),等价于 `s.chars().count()`。
|
||||
#[inline]
|
||||
fn chars_len(s: &str) -> usize {
|
||||
s.chars().count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ===== Group B1 — Document struct 基础测试 =====
|
||||
|
||||
#[test]
|
||||
fn document_new_metadata_defaults_empty() {
|
||||
let doc = Document::new("id-1", "content", "text/plain");
|
||||
assert!(doc.metadata.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_clone_partial_eq() {
|
||||
let doc = Document::new("id-1", "content", "text/plain");
|
||||
let cloned = doc.clone();
|
||||
assert_eq!(doc, cloned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_different_ids_not_equal() {
|
||||
let doc1 = Document::new("id-1", "content", "text/plain");
|
||||
let doc2 = Document::new("id-2", "content", "text/plain");
|
||||
assert_ne!(doc1, doc2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_from_raw_uses_text_plain() {
|
||||
let doc = Document::from_raw("id-1", "hello");
|
||||
assert_eq!(doc.mime_type, "text/plain");
|
||||
assert!(doc.metadata.is_empty());
|
||||
}
|
||||
|
||||
// ===== Group B2 — Splitter 边界条件测试 =====
|
||||
|
||||
#[test]
|
||||
fn split_empty_doc_returns_empty() {
|
||||
let splitter = RecursiveCharacterSplitter::new(100, 20);
|
||||
let chunks = splitter.split(&[]);
|
||||
assert!(chunks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_short_doc_single_chunk() {
|
||||
let splitter = RecursiveCharacterSplitter::new(100, 20);
|
||||
let doc = Document::from_raw("short", "hello");
|
||||
let chunks = splitter.split(&[doc]);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].content, "hello");
|
||||
assert_eq!(
|
||||
chunks[0].metadata.get("chunk_index").map(|s| s.as_str()),
|
||||
Some("0")
|
||||
);
|
||||
assert_eq!(
|
||||
chunks[0].metadata.get("chunk_count").map(|s| s.as_str()),
|
||||
Some("1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_empty_content_yields_no_chunks() {
|
||||
let splitter = RecursiveCharacterSplitter::new(100, 20);
|
||||
let doc = Document::from_raw("empty", "");
|
||||
let chunks = splitter.split(&[doc]);
|
||||
assert!(chunks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "chunk_size must be greater than chunk_overlap")]
|
||||
fn split_constructor_panics_on_invalid_overlap() {
|
||||
let _ = RecursiveCharacterSplitter::new(10, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "chunk_size must be greater than 0")]
|
||||
fn split_constructor_panics_on_zero_chunk_size() {
|
||||
let _ = RecursiveCharacterSplitter::new(0, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_new_returns_err_on_invalid_params() {
|
||||
assert!(RecursiveCharacterSplitter::try_new(0, 0).is_err());
|
||||
assert!(RecursiveCharacterSplitter::try_new(10, 10).is_err());
|
||||
assert!(RecursiveCharacterSplitter::try_new(100, 20).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_separators_match_spec() {
|
||||
let splitter = RecursiveCharacterSplitter::default();
|
||||
// Default separators should include CJK punctuation as the last meaningful
|
||||
// separator before the char-level fallback. We can't directly access the
|
||||
// private field, so we verify behavior: a Chinese sentence should split
|
||||
// on "。" at the sentence level rather than the word level.
|
||||
let doc = Document::from_raw("zh", "你好世界。今天天气好。");
|
||||
let chunks = splitter.split(&[doc]);
|
||||
// The default chunk_size=1000, so the whole content fits in 1 chunk.
|
||||
// But the separators list contains "。" — this is verified via integration test.
|
||||
assert!(!chunks.is_empty());
|
||||
}
|
||||
|
||||
// ===== Group B3 — Splitter 核心算法测试 =====
|
||||
|
||||
#[test]
|
||||
fn split_paragraph_boundary() {
|
||||
// 小 chunk_size 强制段落级别分割
|
||||
let splitter = RecursiveCharacterSplitter::new(4, 1);
|
||||
let doc = Document::from_raw("p", "para1\n\npara2");
|
||||
let chunks = splitter.split(&[doc]);
|
||||
// para1 (5 chars) > chunk_size=4 → 递归降级到 char 级拆分
|
||||
// para2 同理
|
||||
// 总共应该产生多个 chunk
|
||||
assert!(
|
||||
chunks.len() >= 2,
|
||||
"expected >= 2 chunks, got {}",
|
||||
chunks.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_recursive_deepen() {
|
||||
let splitter = RecursiveCharacterSplitter::new(50, 5);
|
||||
// 200 字符无 \n\n,强制降级
|
||||
let text: String = "a".repeat(200);
|
||||
let doc = Document::from_raw("long", &text);
|
||||
let chunks = splitter.split(&[doc]);
|
||||
assert!(
|
||||
chunks.len() >= 3,
|
||||
"expected >= 3 chunks, got {}",
|
||||
chunks.len()
|
||||
);
|
||||
for chunk in &chunks {
|
||||
// chunk 内容 = overlap_tail(≤5) + new_content(≤50),故 ≤ 55
|
||||
assert!(
|
||||
chars_len(&chunk.content) <= 55,
|
||||
"chunk too long: {} chars",
|
||||
chars_len(&chunk.content)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_greedy_merge_combines_segments() {
|
||||
let splitter = RecursiveCharacterSplitter::new(20, 2);
|
||||
// 一段含多个 \n\n 分隔的短小段,应被合并到 chunk_size
|
||||
let doc = Document::from_raw("g", "aa\n\nbb\n\ncc\n\ndd");
|
||||
let chunks = splitter.split(&[doc]);
|
||||
// 短段应被合并:总共应该少于 4 个 chunk
|
||||
assert!(
|
||||
chunks.len() <= 3,
|
||||
"expected <= 3 chunks after merge, got {}",
|
||||
chunks.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_overlap_consistency() {
|
||||
let splitter = RecursiveCharacterSplitter::new(20, 5);
|
||||
// 构造一个需要多 chunk 的文本
|
||||
let text: String = "x".repeat(50);
|
||||
let doc = Document::from_raw("o", &text);
|
||||
let chunks = splitter.split(&[doc]);
|
||||
assert!(chunks.len() >= 2);
|
||||
// chunk[1] 应该以 chunk[0] 的最后 5 个字符作为前缀
|
||||
let prev_tail: String = chunks[0]
|
||||
.content
|
||||
.chars()
|
||||
.rev()
|
||||
.take(5)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
assert!(
|
||||
chunks[1].content.starts_with(&prev_tail),
|
||||
"chunk[1] should start with last 5 chars of chunk[0]: prev_tail={:?}, chunk[1]={:?}",
|
||||
prev_tail,
|
||||
chunks[1].content
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_character_fallback() {
|
||||
let splitter = RecursiveCharacterSplitter::new(5, 0);
|
||||
// 纯字母无标点,应降级到字符级
|
||||
let doc = Document::from_raw("cf", "aaaaaaaaa");
|
||||
let chunks = splitter.split(&[doc]);
|
||||
assert_eq!(chunks.len(), 2, "expected 2 chunks, got {}", chunks.len());
|
||||
for chunk in &chunks {
|
||||
assert!(chars_len(&chunk.content) <= 5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_multibyte_utf8_boundary() {
|
||||
// 验证字符级单位而非字节级单位
|
||||
let splitter = RecursiveCharacterSplitter::new(10, 2);
|
||||
// 30 个中文字符 = 90 字节(UTF-8)
|
||||
let text: String = "中".repeat(30);
|
||||
let doc = Document::from_raw("cjk", &text);
|
||||
let chunks = splitter.split(&[doc]);
|
||||
// 30 字符 / 10 chunk_size = 3 个 chunk
|
||||
assert!(
|
||||
chunks.len() >= 3,
|
||||
"expected >= 3 chunks for 30 chars / chunk_size=10, got {}",
|
||||
chunks.len()
|
||||
);
|
||||
for chunk in &chunks {
|
||||
let char_count = chars_len(&chunk.content);
|
||||
// chunk = overlap_tail(≤2) + new_content(≤10),故 ≤ 12
|
||||
assert!(
|
||||
char_count <= 12,
|
||||
"chunk char count {} exceeds 10+overlap",
|
||||
char_count
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Group B4 — Splitter 集成测试 =====
|
||||
|
||||
#[test]
|
||||
fn split_multiple_docs() {
|
||||
let splitter = RecursiveCharacterSplitter::new(50, 5);
|
||||
let docs = vec![
|
||||
Document::from_raw("a", "a".repeat(30).as_str()),
|
||||
Document::from_raw("b", "b".repeat(30).as_str()),
|
||||
Document::from_raw("c", "c".repeat(30).as_str()),
|
||||
];
|
||||
let chunks = splitter.split(&docs);
|
||||
assert!(chunks.len() >= 3);
|
||||
// 每个 chunk 的 source_id 应指向对应的输入 doc
|
||||
for chunk in &chunks {
|
||||
let source = chunk.metadata.get("source_id").unwrap();
|
||||
assert!(["a", "b", "c"].contains(&source.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_metadata_inheritance() {
|
||||
let splitter = RecursiveCharacterSplitter::new(100, 10);
|
||||
let mut doc = Document::new("m", "short content", "text/plain");
|
||||
doc.metadata
|
||||
.insert("author".to_string(), "alice".to_string());
|
||||
let chunks = splitter.split(&[doc]);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(
|
||||
chunks[0].metadata.get("author").map(|s| s.as_str()),
|
||||
Some("alice")
|
||||
);
|
||||
assert_eq!(
|
||||
chunks[0].metadata.get("source_id").map(|s| s.as_str()),
|
||||
Some("m")
|
||||
);
|
||||
assert_eq!(
|
||||
chunks[0].metadata.get("chunk_index").map(|s| s.as_str()),
|
||||
Some("0")
|
||||
);
|
||||
assert_eq!(
|
||||
chunks[0].metadata.get("chunk_count").map(|s| s.as_str()),
|
||||
Some("1")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,218 @@
|
||||
//! Agent 角色热切换(Phase 18)。
|
||||
//!
|
||||
//! 提供 `SessionManager::switch_agent()`,运行时替换 session 绑定的 Agent,
|
||||
//! 保留 slot 历史 / turn_index / session_memory / cost_so_far。
|
||||
//!
|
||||
//! **未来扩展方向**(roadmap 备注):
|
||||
//! - v0.4 可考虑提供 `switch_agent_with_rollback`,在切换前自动 checkpoint
|
||||
//! - 可在 `SessionMeta` 中记录 `previous_agent_name` 支持审计历史
|
||||
//! - 可新增 `switch_history` API 暴露切换时间序列
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::agent::Agent;
|
||||
use crate::engine::error::EngineError;
|
||||
use crate::engine::session_manager::{SessionManager, SessionMeta};
|
||||
|
||||
impl SessionManager {
|
||||
/// 热切换指定 session 的 Agent 角色。
|
||||
///
|
||||
/// # 行为
|
||||
///
|
||||
/// - **保留上下文**:slot 历史 / turn_index / session_memory / cost_so_far 全部保留
|
||||
/// - **更新 SessionMeta**:`agent_name` 替换为新 agent,`created_at`/`parent_id` 保持原始
|
||||
/// - **不自动 checkpoint**:与 `auto_checkpoint` 语义一致(仅 `submit_turn`/`finalize_turn` 触发)
|
||||
///
|
||||
/// # 注意
|
||||
///
|
||||
/// 切换后**新的 system_prompt 将与已有对话历史共存**。建议在切换后
|
||||
/// 发送一条明确的上下文过渡提示(如"你现在以新角色 X 的身份继续对话")
|
||||
/// 作为切换后的首条输入,以避免 LLM 误解对话历史。
|
||||
///
|
||||
/// # 安全提示
|
||||
///
|
||||
/// `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(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agent::Agent;
|
||||
use crate::agent::builder::AgentBuilder;
|
||||
use crate::agent::runtime::RuntimeBundle;
|
||||
use crate::engine::session_manager::SessionManager;
|
||||
use crate::llm::hooks::HookExecutor;
|
||||
use crate::llm::mock::MockProvider;
|
||||
use crate::llm::types::Usage;
|
||||
use crate::llm::types::message::{ContentBlock, Message};
|
||||
use crate::llm::types::response_v2::{MessageResponse, StopReason};
|
||||
use crate::memory::store::InMemoryStore;
|
||||
use crate::tools::ToolRegistry;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// 测试用 MockAgent(name + system_prompt 可控)
|
||||
struct MockAgent {
|
||||
name: String,
|
||||
system_prompt: String,
|
||||
}
|
||||
|
||||
impl MockAgent {
|
||||
fn new(name: &str, system_prompt: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
system_prompt: system_prompt.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Agent for MockAgent {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
Some(&self.system_prompt)
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn make_manager_and_bundle() -> (Arc<SessionManager>, Arc<RuntimeBundle>) {
|
||||
let store: Arc<dyn crate::memory::store::MemoryStore> = Arc::new(InMemoryStore::new());
|
||||
let provider = Arc::new(MockProvider::new(vec![
|
||||
assistant_text("response1"),
|
||||
assistant_text("response2"),
|
||||
assistant_text("response3"),
|
||||
]));
|
||||
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 build"),
|
||||
);
|
||||
let sm = Arc::new(SessionManager::new(store));
|
||||
(sm, bundle)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_switch_agent_basic() {
|
||||
let (sm, bundle) = make_manager_and_bundle().await;
|
||||
let agent_a: Arc<dyn Agent> = Arc::new(MockAgent::new("agent_a", "I am A"));
|
||||
let agent_b: Arc<dyn Agent> = Arc::new(MockAgent::new("agent_b", "I am B"));
|
||||
|
||||
let sid = sm.create(agent_a, bundle).await.unwrap();
|
||||
sm.switch_agent(&sid, agent_b).await.unwrap();
|
||||
|
||||
let session = sm.get(&sid).await.unwrap();
|
||||
let guard = session.lock().await;
|
||||
assert_eq!(guard.agent.name(), "agent_b");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_switch_agent_preserves_context() {
|
||||
let (sm, bundle) = make_manager_and_bundle().await;
|
||||
let agent_a: Arc<dyn Agent> = Arc::new(MockAgent::new("agent_a", "I am A"));
|
||||
let agent_b: Arc<dyn Agent> = Arc::new(MockAgent::new("agent_b", "I am B"));
|
||||
|
||||
let sid = sm.create(agent_a, bundle).await.unwrap();
|
||||
|
||||
// 在 switch 前写入 session_data 并提交一轮
|
||||
{
|
||||
let session = sm.get(&sid).await.unwrap();
|
||||
let mut guard = session.lock().await;
|
||||
guard.set_session_data("key1", "value1").await.unwrap();
|
||||
}
|
||||
sm.submit_turn(&sid, "hello").await.unwrap();
|
||||
|
||||
// switch
|
||||
sm.switch_agent(&sid, agent_b).await.unwrap();
|
||||
|
||||
// 验证 turn_index 保留
|
||||
let session = sm.get(&sid).await.unwrap();
|
||||
let guard = session.lock().await;
|
||||
assert_eq!(guard.turn_index(), 1, "turn_index should be preserved");
|
||||
// 验证 session_memory 保留
|
||||
let val = guard.session_memory().get("key1").await.unwrap();
|
||||
assert_eq!(val, Some("value1".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_switch_agent_updates_session_meta() {
|
||||
let (sm, bundle) = make_manager_and_bundle().await;
|
||||
let agent_a: Arc<dyn Agent> = Arc::new(MockAgent::new("agent_a", "I am A"));
|
||||
let agent_b: Arc<dyn Agent> = Arc::new(MockAgent::new("agent_b", "I am B"));
|
||||
|
||||
let sid = sm.create(agent_a, bundle).await.unwrap();
|
||||
sm.switch_agent(&sid, agent_b).await.unwrap();
|
||||
|
||||
// 通过 load_session_meta 验证持久化
|
||||
let meta = sm.load_session_meta(&sid).await.unwrap();
|
||||
assert!(meta.is_some(), "SessionMeta should persist");
|
||||
let meta = meta.unwrap();
|
||||
assert_eq!(meta.agent_name, "agent_b");
|
||||
// created_at / parent_id 保持
|
||||
assert_eq!(meta.parent_id, None, "parent_id should remain None");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_switch_agent_session_not_found() {
|
||||
let (sm, _bundle) = make_manager_and_bundle().await;
|
||||
let agent: Arc<dyn Agent> = Arc::new(MockAgent::new("agent_a", "I am A"));
|
||||
let result = sm.switch_agent("nonexistent_session_id", agent).await;
|
||||
assert!(matches!(result, Err(EngineError::SessionNotFound(_))));
|
||||
}
|
||||
}
|
||||
+16
@@ -1,15 +1,31 @@
|
||||
//! agcore —— 智能体(Agent)核心工具箱。
|
||||
|
||||
#[cfg(feature = "agent")]
|
||||
pub mod agent;
|
||||
#[cfg(feature = "document")]
|
||||
pub mod document;
|
||||
#[cfg(feature = "engine")]
|
||||
pub mod engine;
|
||||
#[cfg(any(feature = "llm-types", feature = "llm"))]
|
||||
pub mod llm;
|
||||
#[cfg(feature = "memory")]
|
||||
pub mod memory;
|
||||
#[cfg(feature = "prompt")]
|
||||
pub mod prompt;
|
||||
#[cfg(feature = "tools")]
|
||||
pub mod tools;
|
||||
|
||||
#[cfg(feature = "document")]
|
||||
pub use document::Document;
|
||||
|
||||
#[cfg(feature = "tracing-init")]
|
||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
|
||||
#[cfg(feature = "tracing-init")]
|
||||
static INIT: std::sync::Once = std::sync::Once::new();
|
||||
|
||||
/// 初始化 tracing 日志订阅(仅在启用 `tracing-init` feature 时可用)。
|
||||
#[cfg(feature = "tracing-init")]
|
||||
pub fn init_tracing() {
|
||||
INIT.call_once(|| {
|
||||
let filter =
|
||||
|
||||
+28
-2
@@ -1,11 +1,37 @@
|
||||
//! LLM 调用周期 —— 大模型基础调用周期控制。
|
||||
|
||||
#[cfg(feature = "llm")]
|
||||
pub mod compact;
|
||||
#[cfg(feature = "llm")]
|
||||
pub mod convert;
|
||||
#[cfg(feature = "llm")]
|
||||
pub mod cycle;
|
||||
#[cfg(feature = "llm")]
|
||||
pub mod embedding;
|
||||
#[cfg(feature = "llm")]
|
||||
pub mod error;
|
||||
#[cfg(feature = "llm")]
|
||||
pub mod hooks;
|
||||
#[cfg(feature = "llm")]
|
||||
pub mod mock;
|
||||
pub mod provider;
|
||||
pub mod stream;
|
||||
#[cfg(feature = "llm-types")]
|
||||
pub mod types;
|
||||
// provider 模块依赖 reqwest(通过 reqwest::Client),仅在任一 provider feature 启用时编译
|
||||
#[cfg(any(
|
||||
feature = "provider-openai",
|
||||
feature = "provider-anthropic",
|
||||
feature = "provider-deepseek",
|
||||
feature = "provider-qwen",
|
||||
feature = "provider-ollama",
|
||||
feature = "provider-openai-response"
|
||||
))]
|
||||
pub mod provider;
|
||||
/// Provider 抽象接口(trait + 能力元数据),仅依赖 `llm` feature,不引入 reqwest。
|
||||
#[cfg(feature = "llm")]
|
||||
pub mod provider_trait;
|
||||
#[cfg(feature = "llm")]
|
||||
pub mod stream;
|
||||
|
||||
// 重导出 Provider 抽象接口到 `crate::llm::` 顶层,便于下游 `use crate::llm::LlmProvider`。
|
||||
#[cfg(feature = "llm")]
|
||||
pub use provider_trait::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
|
||||
+32
-16
@@ -73,10 +73,7 @@ impl CompactState {
|
||||
|
||||
/// 粗略估计消息列表的 token 数(基于字符数,4 字符 ≈ 1 token)。
|
||||
pub fn estimate_message_tokens(messages: &[Message]) -> u32 {
|
||||
messages
|
||||
.iter()
|
||||
.map(estimate_single_message_tokens)
|
||||
.sum()
|
||||
messages.iter().map(estimate_single_message_tokens).sum()
|
||||
}
|
||||
|
||||
fn estimate_single_message_tokens(msg: &Message) -> u32 {
|
||||
@@ -99,9 +96,7 @@ fn estimate_block_tokens(block: &ContentBlock) -> u32 {
|
||||
match block {
|
||||
ContentBlock::Text { text } => estimate_text_tokens(text),
|
||||
ContentBlock::Thinking { text, .. } => estimate_text_tokens(text),
|
||||
ContentBlock::ToolUse { input, .. } => {
|
||||
estimate_text_tokens(&input.to_string())
|
||||
}
|
||||
ContentBlock::ToolUse { input, .. } => estimate_text_tokens(&input.to_string()),
|
||||
ContentBlock::ToolResult { content, .. } => estimate_content_blocks_tokens(content),
|
||||
// ponytail: Image / Audio / File / Extension 在 IR 中固定估算。
|
||||
// 无文本的视觉/音频 block 用兜底估算,避免 token 计数膨胀。
|
||||
@@ -148,14 +143,25 @@ pub fn microcompact(messages: &mut [Message], keep_recent: usize) -> u32 {
|
||||
|
||||
// 第一遍:计算可释放 token(仅非错误 ToolResult)
|
||||
for msg in &messages[..prune_start] {
|
||||
if matches!(msg, Message::ToolResult { is_error: false, .. }) {
|
||||
if matches!(
|
||||
msg,
|
||||
Message::ToolResult {
|
||||
is_error: false,
|
||||
..
|
||||
}
|
||||
) {
|
||||
freed_tokens += estimate_single_message_tokens(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// 第二遍:替换内容(仅非错误 ToolResult)
|
||||
for msg in &mut messages[..prune_start] {
|
||||
if let Message::ToolResult { content, is_error: false, .. } = msg {
|
||||
if let Message::ToolResult {
|
||||
content,
|
||||
is_error: false,
|
||||
..
|
||||
} = msg
|
||||
{
|
||||
*content = vec![ContentBlock::Text {
|
||||
text: "[pruned]".to_string(),
|
||||
}];
|
||||
@@ -177,13 +183,15 @@ mod tests {
|
||||
fn estimate_message_tokens_handles_all_variants() {
|
||||
let messages = vec![
|
||||
Message::System {
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "sys".into(),
|
||||
}],
|
||||
content: vec![ContentBlock::Text { text: "sys".into() }],
|
||||
},
|
||||
Message::user_text("hi"),
|
||||
Message::assistant("ans"),
|
||||
Message::user_image("b64", "image/png", crate::llm::types::shared::ImageDetail::Auto),
|
||||
Message::user_image(
|
||||
"b64",
|
||||
"image/png",
|
||||
crate::llm::types::shared::ImageDetail::Auto,
|
||||
),
|
||||
Message::tool_result("call_1", "tool res", false),
|
||||
];
|
||||
let tokens = estimate_message_tokens(&messages);
|
||||
@@ -205,7 +213,10 @@ mod tests {
|
||||
assert!(freed > 0);
|
||||
assert_eq!(messages.len(), before_len); // 只改内容,不删消息
|
||||
// 索引 1 是被压缩的 ToolResult
|
||||
if let Message::ToolResult { content, is_error, .. } = &messages[1] {
|
||||
if let Message::ToolResult {
|
||||
content, is_error, ..
|
||||
} = &messages[1]
|
||||
{
|
||||
assert_eq!(content.len(), 1);
|
||||
assert!(matches!(&content[0], ContentBlock::Text { text } if text == "[pruned]"));
|
||||
assert!(!is_error);
|
||||
@@ -228,9 +239,14 @@ mod tests {
|
||||
assert_eq!(freed, 0); // 错误 ToolResult 不计入
|
||||
assert_eq!(messages.len(), before_len);
|
||||
// 错误信息保留完整
|
||||
if let Message::ToolResult { content, is_error, .. } = &messages[1] {
|
||||
if let Message::ToolResult {
|
||||
content, is_error, ..
|
||||
} = &messages[1]
|
||||
{
|
||||
assert!(is_error);
|
||||
assert!(matches!(&content[0], ContentBlock::Text { text } if text.contains("backend down")));
|
||||
assert!(
|
||||
matches!(&content[0], ContentBlock::Text { text } if text.contains("backend down"))
|
||||
);
|
||||
} else {
|
||||
panic!("expected ToolResult at index 1");
|
||||
}
|
||||
|
||||
+20
-21
@@ -8,11 +8,9 @@
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm::types::message::{ContentBlock, Message};
|
||||
use crate::llm::types::openai_message::{
|
||||
ContentField, OpenaiChatMessage, OpenaiContentPart,
|
||||
};
|
||||
use crate::llm::types::OpenaiToolCall;
|
||||
use crate::llm::types::message::{ContentBlock, Message};
|
||||
use crate::llm::types::openai_message::{ContentField, OpenaiChatMessage, OpenaiContentPart};
|
||||
|
||||
/// `OpenaiChatMessage` → IR `Message`。
|
||||
///
|
||||
@@ -24,11 +22,10 @@ use crate::llm::types::OpenaiToolCall;
|
||||
/// - `Function`(已废弃)→ `Message::ToolResult`(`name` 作为 `tool_call_id` 兜底)
|
||||
pub fn from_openai(msg: &OpenaiChatMessage) -> Message {
|
||||
match msg {
|
||||
OpenaiChatMessage::Developer { content, .. } | OpenaiChatMessage::System { content, .. } => {
|
||||
Message::System {
|
||||
OpenaiChatMessage::Developer { content, .. }
|
||||
| OpenaiChatMessage::System { content, .. } => Message::System {
|
||||
content: content_to_blocks(content),
|
||||
}
|
||||
}
|
||||
},
|
||||
OpenaiChatMessage::User { content, .. } => Message::User {
|
||||
content: content_to_blocks(content),
|
||||
},
|
||||
@@ -86,7 +83,11 @@ pub fn to_openai(msg: &Message) -> OpenaiChatMessage {
|
||||
content: blocks_to_content(content),
|
||||
name: None,
|
||||
},
|
||||
Message::UserImage { data, mime_type, detail } => {
|
||||
Message::UserImage {
|
||||
data,
|
||||
mime_type,
|
||||
detail,
|
||||
} => {
|
||||
// ponytail: 构造为单 image part 的 User 消息(OpenAI 多模态格式)。
|
||||
let mime = mime_type.clone();
|
||||
let is_url = data.starts_with("http://") || data.starts_with("https://");
|
||||
@@ -167,18 +168,17 @@ pub fn content_to_blocks(field: &ContentField) -> Vec<ContentBlock> {
|
||||
ContentField::Array(parts) => parts
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
OpenaiContentPart::Text { text } => {
|
||||
Some(ContentBlock::Text { text: text.clone() })
|
||||
}
|
||||
OpenaiContentPart::Refusal { refusal } => {
|
||||
Some(ContentBlock::Text { text: refusal.clone() })
|
||||
}
|
||||
OpenaiContentPart::Text { text } => Some(ContentBlock::Text { text: text.clone() }),
|
||||
OpenaiContentPart::Refusal { refusal } => Some(ContentBlock::Text {
|
||||
text: refusal.clone(),
|
||||
}),
|
||||
OpenaiContentPart::Image { image_url, .. } => {
|
||||
// ponytail: 简化处理 —— URL 直接通过,data URI 拆出
|
||||
// data:<mime>;base64,<b64> → ImageSource { data: b64, mime, is_url: false }。
|
||||
let url = &image_url.url;
|
||||
if let Some(rest) = url.strip_prefix("data:")
|
||||
&& let Some((mime, b64)) = rest.split_once(";base64,") {
|
||||
&& let Some((mime, b64)) = rest.split_once(";base64,")
|
||||
{
|
||||
return Some(ContentBlock::Image {
|
||||
source: crate::llm::types::message::ImageSource {
|
||||
data: b64.to_string(),
|
||||
@@ -263,7 +263,9 @@ mod tests {
|
||||
match ir {
|
||||
Message::System { content } => {
|
||||
assert_eq!(content.len(), 1);
|
||||
assert!(matches!(&content[0], ContentBlock::Text { text } if text == "you are helpful"));
|
||||
assert!(
|
||||
matches!(&content[0], ContentBlock::Text { text } if text == "you are helpful")
|
||||
);
|
||||
}
|
||||
_ => panic!("expected System variant"),
|
||||
}
|
||||
@@ -385,10 +387,7 @@ mod tests {
|
||||
assert_eq!(parts.len(), 1);
|
||||
match &parts[0] {
|
||||
OpenaiContentPart::Image { image_url, .. } => {
|
||||
assert_eq!(
|
||||
image_url.url,
|
||||
"data:image/png;base64,BASE64DATA"
|
||||
);
|
||||
assert_eq!(image_url.url, "data:image/png;base64,BASE64DATA");
|
||||
}
|
||||
_ => panic!("expected Image part"),
|
||||
}
|
||||
|
||||
+875
-48
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
||||
//! Embedding 抽象 —— 文本向量化接口。
|
||||
//!
|
||||
//! 提供 [`Embedding`] trait 和零依赖的 [`MockEmbedding`] 引用实现。
|
||||
//! 上层可实现此 trait 以对接真实 Embedding Provider(OpenAI、Cohere 等)。
|
||||
//!
|
||||
//! 所有实现使用 [`LlmError`] 作为统一错误类型,与 llm 模块保持一致。
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::llm::error::LlmError;
|
||||
|
||||
/// 文本向量化抽象接口。
|
||||
///
|
||||
/// 将文本字符串转换为固定维度的浮点向量,用于语义相似度计算。
|
||||
/// 设计为异步以支持网络 IO(如 OpenAI Embedding API)。
|
||||
///
|
||||
/// 使用 [`LlmError`] 作为统一错误类型,与 llm 模块保持一致。
|
||||
///
|
||||
/// # 实现要求
|
||||
///
|
||||
/// - `embed()` 返回的向量外层的 Vec 长度必须等于输入切片长度(一对一映射)
|
||||
/// - 内层 Vec 长度必须等于 `dim()` 返回值
|
||||
/// - 调用方应保证输入非空(空切片返回空外层 Vec,不报错)
|
||||
///
|
||||
/// # 稳定性
|
||||
///
|
||||
/// 实验性 API(v0.3.x),方法签名可能在 v0.4 中调整。
|
||||
#[async_trait]
|
||||
pub trait Embedding: Send + Sync {
|
||||
/// 批量向量化。
|
||||
///
|
||||
/// 返回 `Vec<Vec<f32>>`,第 i 个内层向量对应 `input[i]`。
|
||||
async fn embed(&self, input: &[String]) -> Result<Vec<Vec<f32>>, LlmError>;
|
||||
|
||||
/// 返回向量维度。
|
||||
fn dim(&self) -> usize;
|
||||
}
|
||||
|
||||
/// 确定性 Mock Embedding —— 零依赖伪随机单位向量。
|
||||
///
|
||||
/// 使用 sin 哈希将输入字符串映射到单位球面上的一个点:
|
||||
/// 1. 对输入字符串计算简单哈希(字符字节和 + 长度)作为种子
|
||||
/// 2. 用 `f32::sin(seed + i) * 10000` 生成第 i 个维度的值
|
||||
/// 3. 归一化到单位长度(L2 norm = 1.0)
|
||||
///
|
||||
/// 特性:
|
||||
/// - **确定性**:相同输入 → 相同向量
|
||||
/// - **有区分度**:不同输入产生不同向量(高概率)
|
||||
/// - **单位范数**:余弦相似度等价于点积
|
||||
/// - **开销极低**:不分配额外内存,无 IO
|
||||
///
|
||||
/// # 已知限制
|
||||
///
|
||||
/// `f32::sin(seed + i) * 10000` 在维度较高时(如 1536,OpenAI Embedding 维度)
|
||||
/// 可能出现周期性模式——相邻维度取值在 `sin` 周期 2π 约束下呈规律性重复。
|
||||
/// MockEmbedding 仅用于测试验证,**不应用于生产级相似度排序**;
|
||||
/// 做严肃验证时建议使用真实 Embedding Provider 或显式随机初始化。
|
||||
pub struct MockEmbedding {
|
||||
dim: usize,
|
||||
}
|
||||
|
||||
impl MockEmbedding {
|
||||
/// 创建 Mock Embedding,输出向量维度为 `dim`。
|
||||
pub fn new(dim: usize) -> Self {
|
||||
Self { dim }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Embedding for MockEmbedding {
|
||||
async fn embed(&self, input: &[String]) -> Result<Vec<Vec<f32>>, LlmError> {
|
||||
let results: Vec<Vec<f32>> = input
|
||||
.iter()
|
||||
.map(|text| {
|
||||
// 简单哈希:字符字节值和 + 文本长度作为种子
|
||||
let seed: f64 = text.bytes().map(|b| b as f64).sum::<f64>() + text.len() as f64;
|
||||
let mut vec: Vec<f32> = (0..self.dim)
|
||||
.map(|i| f32::sin(seed as f32 + i as f32) * 10000.0)
|
||||
.collect();
|
||||
l2_normalize(&mut vec);
|
||||
vec
|
||||
})
|
||||
.collect();
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn dim(&self) -> usize {
|
||||
self.dim
|
||||
}
|
||||
}
|
||||
|
||||
/// L2 归一化(in-place)。
|
||||
///
|
||||
/// 零向量(norm == 0)保持全零 —— 防除零保护。
|
||||
fn l2_normalize(vec: &mut [f32]) {
|
||||
let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm > f32::EPSILON {
|
||||
for x in vec.iter_mut() {
|
||||
*x /= norm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 计算向量的 L2 范数。
|
||||
fn l2_norm(v: &[f32]) -> f32 {
|
||||
v.iter().map(|x| x * x).sum::<f32>().sqrt()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embed_correct_dim() {
|
||||
let embedder = MockEmbedding::new(8);
|
||||
let inputs = vec!["hello".to_string(), "world".to_string()];
|
||||
let result = embedder.embed(&inputs).await.unwrap();
|
||||
assert_eq!(result.len(), 2);
|
||||
for vec in &result {
|
||||
assert_eq!(vec.len(), 8);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embed_batch_size_match() {
|
||||
let embedder = MockEmbedding::new(4);
|
||||
let inputs = vec![
|
||||
"a".to_string(),
|
||||
"b".to_string(),
|
||||
"c".to_string(),
|
||||
"d".to_string(),
|
||||
"e".to_string(),
|
||||
];
|
||||
let result = embedder.embed(&inputs).await.unwrap();
|
||||
assert_eq!(result.len(), inputs.len());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embed_deterministic() {
|
||||
let embedder = MockEmbedding::new(4);
|
||||
let inputs = vec!["deterministic test".to_string()];
|
||||
let r1 = embedder.embed(&inputs).await.unwrap();
|
||||
let r2 = embedder.embed(&inputs).await.unwrap();
|
||||
assert_eq!(r1, r2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embed_unit_vector_norm() {
|
||||
let embedder = MockEmbedding::new(16);
|
||||
let inputs = vec!["any text".to_string(), "another".to_string()];
|
||||
let result = embedder.embed(&inputs).await.unwrap();
|
||||
for vec in &result {
|
||||
let norm = l2_norm(vec);
|
||||
assert!(
|
||||
(norm - 1.0).abs() < 1e-5,
|
||||
"vector norm should be ~1.0, got {}",
|
||||
norm
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embed_different_inputs_different_vectors() {
|
||||
let embedder = MockEmbedding::new(16);
|
||||
let r1 = embedder.embed(&["hello world".to_string()]).await.unwrap();
|
||||
let r2 = embedder
|
||||
.embed(&["completely different".to_string()])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(r1, r2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embed_empty_string() {
|
||||
// 空字符串输入应不 panic,且向量范数仍≈1.0(防除零路径)
|
||||
let embedder = MockEmbedding::new(4);
|
||||
let inputs = vec!["".to_string()];
|
||||
let result = embedder.embed(&inputs).await.unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].len(), 4);
|
||||
let norm = l2_norm(&result[0]);
|
||||
assert!(
|
||||
(norm - 1.0).abs() < 1e-5,
|
||||
"empty-string vector norm should be ~1.0, got {}",
|
||||
norm
|
||||
);
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -8,9 +8,12 @@ use std::time::Duration;
|
||||
///
|
||||
/// 错误消息面向最终用户(中文),并尽量附带可操作的修复建议(如检查 API key、减少上下文)。
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum LlmError {
|
||||
/// API 认证失败(API key 无效、过期或权限不足)。
|
||||
#[error("LLM 认证失败: {0}。请检查环境变量中的 API key(如 OPENAI_API_KEY / ANTHROPIC_API_KEY)是否正确")]
|
||||
#[error(
|
||||
"LLM 认证失败: {0}。请检查环境变量中的 API key(如 OPENAI_API_KEY / ANTHROPIC_API_KEY)是否正确"
|
||||
)]
|
||||
Authentication(String),
|
||||
|
||||
/// 请求被限流,可选地附带重试等待时间。可重试。
|
||||
@@ -18,7 +21,9 @@ pub enum LlmError {
|
||||
RateLimit { retry_after: Option<Duration> },
|
||||
|
||||
/// HTTP 请求失败(网络错误或非 2xx 状态码),包含状态码与响应体。
|
||||
#[error("LLM 请求失败(HTTP {status}): {body}。请检查 Provider 端点地址(base_url)和网络连通性")]
|
||||
#[error(
|
||||
"LLM 请求失败(HTTP {status}): {body}。请检查 Provider 端点地址(base_url)和网络连通性"
|
||||
)]
|
||||
Request { status: u16, body: String },
|
||||
|
||||
/// 请求超时。可重试。
|
||||
@@ -30,7 +35,9 @@ pub enum LlmError {
|
||||
Stream(String),
|
||||
|
||||
/// 上下文长度超出模型窗口限制。
|
||||
#[error("LLM 上下文超限:当前 {actual} tokens > 模型上限 {limit} tokens。请减少消息历史、缩短 prompt,或启用 auto-compaction(llm::compact)")]
|
||||
#[error(
|
||||
"LLM 上下文超限:当前 {actual} tokens > 模型上限 {limit} tokens。请减少消息历史、缩短 prompt,或启用 auto-compaction(llm::compact)"
|
||||
)]
|
||||
ContextLength { actual: u32, limit: u32 },
|
||||
|
||||
/// 其他未分类的 LLM 调用失败。
|
||||
|
||||
+2
-3
@@ -7,6 +7,7 @@ use crate::llm::types::request_v2::MessageRequest;
|
||||
|
||||
/// 生命周期钩子事件点。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum HookEvent {
|
||||
/// LLM 请求发起之前(可阻断)。
|
||||
PreRequest,
|
||||
@@ -130,9 +131,7 @@ impl Default for HookExecutor {
|
||||
impl HookExecutor {
|
||||
/// 创建一个空的执行器。
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
hooks: Vec::new(),
|
||||
}
|
||||
Self { hooks: Vec::new() }
|
||||
}
|
||||
|
||||
/// 注册一个钩子到指定事件点。
|
||||
|
||||
+12
-10
@@ -8,7 +8,7 @@
|
||||
//! ```no_run
|
||||
//! use std::sync::Arc;
|
||||
//! 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;
|
||||
@@ -43,10 +43,10 @@ use async_stream::stream;
|
||||
use futures_core::Stream;
|
||||
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
use crate::llm::types::message::{ContentBlock, ContentBlockType, Message};
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, PartialUsage, StreamEvent};
|
||||
use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
|
||||
/// 按调用顺序返回预设响应的 [`LlmProvider`]。
|
||||
///
|
||||
@@ -97,8 +97,7 @@ impl LlmProvider for MockProvider {
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
_request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
|
||||
{
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
|
||||
let response = self.pop()?;
|
||||
// 提前 clone 出在 stream 闭包中需要的字段;最后 yield 时 move response。
|
||||
let id = response.id.clone();
|
||||
@@ -206,10 +205,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn chat_returns_queued_response() {
|
||||
let provider = MockProvider::new(vec![text_response("hello")]);
|
||||
let resp = provider
|
||||
.chat(MessageRequest::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = provider.chat(MessageRequest::default()).await.unwrap();
|
||||
assert_eq!(resp.text(), "hello");
|
||||
assert_eq!(provider.remaining(), 0);
|
||||
}
|
||||
@@ -231,7 +227,10 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn chat_stream_emits_text_delta_sequence() {
|
||||
let provider = MockProvider::new(vec![text_response("hi")]);
|
||||
let mut stream = provider.chat_stream(MessageRequest::default()).await.unwrap();
|
||||
let mut stream = provider
|
||||
.chat_stream(MessageRequest::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut seen_start = false;
|
||||
let mut seen_block_start = false;
|
||||
@@ -283,7 +282,10 @@ mod tests {
|
||||
extra: Default::default(),
|
||||
};
|
||||
let provider = MockProvider::new(vec![response]);
|
||||
let mut stream = provider.chat_stream(MessageRequest::default()).await.unwrap();
|
||||
let mut stream = provider
|
||||
.chat_stream(MessageRequest::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut saw_tool_args = false;
|
||||
let mut saw_tool_end = false;
|
||||
|
||||
+470
-67
@@ -1,16 +1,22 @@
|
||||
pub mod anthropic;
|
||||
pub mod ollama;
|
||||
pub mod openai;
|
||||
pub mod openai_compat;
|
||||
#[cfg(feature = "provider-openai-response")]
|
||||
pub mod openai_response;
|
||||
pub mod registry;
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_core::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
|
||||
|
||||
// 向后兼容重导出 —— v0.3.2 Step 3 起,`LlmProvider` / `ProviderCapabilities` /
|
||||
// `ProviderFeatures` 定义移至 `provider_trait` 模块(`#[cfg(feature = "llm")]`)。
|
||||
// 此处重导出使老路径 `agcore::llm::provider::LlmProvider` 仍可用。
|
||||
// 推荐下游迁移至 `agcore::llm::LlmProvider`。
|
||||
pub use super::provider_trait::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
|
||||
/// Provider 类型枚举 —— `create_provider()` 在编译期 exhaustive match 中使用。
|
||||
///
|
||||
@@ -18,6 +24,7 @@ use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
|
||||
/// 当前协议数量(5 种以内)完全可控,enum 的编译期安全检查优于运行时的 `HashMap::get()`。
|
||||
/// 未来如果扩展到 15+ 种以上,再改为注册表模式。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum ProviderType {
|
||||
/// OpenAI Chat Completions API(兼容 DeepSeek / Qwen 等 `/chat/completions` 端点)。
|
||||
OpenaiChat,
|
||||
@@ -29,6 +36,8 @@ pub enum ProviderType {
|
||||
DeepSeek,
|
||||
/// Qwen / 阿里云百炼(OpenAI-compatible `/chat/completions`)。
|
||||
Qwen,
|
||||
/// Ollama 本地推理(OpenAI-compatible `/chat/completions`,默认 `http://localhost:11434/v1`)。
|
||||
Ollama,
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ProviderType {
|
||||
@@ -41,104 +50,498 @@ impl std::str::FromStr for ProviderType {
|
||||
"anthropic" | "claude" => Ok(ProviderType::Anthropic),
|
||||
"deepseek" => Ok(ProviderType::DeepSeek),
|
||||
"qwen" | "dashscope" | "tongyi" => Ok(ProviderType::Qwen),
|
||||
"ollama" => Ok(ProviderType::Ollama),
|
||||
_ => Err(format!("未知的 Provider 类型: {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 构造参数 —— 通用 base_url + api_key + model。
|
||||
/// Provider 构造参数 —— 通用 base_url + api_key + model + timeout / retry 配置。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderConfig {
|
||||
/// API base URL(如 `https://api.openai.com/v1`)。为空时由 Provider 选择默认值。
|
||||
pub base_url: String,
|
||||
/// API key。Ollama 等本地 Provider 可为空。
|
||||
pub api_key: String,
|
||||
/// 模型名(如 `gpt-4o` / `claude-sonnet-4-20250514`)。
|
||||
pub model: String,
|
||||
/// 请求超时秒数(默认 30)。应用于 Provider 的 HTTP Client 级别。
|
||||
pub timeout_secs: u64,
|
||||
/// 最大重试次数(默认 3)。
|
||||
///
|
||||
/// 当前此字段仅由 `from_env()` 采集,**实际重试逻辑由 `CycleConfig.retry.max_retries` 控制**。
|
||||
/// 此处保留字段以与 Roadmap §Phase 5 Step 5.1 对齐;未来 Phase 6+ 可统一合并到 `CycleConfig`。
|
||||
pub max_retries: u32,
|
||||
}
|
||||
|
||||
impl Default for ProviderConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_url: String::new(),
|
||||
api_key: String::new(),
|
||||
model: String::new(),
|
||||
timeout_secs: 30,
|
||||
max_retries: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderConfig {
|
||||
/// 从环境变量构造 `ProviderConfig`。
|
||||
///
|
||||
/// 必填变量:
|
||||
/// - `{prefix}_BASE_URL`
|
||||
/// - `{prefix}_API_KEY`
|
||||
/// - `{prefix}_MODEL`
|
||||
///
|
||||
/// 可选变量(有默认值):
|
||||
/// - `{prefix}_TIMEOUT_SECS`(默认 30,解析失败回退 30 并 warn)
|
||||
/// - `{prefix}_MAX_RETRIES`(默认 3,解析失败回退 3 并 warn)
|
||||
pub fn from_env(prefix: &str) -> Result<Self, String> {
|
||||
let base_url = std::env::var(format!("{prefix}_BASE_URL"))
|
||||
.map_err(|_| format!("{prefix}_BASE_URL 环境变量未设置"))?;
|
||||
let api_key = std::env::var(format!("{prefix}_API_KEY"))
|
||||
.map_err(|_| format!("{prefix}_API_KEY 环境变量未设置"))?;
|
||||
let model = std::env::var(format!("{prefix}_MODEL"))
|
||||
.map_err(|_| format!("{prefix}_MODEL 环境变量未设置"))?;
|
||||
let timeout_secs = match std::env::var(format!("{prefix}_TIMEOUT_SECS")) {
|
||||
Ok(v) => v.parse().unwrap_or_else(|_| {
|
||||
tracing::warn!("{prefix}_TIMEOUT_SECS='{v}' 解析失败,使用默认值 30");
|
||||
30
|
||||
}),
|
||||
Err(_) => 30,
|
||||
};
|
||||
let max_retries = match std::env::var(format!("{prefix}_MAX_RETRIES")) {
|
||||
Ok(v) => v.parse().unwrap_or_else(|_| {
|
||||
tracing::warn!("{prefix}_MAX_RETRIES='{v}' 解析失败,使用默认值 3");
|
||||
3
|
||||
}),
|
||||
Err(_) => 3,
|
||||
};
|
||||
|
||||
// ponytail: max_retries 当前仅采集,不传入 Provider。
|
||||
// 实际重试由 CycleConfig.retry.max_retries 控制。
|
||||
if max_retries != 3 {
|
||||
tracing::warn!(
|
||||
"ProviderConfig.max_retries={} 已采集但当前未生效;\
|
||||
重试次数由 CycleConfig.retry.max_retries 控制",
|
||||
max_retries,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
base_url,
|
||||
api_key,
|
||||
model,
|
||||
timeout_secs,
|
||||
max_retries,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造带 timeout 的 `reqwest::Client`(OpenAI-compatible 共享)。
|
||||
fn build_client_with_timeout(timeout_secs: u64) -> Result<Client, LlmError> {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|e| LlmError::Other(format!("创建 HTTP 客户端失败: {e}")))
|
||||
}
|
||||
|
||||
/// 构造带 Anthropic 默认 headers + timeout 的 `reqwest::Client`。
|
||||
///
|
||||
/// Anthropic 由于需要保留 `x-api-key` / `anthropic-version` 默认 headers,
|
||||
/// 与 OpenAI-compatible 共享的 `build_client_with_timeout` 不同。
|
||||
fn build_anthropic_client(api_key: &str, timeout_secs: u64) -> Result<Client, LlmError> {
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
|
||||
let key_header = HeaderValue::from_str(api_key)
|
||||
.map_err(|_| LlmError::Other("Anthropic API key 包含无效的 HTTP 头部字符".into()))?;
|
||||
let version_header = HeaderValue::from_static("2023-06-01");
|
||||
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.default_headers({
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-api-key", key_header);
|
||||
headers.insert("anthropic-version", version_header);
|
||||
headers
|
||||
})
|
||||
.build()
|
||||
.map_err(|e| LlmError::Other(format!("创建 Anthropic HTTP 客户端失败: {e}")))
|
||||
}
|
||||
|
||||
/// Provider 工厂 —— exhaustive match 在编译期保证新 Provider 被注册。
|
||||
///
|
||||
/// `config.timeout_secs` 注入到 Provider 的 HTTP Client 超时配置。
|
||||
/// 每个分支通过 `from_parts` (pub(crate)) 一次性构造,无冗余 client 创建。
|
||||
pub fn create_provider(
|
||||
provider_type: ProviderType,
|
||||
config: ProviderConfig,
|
||||
) -> Result<Box<dyn LlmProvider>, LlmError> {
|
||||
match provider_type {
|
||||
ProviderType::OpenaiChat => Ok(Box::new(openai::OpenaiChatProvider::new(
|
||||
ProviderType::OpenaiChat => {
|
||||
let client = build_client_with_timeout(config.timeout_secs)?;
|
||||
Ok(Box::new(openai::OpenaiChatProvider(
|
||||
openai::GenericOpenaiProvider::from_parts(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
))),
|
||||
"openai",
|
||||
client,
|
||||
Vec::new(),
|
||||
config.timeout_secs,
|
||||
),
|
||||
)))
|
||||
}
|
||||
#[cfg(feature = "provider-openai-response")]
|
||||
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,
|
||||
Vec::new(),
|
||||
),
|
||||
))
|
||||
}
|
||||
#[cfg(not(feature = "provider-openai-response"))]
|
||||
ProviderType::OpenaiResponse => Err(LlmError::Other(
|
||||
"OpenaiResponse Provider 在 Phase 1 暂不实现;请使用 OpenaiChat".into(),
|
||||
"OpenaiResponse Provider 未编译:启用 `provider-openai-response` feature".into(),
|
||||
)),
|
||||
ProviderType::Anthropic => Ok(Box::new(anthropic::AnthropicProvider::new(
|
||||
ProviderType::Anthropic => {
|
||||
let client = build_anthropic_client(&config.api_key, config.timeout_secs)?;
|
||||
Ok(Box::new(anthropic::AnthropicProvider::from_parts(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
))),
|
||||
ProviderType::DeepSeek => Ok(Box::new(openai_compat::DeepSeekProvider::new(
|
||||
client,
|
||||
config.timeout_secs,
|
||||
Vec::new(),
|
||||
)))
|
||||
}
|
||||
ProviderType::DeepSeek => {
|
||||
let client = build_client_with_timeout(config.timeout_secs)?;
|
||||
Ok(Box::new(openai_compat::DeepSeekProvider(
|
||||
openai::GenericOpenaiProvider::from_parts(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
))),
|
||||
ProviderType::Qwen => Ok(Box::new(openai_compat::QwenProvider::new(
|
||||
"deepseek",
|
||||
client,
|
||||
Vec::new(),
|
||||
config.timeout_secs,
|
||||
),
|
||||
)))
|
||||
}
|
||||
ProviderType::Qwen => {
|
||||
let client = build_client_with_timeout(config.timeout_secs)?;
|
||||
Ok(Box::new(openai_compat::QwenProvider(
|
||||
openai::GenericOpenaiProvider::from_parts(
|
||||
config.base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
))),
|
||||
"qwen",
|
||||
client,
|
||||
vec![("X-DashScope-SSE".to_string(), "enable".to_string())],
|
||||
config.timeout_secs,
|
||||
),
|
||||
)))
|
||||
}
|
||||
ProviderType::Ollama => {
|
||||
let client = build_client_with_timeout(config.timeout_secs)?;
|
||||
// ponytail: Ollama 默认 base_url 由 OllamaProvider 构造处理 —— 但 from_parts 不走
|
||||
// OllamaProvider::new 的默认 URL 回退。这里保留 base_url(可能为空 → http://localhost:11434/v1)。
|
||||
let base_url = if config.base_url.is_empty() {
|
||||
"http://localhost:11434/v1".to_string()
|
||||
} else {
|
||||
config.base_url
|
||||
};
|
||||
Ok(Box::new(ollama::OllamaProvider(
|
||||
openai::GenericOpenaiProvider::from_parts(
|
||||
base_url,
|
||||
config.api_key,
|
||||
config.model,
|
||||
"ollama",
|
||||
client,
|
||||
Vec::new(),
|
||||
config.timeout_secs,
|
||||
),
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 能力描述 —— 静态元信息,调用方据此决定可用特性。
|
||||
/// Provider 能力描述、功能开关集合与 `LlmProvider` trait 定义。
|
||||
///
|
||||
/// 设计依据(见 `docs/10-llm-provider-refinement.md` §4 任务 6 决策):
|
||||
/// `ProviderCapabilities` 与 trait 同文件(`provider.rs`),不分散到类型目录。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderCapabilities {
|
||||
/// 人类可读的 Provider 名(如 `"openai"` / `"anthropic"`)。
|
||||
pub provider_name: &'static str,
|
||||
/// 支持的模型列表(`None` 表示"未列举全部")。
|
||||
pub supported_models: Option<Vec<String>>,
|
||||
/// 详细功能开关。
|
||||
pub features: ProviderFeatures,
|
||||
}
|
||||
|
||||
/// Provider 功能开关集合。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ProviderFeatures {
|
||||
/// 是否支持流式响应。
|
||||
pub streaming: bool,
|
||||
/// 是否支持 thinking / 推理。
|
||||
pub thinking: bool,
|
||||
/// 是否支持图片输入。
|
||||
pub vision: bool,
|
||||
/// 是否支持音频输入。
|
||||
pub audio_input: bool,
|
||||
/// 是否支持工具调用。
|
||||
pub tool_use: bool,
|
||||
/// 是否支持并行工具调用。
|
||||
pub parallel_tool_calls: bool,
|
||||
/// system prompt 是否放在 messages 中(`true`)还是顶层 `system` 字段(`false`)。
|
||||
pub system_prompt_in_messages: bool,
|
||||
/// 模型上下文窗口(tokens);`0` 表示未知。
|
||||
pub max_context_window: u32,
|
||||
}
|
||||
|
||||
/// LLM Provider 抽象接口。
|
||||
/// v0.3.2 Step 3 起,上述类型已移至 `src/llm/provider_trait.rs`(`#[cfg(feature = "llm")]`),
|
||||
/// 使纯 Mock 场景无需引入任何 provider feature。
|
||||
///
|
||||
/// 所有具体的 LLM 后端实现(OpenAI、Anthropic、DeepSeek、Qwen 等)
|
||||
/// 均需实现此 trait,以实现可插拔替换。
|
||||
///
|
||||
/// 修订(Phase 0):签名由 `chat(ChatRequest) → ChatResponse` 切换为
|
||||
/// `chat(MessageRequest) → MessageResponse`,`chat_stream` 返回新 `StreamEvent` 流,
|
||||
/// 新增 `capabilities()` 方法。
|
||||
#[async_trait::async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
/// 发送聊天请求并返回完整响应。
|
||||
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError>;
|
||||
/// 详见 `docs/27-step3-phase26-ci-verification.md` §3.2 工作 0 + ADR-1。
|
||||
|
||||
/// 流式聊天请求 —— 返回新 IR `StreamEvent` 流。
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 返回 Provider 静态能力描述。
|
||||
fn capabilities(&self) -> ProviderCapabilities;
|
||||
#[test]
|
||||
fn provider_config_default_values() {
|
||||
let config = ProviderConfig::default();
|
||||
assert_eq!(config.timeout_secs, 30);
|
||||
assert_eq!(config.max_retries, 3);
|
||||
assert_eq!(config.base_url, "");
|
||||
assert_eq!(config.api_key, "");
|
||||
assert_eq!(config.model, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_from_env_requires_all_three() {
|
||||
// 使用 temp_env 移除所有相关变量,避免外部环境意外设置导致测试 flaky
|
||||
temp_env::with_vars(
|
||||
[
|
||||
("TEST_PROVIDER_MISSING_BASE_URL", None::<&str>),
|
||||
("TEST_PROVIDER_MISSING_API_KEY", None::<&str>),
|
||||
("TEST_PROVIDER_MISSING_MODEL", None::<&str>),
|
||||
("TEST_PROVIDER_MISSING_TIMEOUT_SECS", None::<&str>),
|
||||
("TEST_PROVIDER_MISSING_MAX_RETRIES", None::<&str>),
|
||||
],
|
||||
|| {
|
||||
let result = ProviderConfig::from_env("TEST_PROVIDER_MISSING");
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err();
|
||||
assert!(
|
||||
msg.contains("TEST_PROVIDER_MISSING_BASE_URL"),
|
||||
"error should mention missing var, got: {msg}"
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_from_env_uses_defaults_when_only_required_set() {
|
||||
temp_env::with_vars(
|
||||
[
|
||||
("TEST_PROVIDER_BASE_URL", Some("http://localhost:11434/v1")),
|
||||
("TEST_PROVIDER_API_KEY", Some("")),
|
||||
("TEST_PROVIDER_MODEL", Some("llama3")),
|
||||
],
|
||||
|| {
|
||||
let config = ProviderConfig::from_env("TEST_PROVIDER").unwrap();
|
||||
assert_eq!(config.base_url, "http://localhost:11434/v1");
|
||||
assert_eq!(config.api_key, "");
|
||||
assert_eq!(config.model, "llama3");
|
||||
assert_eq!(config.timeout_secs, 30);
|
||||
assert_eq!(config.max_retries, 3);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_from_env_reads_custom_values() {
|
||||
temp_env::with_vars(
|
||||
[
|
||||
("TEST_PROVIDER_BASE_URL", Some("http://x")),
|
||||
("TEST_PROVIDER_API_KEY", Some("k")),
|
||||
("TEST_PROVIDER_MODEL", Some("m")),
|
||||
("TEST_PROVIDER_TIMEOUT_SECS", Some("60")),
|
||||
("TEST_PROVIDER_MAX_RETRIES", Some("5")),
|
||||
],
|
||||
|| {
|
||||
let config = ProviderConfig::from_env("TEST_PROVIDER").unwrap();
|
||||
assert_eq!(config.timeout_secs, 60);
|
||||
assert_eq!(config.max_retries, 5);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_from_env_falls_back_on_invalid_numbers() {
|
||||
temp_env::with_vars(
|
||||
[
|
||||
("TEST_PROVIDER_BASE_URL", Some("http://x")),
|
||||
("TEST_PROVIDER_API_KEY", Some("k")),
|
||||
("TEST_PROVIDER_MODEL", Some("m")),
|
||||
("TEST_PROVIDER_TIMEOUT_SECS", Some("not-a-number")),
|
||||
("TEST_PROVIDER_MAX_RETRIES", Some("also-bad")),
|
||||
],
|
||||
|| {
|
||||
let config = ProviderConfig::from_env("TEST_PROVIDER").unwrap();
|
||||
// 解析失败回退默认值
|
||||
assert_eq!(config.timeout_secs, 30);
|
||||
assert_eq!(config.max_retries, 3);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Timeout 传导集成测试:构造 `ProviderConfig` timeout=1s,
|
||||
/// `create_provider` 注入 1s 超时 client,请求一个故意延迟 3s 的 mock server,
|
||||
/// 验证返回 `LlmError::Timeout { duration: 1s }`。
|
||||
#[tokio::test]
|
||||
async fn create_provider_injects_timeout_into_openai_chat() {
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
let server = MockServer::start().await;
|
||||
// 故意延迟 3s 触发超时
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/chat/completions"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_delay(Duration::from_secs(3))
|
||||
.set_body_json(json!({
|
||||
"id": "x",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "gpt-4o",
|
||||
"choices": [],
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||
})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = create_provider(
|
||||
ProviderType::OpenaiChat,
|
||||
ProviderConfig {
|
||||
base_url: server.uri(),
|
||||
api_key: "sk-test".into(),
|
||||
model: "gpt-4o".into(),
|
||||
timeout_secs: 1,
|
||||
max_retries: 3,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = provider
|
||||
.chat(crate::llm::types::request_v2::MessageRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![],
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
LlmError::Timeout { duration } => {
|
||||
assert_eq!(duration, Duration::from_secs(1));
|
||||
}
|
||||
other => panic!("expected Timeout, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Timeout 传导验证:`create_provider` 生成的 DeepSeek Provider 也带 1s 超时,
|
||||
/// 错误消息中的 duration 与 timeout_secs 一致(而非硬编码 120s)。
|
||||
#[tokio::test]
|
||||
async fn create_provider_injects_timeout_into_deepseek() {
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/chat/completions"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_delay(Duration::from_secs(3))
|
||||
.set_body_json(json!({
|
||||
"id": "x",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "deepseek-chat",
|
||||
"choices": [],
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||
})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = create_provider(
|
||||
ProviderType::DeepSeek,
|
||||
ProviderConfig {
|
||||
base_url: server.uri(),
|
||||
api_key: "sk-test".into(),
|
||||
model: "deepseek-chat".into(),
|
||||
timeout_secs: 1,
|
||||
max_retries: 3,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = provider
|
||||
.chat(crate::llm::types::request_v2::MessageRequest {
|
||||
model: "deepseek-chat".into(),
|
||||
messages: vec![],
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
LlmError::Timeout { duration } => {
|
||||
assert_eq!(duration, Duration::from_secs(1));
|
||||
}
|
||||
other => panic!("expected Timeout, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Timeout 传导验证:`create_provider` 生成的 Anthropic Provider 通过 `with_timeout`
|
||||
/// 注入 1s 超时。
|
||||
///
|
||||
/// 与 OpenAI-compatible 路径不同,Anthropic 走 `AnthropicProvider::with_timeout()`
|
||||
/// 重建底层 client(保留 default_headers),独立于 OpenAI-compatible 的 `build_client_with_timeout`。
|
||||
/// 单独覆盖此路径以验证 `with_timeout` 不会因服务端延迟而返回硬编码 120s 的超时错误。
|
||||
#[tokio::test]
|
||||
async fn create_provider_injects_timeout_into_anthropic() {
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
let server = MockServer::start().await;
|
||||
// Anthropic Messages API 端点:`POST /v1/messages`
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/messages"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_delay(Duration::from_secs(3))
|
||||
.set_body_json(json!({
|
||||
"id": "msg_timeout_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "ok"}],
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1}
|
||||
})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = create_provider(
|
||||
ProviderType::Anthropic,
|
||||
ProviderConfig {
|
||||
base_url: server.uri(),
|
||||
api_key: "sk-ant-test".into(),
|
||||
model: "claude-sonnet-4-20250514".into(),
|
||||
timeout_secs: 1,
|
||||
max_retries: 3,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = provider
|
||||
.chat(crate::llm::types::request_v2::MessageRequest {
|
||||
model: "claude-sonnet-4-20250514".into(),
|
||||
messages: vec![],
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
LlmError::Timeout { duration } => {
|
||||
assert_eq!(duration, Duration::from_secs(1));
|
||||
}
|
||||
other => panic!("expected Timeout, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+589
-64
@@ -4,6 +4,7 @@
|
||||
//! → `content_block_stop` → `message_delta` → `message_stop`。与 OpenAI 不同,
|
||||
//! Anthropic 提供显式 block 边界事件,状态机相对简单。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
@@ -12,13 +13,12 @@ use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use futures_core::Stream;
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use reqwest::Client;
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::types::message::{ContentBlock, ContentBlockType, Message};
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
@@ -26,6 +26,7 @@ use crate::llm::types::response_v2::{
|
||||
MessageResponse, PartialMessageResponse, PartialUsage, StopReason, StreamEvent,
|
||||
};
|
||||
use crate::llm::types::usage::Usage;
|
||||
use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
|
||||
/// Anthropic Provider 默认 `max_tokens` 兜底值。
|
||||
///
|
||||
@@ -39,16 +40,22 @@ pub struct AnthropicProvider {
|
||||
#[allow(dead_code)]
|
||||
api_key: String,
|
||||
model: String,
|
||||
/// HTTP 请求超时秒数。由 `ProviderConfig::timeout_secs` 传入,
|
||||
/// 在 `LlmError::Timeout { duration }` 中回显。`reqwest::Client` 不暴露 timeout getter,
|
||||
/// 因此单独存储以便错误消息与配置保持一致。
|
||||
timeout_secs: u64,
|
||||
/// Provider 级别固定请求头(如平台标识头),所有请求自动携带。
|
||||
extra_headers: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl AnthropicProvider {
|
||||
pub fn new(base_url: String, api_key: String, model: String) -> Self {
|
||||
let key_header = HeaderValue::from_str(&api_key)
|
||||
.expect("Anthropic API key 包含无效的 HTTP 头部字符");
|
||||
pub fn new(base_url: String, api_key: String, model: String, timeout_secs: u64) -> Self {
|
||||
let key_header =
|
||||
HeaderValue::from_str(&api_key).expect("Anthropic API key 包含无效的 HTTP 头部字符");
|
||||
let version_header = HeaderValue::from_static("2023-06-01");
|
||||
|
||||
let http_client = Client::builder()
|
||||
.timeout(Duration::from_secs(120))
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.default_headers({
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-api-key", key_header);
|
||||
@@ -67,14 +74,90 @@ impl AnthropicProvider {
|
||||
},
|
||||
api_key,
|
||||
model,
|
||||
timeout_secs,
|
||||
extra_headers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ⚠️ 替换 HTTP Client,**丢弃** `new()` 中设置的默认 headers(`x-api-key` / `anthropic-version`)。
|
||||
///
|
||||
/// 调用此方法后,所有 Anthropic API 请求将以**无认证头**发送出去,预期会 401/403 失败。
|
||||
/// 推荐改用 [`Self::with_timeout`],它会重建 client 并保留默认 headers。
|
||||
///
|
||||
/// 此方法仍保留以兼容调用方自定义 client 但不需要默认 headers 的极端场景。
|
||||
#[deprecated(
|
||||
since = "0.2.0",
|
||||
note = "此方法会丢弃默认 headers(x-api-key / anthropic-version),改为使用 `with_timeout` 或带 headers 的 `Client::builder()`"
|
||||
)]
|
||||
pub fn with_client(mut self, client: Client) -> Self {
|
||||
self.http_client = client;
|
||||
self
|
||||
}
|
||||
|
||||
/// 替换 HTTP Client 的超时配置(重建底层 client,保留默认 headers)。
|
||||
///
|
||||
/// ⚠️ 副作用:此方法**完全重建** `http_client`,调用后通过 `with_client` 注入的 Client
|
||||
/// 将被替换。headers 构造逻辑与 `new()` 中的保持一致(`x-api-key` / `anthropic-version`)。
|
||||
///
|
||||
/// ponytail: 同值调用短路。当 `secs == self.timeout_secs` 时跳过 client 重建,
|
||||
/// 避免 `create_provider` 路径 `new(timeout).with_timeout(timeout)` 的双重构造。
|
||||
pub fn with_timeout(mut self, secs: u64) -> Result<Self, LlmError> {
|
||||
if secs == self.timeout_secs {
|
||||
return Ok(self);
|
||||
}
|
||||
// ponytail: 重建 http_client 时保留已有默认 headers(x-api-key / anthropic-version)。
|
||||
// 如后续 AnthropicProvider 的 headers 变为动态,此方法需同步更新。
|
||||
let key_header = HeaderValue::from_str(&self.api_key)
|
||||
.map_err(|_| LlmError::Other("Anthropic API key 包含无效的 HTTP 头部字符".into()))?;
|
||||
let version_header = HeaderValue::from_static("2023-06-01");
|
||||
|
||||
self.http_client = Client::builder()
|
||||
.timeout(Duration::from_secs(secs))
|
||||
.default_headers({
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-api-key", key_header);
|
||||
headers.insert("anthropic-version", version_header);
|
||||
headers
|
||||
})
|
||||
.build()
|
||||
.map_err(|e| LlmError::Other(format!("创建 Anthropic HTTP 客户端失败: {e}")))?;
|
||||
self.timeout_secs = secs;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// 一次性构造 —— `create_provider` 路径专用,避免 `new(...)` + `with_timeout(...)` 的双重 client 构造。
|
||||
///
|
||||
/// 调用方负责预先构造好符合 Anthropic 协议要求的 `http_client`(带正确的 `x-api-key` /
|
||||
/// `anthropic-version` 默认 headers + 指定 timeout)。
|
||||
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 {
|
||||
Self {
|
||||
http_client,
|
||||
base_url: if base_url.is_empty() {
|
||||
"https://api.anthropic.com".to_string()
|
||||
} else {
|
||||
base_url
|
||||
},
|
||||
api_key,
|
||||
model,
|
||||
timeout_secs,
|
||||
extra_headers,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置 Provider 级别固定头,替换已有的 extra_headers(如有)。
|
||||
/// 返回 self 以支持链式调用。如需追加语义,在外部自行 `extend`。
|
||||
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
|
||||
self.extra_headers = headers;
|
||||
self
|
||||
}
|
||||
|
||||
fn resolve_max_tokens(&self, request: &MessageRequest) -> u32 {
|
||||
request.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS)
|
||||
}
|
||||
@@ -104,12 +187,14 @@ impl AnthropicProvider {
|
||||
Message::User { content } => {
|
||||
api_messages.push(AnthropicMessage::user(content));
|
||||
}
|
||||
Message::UserImage { data, mime_type, detail } => {
|
||||
Message::UserImage {
|
||||
data,
|
||||
mime_type,
|
||||
detail,
|
||||
} => {
|
||||
// Anthropic image format: {type: "image", source: {type: "base64", media_type, data}}
|
||||
let source = if data.starts_with("http://") || data.starts_with("https://") {
|
||||
AnthropicImageSource::Url {
|
||||
url: data.clone(),
|
||||
}
|
||||
AnthropicImageSource::Url { url: data.clone() }
|
||||
} else {
|
||||
AnthropicImageSource::Base64 {
|
||||
media_type: mime_type.clone(),
|
||||
@@ -142,6 +227,10 @@ impl AnthropicProvider {
|
||||
|
||||
let max_tokens = self.resolve_max_tokens(&request);
|
||||
|
||||
// ponytail: 提前抽取 custom_headers,避免后续 into_iter 消耗 request.tools 后借用失败。
|
||||
let custom_headers: HashMap<String, String> =
|
||||
request.get_extra_opt("custom_headers").unwrap_or_default();
|
||||
|
||||
let tools = if request.tools.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -179,22 +268,57 @@ impl AnthropicProvider {
|
||||
tools,
|
||||
thinking,
|
||||
stream: if request.stream { Some(true) } else { None },
|
||||
custom_headers,
|
||||
})
|
||||
}
|
||||
|
||||
/// 构造 HTTP POST 请求 builder(含认证头 + 自定义头)。
|
||||
/// 认证头(x-api-key / anthropic-version)已由 Client 的 default_headers 提供。
|
||||
///
|
||||
/// 头融合顺序:认证头(default_headers)→ Provider 级 extra_headers → 请求级 custom_headers
|
||||
/// 后者覆盖前者。非法 header 名/值(如控制字符)静默跳过 + warn,避免 reqwest panic。
|
||||
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 {
|
||||
if let (Ok(name), Ok(value)) = (
|
||||
HeaderName::from_bytes(k.as_bytes()),
|
||||
HeaderValue::from_str(v),
|
||||
) {
|
||||
builder = builder.header(name, value);
|
||||
} else {
|
||||
warn!(header = %k, "skipping invalid extra_header (key or value contains illegal characters)");
|
||||
}
|
||||
}
|
||||
|
||||
for (key, value) in &body.custom_headers {
|
||||
if let (Ok(name), Ok(value)) = (
|
||||
HeaderName::from_bytes(key.as_bytes()),
|
||||
HeaderValue::from_str(value),
|
||||
) {
|
||||
builder = builder.header(name, value);
|
||||
} else {
|
||||
warn!(header = %key, "skipping invalid custom_header (key or value contains illegal characters)");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(builder)
|
||||
}
|
||||
|
||||
async fn chat_blocking(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
|
||||
let body = self.build_request_body(request)?;
|
||||
let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
|
||||
|
||||
info!(model = %body.model, "Anthropic: 发送非流式请求");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.build_request_builder(&body)?
|
||||
.send()
|
||||
.await
|
||||
.map_err(Self::map_reqwest_error)?;
|
||||
.map_err(|e| self.map_reqwest_error(e))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
@@ -215,31 +339,26 @@ impl AnthropicProvider {
|
||||
async fn chat_stream_inner(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
|
||||
{
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
|
||||
let mut body = self.build_request_body(request)?;
|
||||
body.stream = Some(true);
|
||||
|
||||
let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
|
||||
|
||||
info!(model = %body.model, "Anthropic: 发送流式请求");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.build_request_builder(&body)?
|
||||
.send()
|
||||
.await
|
||||
.map_err(Self::map_reqwest_error)?;
|
||||
.map_err(|e| self.map_reqwest_error(e))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(Self::handle_error_response(response).await);
|
||||
}
|
||||
|
||||
let byte_stream = response.bytes_stream().map(|r| {
|
||||
r.map_err(|e| LlmError::Other(format!("流式读取失败: {e}")))
|
||||
});
|
||||
let byte_stream = response
|
||||
.bytes_stream()
|
||||
.map(|r| r.map_err(|e| LlmError::Other(format!("流式读取失败: {e}"))));
|
||||
|
||||
let byte_stream: Pin<Box<dyn Stream<Item = Result<Bytes, LlmError>> + Send>> =
|
||||
Box::pin(byte_stream);
|
||||
@@ -247,10 +366,10 @@ impl AnthropicProvider {
|
||||
Ok(Box::pin(AnthropicSseStream::new(byte_stream)))
|
||||
}
|
||||
|
||||
fn map_reqwest_error(e: reqwest::Error) -> LlmError {
|
||||
fn map_reqwest_error(&self, e: reqwest::Error) -> LlmError {
|
||||
if e.is_timeout() {
|
||||
LlmError::Timeout {
|
||||
duration: Duration::from_secs(120),
|
||||
duration: Duration::from_secs(self.timeout_secs),
|
||||
}
|
||||
} else if e.is_connect() {
|
||||
LlmError::Other(format!("连接失败: {e}"))
|
||||
@@ -291,13 +410,12 @@ impl AnthropicProvider {
|
||||
blocks.push(ContentBlock::Text { text });
|
||||
}
|
||||
AnthropicContentBlockResp::ToolUse { id, name, input } => {
|
||||
blocks.push(ContentBlock::ToolUse {
|
||||
id,
|
||||
name,
|
||||
input,
|
||||
});
|
||||
blocks.push(ContentBlock::ToolUse { id, name, input });
|
||||
}
|
||||
AnthropicContentBlockResp::Thinking { thinking, signature } => {
|
||||
AnthropicContentBlockResp::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
blocks.push(ContentBlock::Thinking {
|
||||
text: thinking,
|
||||
signature,
|
||||
@@ -336,8 +454,7 @@ impl LlmProvider for AnthropicProvider {
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
|
||||
{
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
|
||||
self.chat_stream_inner(request).await
|
||||
}
|
||||
|
||||
@@ -379,6 +496,9 @@ struct AnthropicRequestBody {
|
||||
thinking: Option<AnthropicThinking>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stream: Option<bool>,
|
||||
/// 请求级别自定义 HTTP 头。运行时注入,不进入 JSON 请求体。
|
||||
#[serde(skip)]
|
||||
custom_headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -418,7 +538,9 @@ impl AnthropicMessage {
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum AnthropicContentPart {
|
||||
Text { text: String },
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
Image {
|
||||
source: AnthropicImageSource,
|
||||
},
|
||||
@@ -437,13 +559,8 @@ enum AnthropicContentPart {
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum AnthropicImageSource {
|
||||
Base64 {
|
||||
media_type: String,
|
||||
data: String,
|
||||
},
|
||||
Url {
|
||||
url: String,
|
||||
},
|
||||
Base64 { media_type: String, data: String },
|
||||
Url { url: String },
|
||||
}
|
||||
|
||||
fn content_to_parts(blocks: &[ContentBlock]) -> Vec<AnthropicContentPart> {
|
||||
@@ -523,9 +640,18 @@ struct AnthropicUsage {
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum AnthropicContentBlockResp {
|
||||
Text { text: String },
|
||||
ToolUse { id: String, name: String, input: Value },
|
||||
Thinking { thinking: String, signature: Option<String> },
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: Value,
|
||||
},
|
||||
Thinking {
|
||||
thinking: String,
|
||||
signature: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -662,18 +788,13 @@ impl AnthropicSseStream {
|
||||
// 先把所有字段提前,避免 match 中 part-move
|
||||
let block_type = match &content_block {
|
||||
AnthropicContentBlockStart::Text { .. } => ContentBlockType::Text,
|
||||
AnthropicContentBlockStart::ToolUse { id, name } => {
|
||||
ContentBlockType::ToolUse {
|
||||
AnthropicContentBlockStart::ToolUse { id, name } => ContentBlockType::ToolUse {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
}
|
||||
}
|
||||
},
|
||||
AnthropicContentBlockStart::Thinking { .. } => ContentBlockType::Thinking,
|
||||
};
|
||||
events.push(StreamEvent::ContentBlockStart {
|
||||
index,
|
||||
block_type,
|
||||
});
|
||||
events.push(StreamEvent::ContentBlockStart { index, block_type });
|
||||
let builder = match content_block {
|
||||
AnthropicContentBlockStart::Text { text } => {
|
||||
crate::llm::types::response_v2::ContentBlockBuilder::Text(text)
|
||||
@@ -742,7 +863,9 @@ impl AnthropicSseStream {
|
||||
completion_tokens_details: None,
|
||||
prompt_tokens_details: None,
|
||||
};
|
||||
events.push(StreamEvent::CostUpdate { usage: partial_usage });
|
||||
events.push(StreamEvent::CostUpdate {
|
||||
usage: partial_usage,
|
||||
});
|
||||
}
|
||||
}
|
||||
AnthropicSseEvent::MessageStop => {
|
||||
@@ -753,7 +876,9 @@ impl AnthropicSseStream {
|
||||
self.saw_terminal = true;
|
||||
match self.partial.clone().finalize() {
|
||||
Ok(full) => {
|
||||
events.push(StreamEvent::MessageComplete { full_response: full });
|
||||
events.push(StreamEvent::MessageComplete {
|
||||
full_response: full,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
events.push(StreamEvent::Error {
|
||||
@@ -780,10 +905,7 @@ fn _unused_marker() {}
|
||||
impl Stream for AnthropicSseStream {
|
||||
type Item = Result<StreamEvent, LlmError>;
|
||||
|
||||
fn poll_next(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Option<Self::Item>> {
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
loop {
|
||||
if let Some(data) = self.next_event_line() {
|
||||
let mut events = self.handle_event_json(&data);
|
||||
@@ -830,12 +952,17 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn make_provider(base_url: String) -> AnthropicProvider {
|
||||
// 跳过默认 header 注入:测试用自定义 base_url 直接 mock
|
||||
AnthropicProvider::new(base_url, "sk-ant-test".into(), "claude-sonnet-4-20250514".into())
|
||||
AnthropicProvider::new(
|
||||
base_url,
|
||||
"sk-ant-test".into(),
|
||||
"claude-sonnet-4-20250514".into(),
|
||||
30,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -979,6 +1106,7 @@ event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
|
||||
"http://x".into(),
|
||||
"k".into(),
|
||||
"claude-sonnet-4-20250514".into(),
|
||||
30,
|
||||
)
|
||||
.capabilities();
|
||||
assert_eq!(caps.provider_name, "anthropic");
|
||||
@@ -1000,4 +1128,401 @@ event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n";
|
||||
assert_eq!(body.max_tokens, DEFAULT_MAX_TOKENS);
|
||||
assert_eq!(body.model, "claude-sonnet-4-20250514");
|
||||
}
|
||||
|
||||
// ===== Phase 11 Step 11.2 wiremock roundtrip 测试 =====
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_401_structured_error() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/messages"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "authentication_error",
|
||||
"message": "Invalid API key provided: sk-ant-test"
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = make_provider(server.uri());
|
||||
let err = provider
|
||||
.chat_blocking(MessageRequest {
|
||||
model: "claude-sonnet-4-20250514".into(),
|
||||
messages: vec![Message::user_text("Hi")],
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
LlmError::Authentication(msg) => assert!(msg.contains("Invalid API key")),
|
||||
other => panic!("expected Authentication, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_tool_use_response() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/messages"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id": "msg_tool",
|
||||
"type": "message",
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"content": [
|
||||
{"type": "text", "text": "Let me check."},
|
||||
{"type": "tool_use", "id": "toolu_abc", "name": "lookup", "input": {"q": "rust"}}
|
||||
],
|
||||
"stop_reason": "tool_use",
|
||||
"usage": {"input_tokens": 8, "output_tokens": 12}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = make_provider(server.uri());
|
||||
let response = provider
|
||||
.chat_blocking(MessageRequest {
|
||||
model: "claude-sonnet-4-20250514".into(),
|
||||
messages: vec![Message::user_text("Look up rust")],
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.stop_reason, StopReason::ToolUse);
|
||||
let tool_use = match &response.message {
|
||||
Message::Assistant { content } => content.iter().find_map(|b| match b {
|
||||
ContentBlock::ToolUse { id, name, .. } => Some((id.clone(), name.clone())),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(tool_use, Some(("toolu_abc".into(), "lookup".into())));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_version_header() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/messages"))
|
||||
.and(header("anthropic-version", "2023-06-01"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id": "msg_v",
|
||||
"type": "message",
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"content": [{"type": "text", "text": "OK"}],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = make_provider(server.uri());
|
||||
let response = provider
|
||||
.chat_blocking(MessageRequest {
|
||||
model: "claude-sonnet-4-20250514".into(),
|
||||
messages: vec![Message::user_text("Hi")],
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.text(), "OK");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_529_overloaded_structured() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/messages"))
|
||||
.respond_with(ResponseTemplate::new(529).set_body_json(json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "overloaded_error",
|
||||
"message": "Overloaded: Anthropic API is temporarily overloaded"
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = make_provider(server.uri());
|
||||
let err = provider
|
||||
.chat_blocking(MessageRequest {
|
||||
model: "claude-sonnet-4-20250514".into(),
|
||||
messages: vec![Message::user_text("Hi")],
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
LlmError::RateLimit { retry_after } => assert!(retry_after.is_none()),
|
||||
other => panic!("expected RateLimit, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ===== custom_headers (Phase 8 Step 8.7) =====
|
||||
|
||||
fn mock_messages_body() -> serde_json::Value {
|
||||
json!({
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"content": [{"type": "text", "text": "OK"}],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1}
|
||||
})
|
||||
}
|
||||
|
||||
fn make_provider_with_extra_headers(
|
||||
base_url: String,
|
||||
extra_headers: Vec<(String, String)>,
|
||||
) -> AnthropicProvider {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("create http client");
|
||||
AnthropicProvider::from_parts(
|
||||
base_url,
|
||||
"sk-ant-test".into(),
|
||||
"claude-sonnet-4-20250514".into(),
|
||||
client,
|
||||
30,
|
||||
extra_headers,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_custom_headers_from_extra() {
|
||||
let provider = make_provider_with_extra_headers("http://x".into(), Vec::new());
|
||||
let mut req = MessageRequest {
|
||||
model: "claude-sonnet-4-20250514".into(),
|
||||
messages: vec![Message::user_text("hi")],
|
||||
..Default::default()
|
||||
};
|
||||
req.set_extra("custom_headers", json!({"X-Custom": "v1", "X-Other": "v2"}));
|
||||
let body = provider.build_request_body(req).unwrap();
|
||||
assert_eq!(body.custom_headers.get("X-Custom").unwrap(), "v1");
|
||||
assert_eq!(body.custom_headers.get("X-Other").unwrap(), "v2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_custom_headers_skipped_in_json_body() {
|
||||
let provider = make_provider_with_extra_headers("http://x".into(), Vec::new());
|
||||
let mut req = MessageRequest {
|
||||
model: "claude-sonnet-4-20250514".into(),
|
||||
messages: vec![Message::user_text("hi")],
|
||||
..Default::default()
|
||||
};
|
||||
req.set_extra("custom_headers", json!({"X-Custom": "v1"}));
|
||||
let body = provider.build_request_body(req).unwrap();
|
||||
let value = serde_json::to_value(&body).unwrap();
|
||||
assert!(
|
||||
value.get("custom_headers").is_none(),
|
||||
"custom_headers 不应进入 JSON body"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_custom_headers_invalid_type_fallback() {
|
||||
let provider = make_provider_with_extra_headers("http://x".into(), Vec::new());
|
||||
let mut req = MessageRequest {
|
||||
model: "claude-sonnet-4-20250514".into(),
|
||||
messages: vec![Message::user_text("hi")],
|
||||
..Default::default()
|
||||
};
|
||||
req.set_extra("custom_headers", json!("not_an_object"));
|
||||
let body = provider.build_request_body(req).unwrap();
|
||||
assert!(body.custom_headers.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_custom_headers_are_sent() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/messages"))
|
||||
.and(header("X-Custom", "v1"))
|
||||
.and(header("anthropic-version", "2023-06-01"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_messages_body()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = make_provider(server.uri());
|
||||
let mut req = MessageRequest {
|
||||
model: "claude-sonnet-4-20250514".into(),
|
||||
messages: vec![Message::user_text("hi")],
|
||||
..Default::default()
|
||||
};
|
||||
req.set_extra("custom_headers", json!({"X-Custom": "v1"}));
|
||||
|
||||
let resp = provider.chat_blocking(req).await.unwrap();
|
||||
assert_eq!(resp.text(), "OK");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_provider_level_headers_are_sent() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/messages"))
|
||||
.and(header("X-Platform", "anthropic-test"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_messages_body()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = make_provider_with_extra_headers(
|
||||
server.uri(),
|
||||
vec![("X-Platform".into(), "anthropic-test".into())],
|
||||
);
|
||||
let req = MessageRequest {
|
||||
model: "claude-sonnet-4-20250514".into(),
|
||||
messages: vec![Message::user_text("hi")],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let resp = provider.chat_blocking(req).await.unwrap();
|
||||
assert_eq!(resp.text(), "OK");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_custom_headers_override_provider_headers() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/messages"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_messages_body()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = make_provider_with_extra_headers(
|
||||
server.uri(),
|
||||
vec![("X-Platform".into(), "provider-level".into())],
|
||||
);
|
||||
let mut req = MessageRequest {
|
||||
model: "claude-sonnet-4-20250514".into(),
|
||||
messages: vec![Message::user_text("hi")],
|
||||
..Default::default()
|
||||
};
|
||||
req.set_extra("custom_headers", json!({"X-Platform": "request-wins"}));
|
||||
|
||||
let resp = provider.chat_blocking(req).await.unwrap();
|
||||
assert_eq!(resp.text(), "OK");
|
||||
|
||||
let received = server.received_requests().await.unwrap();
|
||||
assert_eq!(received.len(), 1);
|
||||
let platforms: Vec<&str> = received[0]
|
||||
.headers
|
||||
.get_all("X-Platform")
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.collect();
|
||||
assert!(platforms.contains(&"request-wins"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anthropic_custom_headers_can_override_auth_header() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/messages"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(mock_messages_body()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = make_provider_with_extra_headers(server.uri(), Vec::new());
|
||||
let mut req = MessageRequest {
|
||||
model: "claude-sonnet-4-20250514".into(),
|
||||
messages: vec![Message::user_text("hi")],
|
||||
..Default::default()
|
||||
};
|
||||
req.set_extra(
|
||||
"custom_headers",
|
||||
json!({"x-api-key": "from-custom-headers"}),
|
||||
);
|
||||
|
||||
let resp = provider.chat_blocking(req).await.unwrap();
|
||||
assert_eq!(resp.text(), "OK");
|
||||
|
||||
let received = server.received_requests().await.unwrap();
|
||||
assert_eq!(received.len(), 1);
|
||||
let keys: Vec<&str> = received[0]
|
||||
.headers
|
||||
.get_all("x-api-key")
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.collect();
|
||||
assert!(
|
||||
keys.contains(&"from-custom-headers"),
|
||||
"custom_headers 应能覆盖 x-api-key 头,实际收到: {keys:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_extra_headers_from_constructor_unit() {
|
||||
// ponytail: 用 RequestBuilder::build() 直检 headers,无需 wiremock。
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("create http client");
|
||||
let provider = AnthropicProvider::from_parts(
|
||||
"http://x".into(),
|
||||
"sk-ant-test".into(),
|
||||
"claude-sonnet-4-20250514".into(),
|
||||
client,
|
||||
30,
|
||||
vec![("X-Platform".into(), "anthropic-test".into())],
|
||||
);
|
||||
let body = AnthropicRequestBody {
|
||||
model: "claude-sonnet-4-20250514".into(),
|
||||
max_tokens: 4096,
|
||||
system: None,
|
||||
messages: Vec::new(),
|
||||
tools: None,
|
||||
thinking: None,
|
||||
stream: None,
|
||||
custom_headers: HashMap::new(),
|
||||
};
|
||||
let req = provider
|
||||
.build_request_builder(&body)
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(req.headers().get("X-Platform").unwrap(), "anthropic-test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_invalid_header_name_is_skipped() {
|
||||
// ponytail: extra_headers 含非法 key 应被跳过,不应让 reqwest panic。
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("create http client");
|
||||
let provider = AnthropicProvider::from_parts(
|
||||
"http://x".into(),
|
||||
"sk-ant-test".into(),
|
||||
"claude-sonnet-4-20250514".into(),
|
||||
client,
|
||||
30,
|
||||
Vec::new(),
|
||||
)
|
||||
.with_extra_headers(vec![
|
||||
("X-Valid".into(), "v1".into()),
|
||||
("bad\nname".into(), "v2".into()),
|
||||
]);
|
||||
let body = AnthropicRequestBody {
|
||||
model: "claude-sonnet-4-20250514".into(),
|
||||
max_tokens: 4096,
|
||||
system: None,
|
||||
messages: Vec::new(),
|
||||
tools: None,
|
||||
thinking: None,
|
||||
stream: None,
|
||||
custom_headers: HashMap::new(),
|
||||
};
|
||||
let req = provider
|
||||
.build_request_builder(&body)
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(req.headers().get("X-Valid").unwrap(), "v1");
|
||||
assert!(
|
||||
req.headers().get("bad\nname").is_none(),
|
||||
"非法 header 名应被静默跳过"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Ollama Provider —— OpenAI-compatible 协议的 newtype 包装,零 API key。
|
||||
//!
|
||||
//! 默认 base_url = `http://localhost:11434/v1`,空 api_key 也可工作。
|
||||
//! 实现方式同 `DeepSeekProvider` / `QwenProvider`,共享 `GenericOpenaiProvider`
|
||||
//! 的 HTTP/SSE/转换逻辑,仅配置不同。
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures_core::Stream;
|
||||
use reqwest::Client;
|
||||
|
||||
use super::openai::GenericOpenaiProvider;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
|
||||
use crate::llm::{LlmProvider, ProviderCapabilities};
|
||||
|
||||
/// Ollama 本地 Provider —— OpenAI-compatible 协议的 newtype 包装。
|
||||
///
|
||||
/// Ollama 在 `localhost:11434` 暴露与 OpenAI 兼容的 `/v1/chat/completions`
|
||||
/// 接口,因此完全复用 `GenericOpenaiProvider` 的实现。允许空 `api_key`。
|
||||
pub struct OllamaProvider(pub GenericOpenaiProvider);
|
||||
|
||||
impl OllamaProvider {
|
||||
/// 构造 Ollama Provider。
|
||||
///
|
||||
/// - `base_url` 为空时使用默认 `http://localhost:11434/v1`
|
||||
/// - `api_key` 可为空字符串(Ollama 不校验)
|
||||
pub fn new(base_url: String, api_key: String, model: String, timeout_secs: u64) -> Self {
|
||||
let url = if base_url.is_empty() {
|
||||
"http://localhost:11434/v1".to_string()
|
||||
} else {
|
||||
base_url
|
||||
};
|
||||
Self(GenericOpenaiProvider::new_with_name(
|
||||
url,
|
||||
api_key,
|
||||
model,
|
||||
"ollama",
|
||||
timeout_secs,
|
||||
))
|
||||
}
|
||||
|
||||
/// 替换默认 HTTP Client(用于 timeout 注入等场景)。
|
||||
///
|
||||
/// 与 `OpenaiChatProvider::with_client`、`DeepSeekProvider::with_client`、
|
||||
/// `QwenProvider::with_client` 签名一致。
|
||||
pub fn with_client(self, client: Client) -> Self {
|
||||
Self(self.0.with_client(client))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for OllamaProvider {
|
||||
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
|
||||
self.0.chat(request).await
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
|
||||
self.0.chat_stream(request).await
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
let mut caps = self.0.capabilities();
|
||||
caps.provider_name = "ollama";
|
||||
caps
|
||||
}
|
||||
}
|
||||
+1048
-74
File diff suppressed because it is too large
Load Diff
@@ -16,11 +16,10 @@ use async_trait::async_trait;
|
||||
use futures_core::Stream;
|
||||
|
||||
use super::openai::GenericOpenaiProvider;
|
||||
use super::ProviderCapabilities;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
|
||||
use crate::llm::provider::LlmProvider;
|
||||
use crate::llm::{LlmProvider, ProviderCapabilities};
|
||||
|
||||
// =============================================================================
|
||||
// DeepSeek
|
||||
@@ -29,7 +28,7 @@ use crate::llm::provider::LlmProvider;
|
||||
pub struct DeepSeekProvider(pub GenericOpenaiProvider);
|
||||
|
||||
impl DeepSeekProvider {
|
||||
pub fn new(base_url: String, api_key: String, model: String) -> Self {
|
||||
pub fn new(base_url: String, api_key: String, model: String, timeout_secs: u64) -> Self {
|
||||
let url = if base_url.is_empty() {
|
||||
"https://api.deepseek.com".to_string()
|
||||
} else {
|
||||
@@ -40,26 +39,27 @@ impl DeepSeekProvider {
|
||||
api_key,
|
||||
model,
|
||||
"deepseek",
|
||||
timeout_secs,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl DeepSeekProvider {
|
||||
/// 替换默认 HTTP Client(用于 timeout 注入等场景)。
|
||||
pub fn with_client(self, client: reqwest::Client) -> Self {
|
||||
Self(self.0.with_client(client))
|
||||
}
|
||||
|
||||
/// 测试中(带 mock_client)使用的构造器。
|
||||
///
|
||||
/// ponytail: 此处 `30` 是 `timeout_secs` 字段的占位值,仅用于 `map_reqwest_error`
|
||||
/// 错误消息中的回显。实际请求超时由传入的 `client` 控制(通常测试用的 mock client
|
||||
/// 无超时),不影响行为。
|
||||
pub fn new_with_client(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
client: reqwest::Client,
|
||||
) -> Self {
|
||||
let url = if base_url.is_empty() {
|
||||
"https://api.deepseek.com".to_string()
|
||||
} else {
|
||||
base_url
|
||||
};
|
||||
let mut inner = GenericOpenaiProvider::new_with_name(url, api_key, model, "deepseek");
|
||||
inner.http_client = client;
|
||||
Self(inner)
|
||||
Self::new(base_url, api_key, model, 30).with_client(client)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,8 +72,7 @@ impl LlmProvider for DeepSeekProvider {
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
|
||||
{
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
|
||||
self.0.chat_stream(request).await
|
||||
}
|
||||
|
||||
@@ -91,7 +90,7 @@ impl LlmProvider for DeepSeekProvider {
|
||||
pub struct QwenProvider(pub GenericOpenaiProvider);
|
||||
|
||||
impl QwenProvider {
|
||||
pub fn new(base_url: String, api_key: String, model: String) -> Self {
|
||||
pub fn new(base_url: String, api_key: String, model: String, timeout_secs: u64) -> Self {
|
||||
let url = if base_url.is_empty() {
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1".to_string()
|
||||
} else {
|
||||
@@ -104,31 +103,28 @@ impl QwenProvider {
|
||||
model,
|
||||
"qwen",
|
||||
vec![("X-DashScope-SSE".to_string(), "enable".to_string())],
|
||||
timeout_secs,
|
||||
);
|
||||
Self(inner)
|
||||
}
|
||||
|
||||
/// 替换默认 HTTP Client(用于 timeout 注入等场景)。
|
||||
pub fn with_client(self, client: reqwest::Client) -> Self {
|
||||
Self(self.0.with_client(client))
|
||||
}
|
||||
|
||||
/// 测试构造器。
|
||||
///
|
||||
/// ponytail: 此处 `30` 是 `timeout_secs` 字段的占位值,仅用于 `map_reqwest_error`
|
||||
/// 错误消息中的回显。实际请求超时由传入的 `client` 控制(通常测试用的 mock client
|
||||
/// 无超时),不影响行为。
|
||||
pub fn new_with_client(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
client: reqwest::Client,
|
||||
) -> Self {
|
||||
let url = if base_url.is_empty() {
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1".to_string()
|
||||
} else {
|
||||
base_url
|
||||
};
|
||||
let mut inner = GenericOpenaiProvider::new_with_name_and_headers(
|
||||
url,
|
||||
api_key,
|
||||
model,
|
||||
"qwen",
|
||||
vec![("X-DashScope-SSE".to_string(), "enable".to_string())],
|
||||
);
|
||||
inner.http_client = client;
|
||||
Self(inner)
|
||||
Self::new(base_url, api_key, model, 30).with_client(client)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,8 +137,7 @@ impl LlmProvider for QwenProvider {
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
|
||||
{
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
|
||||
self.0.chat_stream(request).await
|
||||
}
|
||||
|
||||
@@ -156,8 +151,8 @@ impl LlmProvider for QwenProvider {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::message::Message as IrMessage;
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use serde_json::json;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
@@ -182,11 +177,8 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = DeepSeekProvider::new(
|
||||
server.uri(),
|
||||
"sk-test".into(),
|
||||
"deepseek-chat".into(),
|
||||
);
|
||||
let provider =
|
||||
DeepSeekProvider::new(server.uri(), "sk-test".into(), "deepseek-chat".into(), 30);
|
||||
let response = provider
|
||||
.chat(MessageRequest {
|
||||
model: "deepseek-chat".into(),
|
||||
@@ -219,7 +211,7 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = QwenProvider::new(server.uri(), "sk-test".into(), "qwen-plus".into());
|
||||
let provider = QwenProvider::new(server.uri(), "sk-test".into(), "qwen-plus".into(), 30);
|
||||
let response = provider
|
||||
.chat(MessageRequest {
|
||||
model: "qwen-plus".into(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,9 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::{create_provider, LlmProvider, ProviderConfig, ProviderType};
|
||||
use crate::llm::provider::{ProviderConfig, ProviderType, create_provider};
|
||||
|
||||
/// Provider 注册表 —— 管理多个 LLM Provider 实例。
|
||||
///
|
||||
@@ -61,8 +62,6 @@ impl ProviderRegistry {
|
||||
|
||||
/// 获取默认 Provider。
|
||||
pub fn get_default(&self) -> Option<&dyn LlmProvider> {
|
||||
self.default_name
|
||||
.as_ref()
|
||||
.and_then(|name| self.get(name))
|
||||
self.default_name.as_ref().and_then(|name| self.get(name))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! LLM Provider 抽象接口 —— trait 定义与能力元数据。
|
||||
//!
|
||||
//! 独立于具体 provider 实现(OpenAI / Anthropic / DeepSeek / Qwen / Ollama),
|
||||
//! 仅依赖 `llm` feature,不引入 `reqwest`。纯 Mock 场景可仅启用 `llm` feature。
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use futures_core::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
|
||||
|
||||
/// Provider 能力描述 —— 静态元信息,调用方据此决定可用特性。
|
||||
///
|
||||
/// 设计依据(见 `docs/10-llm-provider-refinement.md` §4 任务 6 决策):
|
||||
/// `ProviderCapabilities` 与 trait 同文件,不分散到类型目录。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderCapabilities {
|
||||
/// 人类可读的 Provider 名(如 `"openai"` / `"anthropic"`)。
|
||||
pub provider_name: &'static str,
|
||||
/// 支持的模型列表(`None` 表示"未列举全部")。
|
||||
pub supported_models: Option<Vec<String>>,
|
||||
/// 详细功能开关。
|
||||
pub features: ProviderFeatures,
|
||||
}
|
||||
|
||||
/// Provider 功能开关集合。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ProviderFeatures {
|
||||
/// 是否支持流式响应。
|
||||
pub streaming: bool,
|
||||
/// 是否支持 thinking / 推理。
|
||||
pub thinking: bool,
|
||||
/// 是否支持图片输入。
|
||||
pub vision: bool,
|
||||
/// 是否支持音频输入。
|
||||
pub audio_input: bool,
|
||||
/// 是否支持工具调用。
|
||||
pub tool_use: bool,
|
||||
/// 是否支持并行工具调用。
|
||||
pub parallel_tool_calls: bool,
|
||||
/// system prompt 是否放在 messages 中(`true`)还是顶层 `system` 字段(`false`)。
|
||||
pub system_prompt_in_messages: bool,
|
||||
/// 模型上下文窗口(tokens);`0` 表示未知。
|
||||
pub max_context_window: u32,
|
||||
}
|
||||
|
||||
/// LLM Provider 抽象接口。
|
||||
///
|
||||
/// 所有具体的 LLM 后端实现(OpenAI、Anthropic、DeepSeek、Qwen 等)
|
||||
/// 均需实现此 trait,以实现可插拔替换。
|
||||
///
|
||||
/// 修订(Phase 0):签名由 `chat(ChatRequest) → ChatResponse` 切换为
|
||||
/// `chat(MessageRequest) → MessageResponse`,`chat_stream` 返回新 `StreamEvent` 流,
|
||||
/// 新增 `capabilities()` 方法。
|
||||
#[async_trait::async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
/// 发送聊天请求并返回完整响应。
|
||||
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError>;
|
||||
|
||||
/// 流式聊天请求 —— 返回新 IR `StreamEvent` 流。
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
request: MessageRequest,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>;
|
||||
|
||||
/// 返回 Provider 静态能力描述。
|
||||
fn capabilities(&self) -> ProviderCapabilities;
|
||||
}
|
||||
+5
-200
@@ -1,204 +1,9 @@
|
||||
//! 流式事件系统 —— 将 LLM 流式响应解析为语义化事件。
|
||||
//! 流式事件系统 —— 重导出 `StreamEvent` 供向后兼容。
|
||||
//!
|
||||
//! Phase 0 修订(参见 `docs/10a-phase0-types-and-trait.md` §"StreamEvent 命名冲突处理"):
|
||||
//! 历史说明(Phase 0 → Phase 13):
|
||||
//! - 对外暴露的 `StreamEvent` 是高精度 IR 版本(来自 `response_v2::StreamEvent`)。
|
||||
//! - 旧变体(`AssistantTextDelta` / `ToolExecutionStarted` 等)重命名为 `LegacyStreamEvent`
|
||||
//! 放在 `crate::llm::types::old_stream` 模块,本文件内部消费。
|
||||
//! - Phase 1 重写 Provider 时可直接消费新事件流后整体删除 `LegacyStreamEvent` 相关代码。
|
||||
//!
|
||||
//! 当前实现:旧的 `parse_chunk_stream` 内部消费 `OpenaiChatChunk`,映射为
|
||||
//! `LegacyStreamEvent`,再在 `LegacyToIrEventStream` 中映射为新 IR `StreamEvent`
|
||||
//! 后输出。Phase 1 会重写此层(OpenAI Provider 直接产出新事件流)。
|
||||
//! - 旧版 chunk 解析 + LegacyStreamEvent 适配层在 Phase 13 完成后已整体删除。
|
||||
//! - 当前文件仅保留 `pub use` 重导出,保持与既有
|
||||
//! `use crate::llm::stream::StreamEvent` 的代码兼容。
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use futures_core::stream::Stream;
|
||||
use futures_util::future::poll_fn;
|
||||
use futures_util::FutureExt;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::types::old_stream::LegacyStreamEvent;
|
||||
use crate::llm::types::response_v2::MessageResponse;
|
||||
use crate::llm::types::response_v2::StopReason;
|
||||
use crate::llm::types::usage::Usage;
|
||||
use crate::llm::types::{OpenaiChatChunk, OpenaiToolCall};
|
||||
|
||||
// 唯一的对外 `StreamEvent` 定义(高精度 IR 事件,来自 `response_v2`)。
|
||||
//
|
||||
// 此 `pub use` 同时起到两个作用:
|
||||
// 1. 让 `crate::llm::stream::StreamEvent` 路径仍指向新高精度 IR 事件,
|
||||
// 保持与既有 `use crate::llm::stream::StreamEvent` 的代码兼容;
|
||||
// 2. 把模块内部的 `StreamEvent` 名字指向 `response_v2::StreamEvent`。
|
||||
pub use crate::llm::types::response_v2::StreamEvent;
|
||||
|
||||
/// 将原始 OpenaiChatChunk 流解析为新高精度 IR StreamEvent 流。
|
||||
///
|
||||
/// ponytail: 每个产出事件都用 `Result<_, LlmError>` 包装,让上层 `chat_stream`
|
||||
/// trait 方法直接消费并保持错误传播链。当前 `LegacyToIrEventStream` 内部
|
||||
/// 不会产生错误,所有结果都是 `Ok`;后续 Phase 1 重写 Provider 时,
|
||||
/// 真实 IR 流转换可在此层注入 error 事件。
|
||||
pub fn parse_chunk_stream(
|
||||
chunks: Pin<Box<dyn futures_core::Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
|
||||
) -> Pin<Box<dyn futures_core::Stream<Item = Result<StreamEvent, LlmError>> + Send>> {
|
||||
let legacy = parse_chunk_stream_legacy(chunks);
|
||||
Box::pin(LegacyToIrEventStream { inner: legacy })
|
||||
}
|
||||
|
||||
// --- 内部:chunk → LegacyStreamEvent ---
|
||||
|
||||
fn parse_chunk_stream_legacy(
|
||||
chunks: Pin<Box<dyn futures_core::Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
|
||||
) -> Pin<Box<dyn futures_core::Stream<Item = LegacyStreamEvent> + Send>> {
|
||||
Box::pin(ChunkToLegacyEventStream { chunks })
|
||||
}
|
||||
|
||||
struct ChunkToLegacyEventStream {
|
||||
chunks: Pin<Box<dyn futures_core::Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>,
|
||||
}
|
||||
|
||||
impl Stream for ChunkToLegacyEventStream {
|
||||
type Item = LegacyStreamEvent;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
let this = &mut *self;
|
||||
poll_fn(|cx| match Pin::new(&mut this.chunks).poll_next(cx) {
|
||||
Poll::Ready(Some(Ok(chunk))) => {
|
||||
for choice in &chunk.choices {
|
||||
let delta = &choice.delta;
|
||||
|
||||
if let Some(content) = &delta.content {
|
||||
return Poll::Ready(Some(LegacyStreamEvent::AssistantTextDelta {
|
||||
text: content.clone(),
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(tool_calls) = &delta.tool_calls
|
||||
&& let Some(tc) = tool_calls.first()
|
||||
{
|
||||
let OpenaiToolCall::Function { id, function } = tc;
|
||||
let args: Value =
|
||||
serde_json::from_str(&function.arguments).unwrap_or(Value::Null);
|
||||
return Poll::Ready(Some(LegacyStreamEvent::ToolExecutionStarted {
|
||||
tool_name: function.name.clone(),
|
||||
input: args,
|
||||
tool_call_id: id.clone(),
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(finish_reason) = &choice.finish_reason {
|
||||
return Poll::Ready(Some(LegacyStreamEvent::TurnComplete {
|
||||
reason: *finish_reason,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(usage) = &chunk.usage {
|
||||
return Poll::Ready(Some(LegacyStreamEvent::CostUpdate {
|
||||
usage: *usage,
|
||||
}));
|
||||
}
|
||||
|
||||
Poll::Ready(None)
|
||||
}
|
||||
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(LegacyStreamEvent::error(e.to_string()))),
|
||||
Poll::Ready(None) => Poll::Ready(None),
|
||||
Poll::Pending => Poll::Pending,
|
||||
})
|
||||
.poll_unpin(cx)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 内部:LegacyStreamEvent → 新 StreamEvent ---
|
||||
|
||||
struct LegacyToIrEventStream {
|
||||
inner: Pin<Box<dyn futures_core::Stream<Item = LegacyStreamEvent> + Send>>,
|
||||
}
|
||||
|
||||
impl Stream for LegacyToIrEventStream {
|
||||
type Item = Result<StreamEvent, LlmError>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
let this = &mut *self;
|
||||
match Pin::new(&mut this.inner).poll_next(cx) {
|
||||
Poll::Ready(Some(legacy)) => Poll::Ready(Some(Ok(map_legacy_to_ir(legacy)))),
|
||||
Poll::Ready(None) => {
|
||||
// 旧流结束 → 主动补一个 MessageComplete(full_response 为兜底空快照)。
|
||||
// ponytail: Phase 0 中 OpenaiProvider 桥接层负责产出真实 MessageResponse,
|
||||
// 此处仅防止消费方无限等待。若 Provider 层已正确发出 MessageComplete,
|
||||
// LlmCycle 不会走到这里 —— 因为桥接层 inline 处理。
|
||||
Poll::Ready(Some(Ok(StreamEvent::MessageComplete {
|
||||
full_response: empty_message_response(),
|
||||
})))
|
||||
}
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_message_response() -> MessageResponse {
|
||||
use crate::llm::types::message::Message;
|
||||
use std::collections::HashMap;
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: String::new(),
|
||||
message: Message::Assistant {
|
||||
content: vec![],
|
||||
},
|
||||
usage: Usage::default(),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 把旧 LegacyStreamEvent 映射到新高精度 IR StreamEvent。
|
||||
///
|
||||
/// Phase 1 重写 Provider 后可直接删除此映射函数。当前映射语义:
|
||||
/// - `AssistantTextDelta` → `TextDelta`
|
||||
/// - `ToolExecutionStarted` → `ToolCallArgumentsDelta`(OpenAI 单 chunk 模式下整段 arguments 一次性下发)
|
||||
/// - `CostUpdate` → `CostUpdate`(Usage → PartialUsage 全字段)
|
||||
/// - `TurnComplete` → `MessageComplete`(Phase 1 重写 Provider 后正确产出)
|
||||
/// - `Error` → `Error`
|
||||
///
|
||||
/// ponytail: 这是一个"目前能跑通未来会被删除"的适配层。当前实现为单事件映射,
|
||||
/// 旧 `ToolExecutionStarted` 携带的 (id, name) 暂未填入 IR 事件(消费方
|
||||
/// Phase 2 中通过 MessageComplete.full_response.tool_use 提取)。Phase 1 重写时
|
||||
/// 由 OpenAI Provider 直接产出 IR 流,整体删除此映射。
|
||||
fn map_legacy_to_ir(legacy: LegacyStreamEvent) -> StreamEvent {
|
||||
use crate::llm::types::response_v2::PartialUsage;
|
||||
|
||||
match legacy {
|
||||
LegacyStreamEvent::AssistantTextDelta { text } => StreamEvent::TextDelta { text },
|
||||
LegacyStreamEvent::ToolExecutionStarted { input, .. } => {
|
||||
let arguments = serde_json::to_string(&input).unwrap_or_default();
|
||||
StreamEvent::ToolCallArgumentsDelta { index: 0, arguments }
|
||||
}
|
||||
LegacyStreamEvent::ToolExecutionCompleted { .. } => {
|
||||
// 旧 ToolExecutionCompleted 不在 IR 流协议中——工具执行是消费方职责。
|
||||
// Phase 1 重写时此处整体删除。当前给一个无副作用的占位事件。
|
||||
StreamEvent::CostUpdate {
|
||||
usage: PartialUsage::default(),
|
||||
}
|
||||
}
|
||||
LegacyStreamEvent::CostUpdate { usage } => StreamEvent::CostUpdate {
|
||||
usage: PartialUsage {
|
||||
prompt_tokens: Some(usage.prompt_tokens),
|
||||
completion_tokens: Some(usage.completion_tokens),
|
||||
total_tokens: Some(usage.total_tokens),
|
||||
completion_tokens_details: usage.completion_tokens_details,
|
||||
prompt_tokens_details: usage.prompt_tokens_details,
|
||||
},
|
||||
},
|
||||
LegacyStreamEvent::TurnComplete { reason } => {
|
||||
// 旧 TurnComplete 不直接对应 IR;映射为带 StopReason 的 MessageComplete。
|
||||
// ponytail: Phase 1 重写 Provider 后此适配整体删除,
|
||||
// OpenAI Provider 直接产出带正确 stop_reason 的 MessageComplete。
|
||||
let _ = reason;
|
||||
StreamEvent::MessageComplete {
|
||||
full_response: empty_message_response(),
|
||||
}
|
||||
}
|
||||
LegacyStreamEvent::Error { message } => StreamEvent::Error { message },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,15 +20,12 @@ use crate::llm::types::shared::ImageDetail;
|
||||
/// 消费方 match 可直接区分文本和图片输入。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[non_exhaustive]
|
||||
pub enum Message {
|
||||
/// 系统提示(User & Assistant 之外的引导指令)。
|
||||
System {
|
||||
content: Vec<ContentBlock>,
|
||||
},
|
||||
System { content: Vec<ContentBlock> },
|
||||
/// 用户输入。
|
||||
User {
|
||||
content: Vec<ContentBlock>,
|
||||
},
|
||||
User { content: Vec<ContentBlock> },
|
||||
/// 用户的图片输入(快捷构造,免去构造 ContentBlock 的 boilerplate)。
|
||||
UserImage {
|
||||
data: String,
|
||||
@@ -36,9 +33,7 @@ pub enum Message {
|
||||
detail: ImageDetail,
|
||||
},
|
||||
/// Assistant 回复内容块(可能包含 text、thinking、tool_use 等多种 block 的混合)。
|
||||
Assistant {
|
||||
content: Vec<ContentBlock>,
|
||||
},
|
||||
Assistant { content: Vec<ContentBlock> },
|
||||
/// 工具调用结果。
|
||||
ToolResult {
|
||||
tool_call_id: String,
|
||||
@@ -103,6 +98,7 @@ impl Message {
|
||||
/// block 的逃生舱。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[non_exhaustive]
|
||||
pub enum ContentBlock {
|
||||
/// 纯文本。
|
||||
Text { text: String },
|
||||
@@ -130,10 +126,7 @@ pub enum ContentBlock {
|
||||
signature: Option<String>,
|
||||
},
|
||||
/// 逃生舱:Provider 特定 block 透传(OpenAI Response 内置工具等)。
|
||||
Extension {
|
||||
kind: String,
|
||||
data: Value,
|
||||
},
|
||||
Extension { kind: String, data: Value },
|
||||
}
|
||||
|
||||
/// 内容块类型标签 —— 用于 `StreamEvent::ContentBlockStart.block_type`。
|
||||
@@ -141,6 +134,7 @@ pub enum ContentBlock {
|
||||
/// 用途:在流式场景中,Provider 先下发 block 类型,再下发 block 内容增量。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[non_exhaustive]
|
||||
pub enum ContentBlockType {
|
||||
/// 文本块。
|
||||
Text,
|
||||
@@ -349,9 +343,7 @@ mod tests {
|
||||
fn message_roundtrip_each_variant() {
|
||||
let msgs = vec![
|
||||
Message::System {
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "sys".into(),
|
||||
}],
|
||||
content: vec![ContentBlock::Text { text: "sys".into() }],
|
||||
},
|
||||
Message::User {
|
||||
content: vec![ContentBlock::Text {
|
||||
@@ -376,9 +368,7 @@ mod tests {
|
||||
},
|
||||
Message::ToolResult {
|
||||
tool_call_id: "call_1".into(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "ok".into(),
|
||||
}],
|
||||
content: vec![ContentBlock::Text { text: "ok".into() }],
|
||||
is_error: true,
|
||||
},
|
||||
];
|
||||
|
||||
+1
-81
@@ -1,9 +1,6 @@
|
||||
pub mod message;
|
||||
pub mod old_stream;
|
||||
pub mod openai_message;
|
||||
pub mod request;
|
||||
pub mod request_v2;
|
||||
pub mod response;
|
||||
pub mod response_v2;
|
||||
pub mod shared;
|
||||
pub mod tool;
|
||||
@@ -12,12 +9,7 @@ pub mod usage;
|
||||
pub use openai_message::{
|
||||
ContentField, FileData, ImageURL, InputAudio, OpenaiChatMessage, OpenaiContentPart,
|
||||
};
|
||||
pub use request::{OpenaiChatRequest, OpenaiTool, StreamOptions, ToolChoice};
|
||||
pub use request_v2::{ExtraError, MessageRequest, ThinkingConfig};
|
||||
pub use response::{
|
||||
Annotation, Choice, ChunkChoice, Delta, Logprobs, OpenaiAudio, OpenaiChatChunk,
|
||||
OpenaiChatResponse, TokenLogprob, TopLogprob, URLCitation,
|
||||
};
|
||||
pub use response_v2::{
|
||||
ContentBlockBuilder, MessageResponse, PartialMessageResponse, PartialUsage, StopReason,
|
||||
StreamEvent,
|
||||
@@ -26,77 +18,5 @@ pub use shared::{
|
||||
AudioFormat, FinishReason, ImageDetail, Modality, ResponseFormat, Role, ServiceTier,
|
||||
StopSequence,
|
||||
};
|
||||
pub use tool::{FunctionCall, OpenaiToolCall, OpenaiToolDefinition};
|
||||
pub use tool::{FunctionCall, OpenaiToolCall, ToolChoice, ToolDef};
|
||||
pub use usage::{CompletionTokensDetails, CostTracker, PromptTokensDetails, Usage};
|
||||
|
||||
// Re-export IR 内容块 / 消息类型供 `types::ContentBlock` 等历史路径消费。
|
||||
//
|
||||
// 注意:以下别名 *故意不暴露* `pub type Message = message::Message`、
|
||||
// `pub type ContentBlock = message::ContentBlock` —— 新 `Message` / `ContentBlock` /
|
||||
// `StopReason` 是独立类型,由 `Message` / `ContentBlock` / `StopReason` 直接路径访问,
|
||||
// 旧别名(指 `OpenaiChatMessage` / `OpenaiContentPart` / `FinishReason`)已移除,
|
||||
// 避免新类型阴影。Phase 2 完成后再统一收敛。
|
||||
//
|
||||
// Phase 1 起移除 `ChatRequest` 别名 —— 新代码统一使用 `MessageRequest`(v2 IR)。
|
||||
// `ChatResponse` 结构体仍存在,作为 OpenAI `chat_inner()` 内部 wire-format 转换目标。
|
||||
/// 旧 wire-format 响应结构(保留用于 OpenAI 内部转换层)。
|
||||
#[deprecated(since = "0.1.0", note = "请改用 MessageResponse")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatResponse {
|
||||
pub message: OpenaiChatMessage,
|
||||
pub usage: Usage,
|
||||
pub stop_reason: Option<FinishReason>,
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
impl From<OpenaiChatResponse> for ChatResponse {
|
||||
fn from(response: OpenaiChatResponse) -> Self {
|
||||
let message = response
|
||||
.choices
|
||||
.first()
|
||||
.map(|c| c.message.clone())
|
||||
.unwrap_or_else(|| OpenaiChatMessage::assistant_text(""));
|
||||
let stop_reason = response.choices.first().and_then(|c| c.finish_reason);
|
||||
ChatResponse {
|
||||
message,
|
||||
usage: response.usage,
|
||||
stop_reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
impl From<ChatResponse> for OpenaiChatChunk {
|
||||
fn from(response: ChatResponse) -> Self {
|
||||
let delta = Delta::from(response.message.clone());
|
||||
let chunk_choice = ChunkChoice {
|
||||
index: 0,
|
||||
delta,
|
||||
logprobs: None,
|
||||
finish_reason: response.stop_reason,
|
||||
};
|
||||
|
||||
OpenaiChatChunk {
|
||||
id: format!(
|
||||
"chunk-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
),
|
||||
object: "chat.completion.chunk".to_string(),
|
||||
created: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
model: String::new(),
|
||||
choices: vec![chunk_choice],
|
||||
usage: Some(response.usage),
|
||||
system_fingerprint: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具定义别名(无新类型冲突,保留)。
|
||||
#[deprecated(since = "0.1.0", note = "ToolDefinition 仍直接对应 OpenAI wire-format;未来 v0.2 引入 IR 工具类型后会再次更新")]
|
||||
pub type ToolDefinition = OpenaiToolDefinition;
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
//! 旧版流式事件 —— Phase 0 临时保留,仅供 `stream.rs` 中 `parse_chunk_stream` 内部使用。
|
||||
//!
|
||||
//! Phase 0 中:高精度 `StreamEvent`(定义在 `response_v2.rs`)是唯一的对外
|
||||
//! `StreamEvent`,旧变体迁移至此模块改名为 `LegacyStreamEvent`,
|
||||
//! 由 `parse_chunk_stream()` 内部消费 `LegacyStreamEvent`,对外返回值已被
|
||||
//! 重映射为新 `StreamEvent`。
|
||||
//!
|
||||
//! Phase 1 重写 Provider 时,`parse_chunk_stream` 可直接消费新事件流后整体删除此文件。
|
||||
|
||||
use crate::llm::types::shared::FinishReason;
|
||||
use crate::llm::types::usage::Usage;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 旧 `StreamEvent` 变体迁移后的别名 —— 仅供 `stream.rs` 内部使用。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LegacyStreamEvent {
|
||||
/// 助手回复文本增量。
|
||||
AssistantTextDelta { text: String },
|
||||
/// 工具调用开始。
|
||||
ToolExecutionStarted {
|
||||
tool_name: String,
|
||||
input: Value,
|
||||
tool_call_id: String,
|
||||
},
|
||||
/// 工具调用完成。
|
||||
ToolExecutionCompleted {
|
||||
tool_name: String,
|
||||
output: Value,
|
||||
is_error: bool,
|
||||
},
|
||||
/// Token 用量更新。
|
||||
CostUpdate { usage: Usage },
|
||||
/// 一轮会话完成。
|
||||
TurnComplete { reason: FinishReason },
|
||||
/// 错误事件。
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
impl LegacyStreamEvent {
|
||||
pub(crate) fn error(message: impl Into<String>) -> Self {
|
||||
Self::Error {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
use crate::llm::types::shared::{ResponseFormat, ServiceTier, StopSequence};
|
||||
use crate::llm::types::tool::OpenaiToolDefinition;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_usage: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_obfuscation: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Default)]
|
||||
pub enum ToolChoice {
|
||||
#[default]
|
||||
None,
|
||||
Auto,
|
||||
Required,
|
||||
Named { name: String },
|
||||
AllowedTools { tool_names: Vec<String> },
|
||||
}
|
||||
|
||||
|
||||
impl Serialize for ToolChoice {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
match self {
|
||||
ToolChoice::None => serializer.serialize_str("none"),
|
||||
ToolChoice::Auto => serializer.serialize_str("auto"),
|
||||
ToolChoice::Required => serializer.serialize_str("required"),
|
||||
ToolChoice::Named { name } => {
|
||||
let obj = serde_json::json!({
|
||||
"type": "function",
|
||||
"function": { "name": name }
|
||||
});
|
||||
obj.serialize(serializer)
|
||||
}
|
||||
ToolChoice::AllowedTools { tool_names } => {
|
||||
let obj = serde_json::json!({
|
||||
"type": "function",
|
||||
"function": { "name": tool_names.first().cloned().unwrap_or_default() }
|
||||
});
|
||||
obj.serialize(serializer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ToolChoice {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = Value::deserialize(deserializer)?;
|
||||
match value {
|
||||
Value::String(s) => match s.as_str() {
|
||||
"none" => Ok(ToolChoice::None),
|
||||
"auto" => Ok(ToolChoice::Auto),
|
||||
"required" => Ok(ToolChoice::Required),
|
||||
_ => Err(serde::de::Error::custom(format!(
|
||||
"unknown tool choice: {s}"
|
||||
))),
|
||||
},
|
||||
Value::Object(obj) => {
|
||||
let typ = obj.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
serde::de::Error::custom("missing 'type' field in tool_choice")
|
||||
})?;
|
||||
if typ == "function" {
|
||||
let func =
|
||||
obj.get("function")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| {
|
||||
serde::de::Error::custom("missing 'function' field in tool_choice")
|
||||
})?;
|
||||
let name = func.get("name").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
serde::de::Error::custom("missing 'function.name' in tool_choice")
|
||||
})?;
|
||||
Ok(ToolChoice::Named {
|
||||
name: name.to_string(),
|
||||
})
|
||||
} else {
|
||||
Err(serde::de::Error::custom(format!(
|
||||
"unknown tool_choice type: {typ}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
_ => Err(serde::de::Error::custom(
|
||||
"tool_choice must be a string or object",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
pub enum OpenaiTool {
|
||||
Function { function: OpenaiToolDefinition },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AudioParam {
|
||||
pub format: String,
|
||||
pub voice: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PredictionContent {
|
||||
#[serde(rename = "type")]
|
||||
pub pred_type: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserLocation {
|
||||
#[serde(rename = "type")]
|
||||
pub loc_type: String,
|
||||
pub approximate: Approximate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Approximate {
|
||||
pub city: String,
|
||||
pub country: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub region: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timezone: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebSearchOptions {
|
||||
pub search_context_size: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user_location: Option<UserLocation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct OpenaiChatRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<crate::llm::types::openai_message::OpenaiChatMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub frequency_penalty: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub logit_bias: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub n: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub presence_penalty: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub response_format: Option<ResponseFormat>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub seed: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub service_tier: Option<ServiceTier>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop: Option<StopSequence>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream_options: Option<StreamOptions>,
|
||||
#[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 tools: Option<Vec<OpenaiTool>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<ToolChoice>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parallel_tool_calls: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub extra_headers: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub extra_body: Option<Value>,
|
||||
}
|
||||
+55
-17
@@ -9,15 +9,15 @@ use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::llm::types::message::Message;
|
||||
use crate::llm::types::request::ToolChoice;
|
||||
use crate::llm::types::tool::OpenaiToolDefinition;
|
||||
use crate::llm::types::tool::ToolChoice;
|
||||
use crate::llm::types::tool::ToolDef;
|
||||
|
||||
/// Provider 无关的请求类型。
|
||||
///
|
||||
/// 设计要点:
|
||||
/// - `system` 字段不存在;system 提示由调用方通过 `Message::System` 在 `messages` 中表达。
|
||||
/// - `tools` / `tool_choice` 直接复用现有 `OpenaiToolDefinition` / `ToolChoice`
|
||||
/// (10a §251 决策:先复用旧类型,Phase 2 切换为新 `ToolDefinition` 后再调整)。
|
||||
/// - `tools` 使用 Provider 无关的 `ToolDef` IR;各 Provider 适配层在 `convert_request`
|
||||
/// 中转换为对应 wire format。`tool_choice` 复用现有 `ToolChoice`。
|
||||
/// - `extra` 作为逃生舱:Provider 特定字段(`web_search_options`、`previous_response_id` 等)
|
||||
/// 通过 `extra.set_extra / get_extra` 传递,避免持续膨胀本结构体。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -26,8 +26,8 @@ pub struct MessageRequest {
|
||||
pub model: String,
|
||||
/// 消息列表(包含 system / user / assistant / tool_result 等所有变体)。
|
||||
pub messages: Vec<Message>,
|
||||
/// 工具定义列表。
|
||||
pub tools: Vec<OpenaiToolDefinition>,
|
||||
/// 工具定义列表(Provider 无关 IR)。
|
||||
pub tools: Vec<ToolDef>,
|
||||
/// 工具选择策略。
|
||||
pub tool_choice: ToolChoice,
|
||||
/// 最大输出 token 数。
|
||||
@@ -127,14 +127,9 @@ mod tests {
|
||||
#[test]
|
||||
fn extra_set_and_get_roundtrip() {
|
||||
let mut req = MessageRequest::default();
|
||||
req.set_extra(
|
||||
"previous_response_id",
|
||||
"resp_abc123",
|
||||
);
|
||||
req.set_extra("previous_response_id", "resp_abc123");
|
||||
|
||||
let v: Option<String> = req
|
||||
.get_extra("previous_response_id")
|
||||
.expect("get_extra ok");
|
||||
let v: Option<String> = req.get_extra("previous_response_id").expect("get_extra ok");
|
||||
assert_eq!(v.as_deref(), Some("resp_abc123"));
|
||||
|
||||
let missing: Option<String> = req.get_extra("missing").expect("missing ok");
|
||||
@@ -174,10 +169,7 @@ mod tests {
|
||||
}
|
||||
|
||||
let opts: Options = req.get_extra_as().expect("get_extra_as ok");
|
||||
assert_eq!(
|
||||
opts.web_search_options.search_context_size,
|
||||
"high"
|
||||
);
|
||||
assert_eq!(opts.web_search_options.search_context_size, "high");
|
||||
assert_eq!(opts.user.as_deref(), Some("u_123"));
|
||||
}
|
||||
|
||||
@@ -206,4 +198,50 @@ mod tests {
|
||||
assert_eq!(decoded.stream, req.stream);
|
||||
assert_eq!(decoded.extra.get("trace_id"), Some(&json!("t-1")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_request_with_tools_roundtrip() {
|
||||
// 验证 ToolDef 的 serde 属性与 OpenaiToolDefinition 一致:
|
||||
// 同名字段(name/description/parameters)序列化结果应一致。
|
||||
let params = json!({
|
||||
"type": "object",
|
||||
"properties": {"x": {"type": "number"}},
|
||||
"required": ["x"],
|
||||
});
|
||||
let tool = super::ToolDef {
|
||||
name: "add".to_string(),
|
||||
description: Some("add two numbers".to_string()),
|
||||
parameters: params.clone(),
|
||||
};
|
||||
let req = MessageRequest {
|
||||
model: "gpt-4o".into(),
|
||||
messages: vec![Message::user_text("hi")],
|
||||
tools: vec![tool],
|
||||
tool_choice: ToolChoice::Auto,
|
||||
max_tokens: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
stop_sequences: vec![],
|
||||
stream: false,
|
||||
thinking: None,
|
||||
extra: HashMap::new(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&req).expect("serialize");
|
||||
// 验证反序列化能还原所有字段(包括嵌套 parameters)
|
||||
let decoded: MessageRequest = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(decoded.tools.len(), 1);
|
||||
assert_eq!(decoded.tools[0].name, "add");
|
||||
assert_eq!(
|
||||
decoded.tools[0].description.as_deref(),
|
||||
Some("add two numbers")
|
||||
);
|
||||
assert_eq!(decoded.tools[0].parameters, params);
|
||||
|
||||
// 验证序列化 JSON 不含 ToolDef 没有的字段(如 strict),保持 wire-format 兼容
|
||||
assert!(
|
||||
!json.contains("strict"),
|
||||
"ToolDef 序列化不应包含 strict 字段"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
use crate::llm::types::openai_message::OpenaiChatMessage;
|
||||
use crate::llm::types::shared::{FinishReason, ServiceTier};
|
||||
use crate::llm::types::tool::OpenaiToolCall;
|
||||
use crate::llm::types::usage::Usage;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::llm::types::{ContentField, OpenaiContentPart};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TokenLogprob {
|
||||
pub token: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bytes: Option<Vec<u32>>,
|
||||
pub logprob: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_logprobs: Option<Vec<TopLogprob>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TopLogprob {
|
||||
pub token: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bytes: Option<Vec<u32>>,
|
||||
pub logprob: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Logprobs {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<Vec<TokenLogprob>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub refusal: Option<Vec<TokenLogprob>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct URLCitation {
|
||||
pub end_index: u32,
|
||||
pub start_index: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Annotation {
|
||||
#[serde(rename = "type")]
|
||||
pub ann_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url_citation: Option<URLCitation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenaiAudio {
|
||||
pub id: String,
|
||||
pub data: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub transcript: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Choice {
|
||||
pub index: u32,
|
||||
pub message: OpenaiChatMessage,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub finish_reason: Option<FinishReason>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub logprobs: Option<Logprobs>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenaiChatResponse {
|
||||
pub id: String,
|
||||
pub object: String,
|
||||
pub created: u64,
|
||||
pub model: String,
|
||||
pub choices: Vec<Choice>,
|
||||
pub 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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Delta {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub refusal: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<OpenaiToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChunkChoice {
|
||||
pub index: u32,
|
||||
pub delta: Delta,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub logprobs: Option<Logprobs>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub finish_reason: Option<FinishReason>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenaiChatChunk {
|
||||
pub id: String,
|
||||
pub object: String,
|
||||
pub created: u64,
|
||||
pub model: String,
|
||||
pub choices: Vec<ChunkChoice>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage: Option<Usage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
impl From<OpenaiChatMessage> for Delta {
|
||||
fn from(msg: OpenaiChatMessage) -> Self {
|
||||
match msg {
|
||||
OpenaiChatMessage::Assistant {
|
||||
content,
|
||||
tool_calls,
|
||||
..
|
||||
} => Delta {
|
||||
role: Some("assistant".to_string()),
|
||||
content: match content {
|
||||
ContentField::String(s) => Some(s),
|
||||
ContentField::Array(parts) => {
|
||||
let mut text = String::new();
|
||||
for part in parts {
|
||||
if let OpenaiContentPart::Text { text: t } = part {
|
||||
text.push_str(&t);
|
||||
}
|
||||
}
|
||||
if text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(text)
|
||||
}
|
||||
}
|
||||
},
|
||||
refusal: None,
|
||||
tool_calls,
|
||||
},
|
||||
_ => Delta {
|
||||
role: None,
|
||||
content: None,
|
||||
refusal: None,
|
||||
tool_calls: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OpenaiChatResponse> for OpenaiChatChunk {
|
||||
fn from(response: OpenaiChatResponse) -> Self {
|
||||
let choices = response
|
||||
.choices
|
||||
.into_iter()
|
||||
.map(|c| ChunkChoice {
|
||||
index: c.index,
|
||||
delta: Delta::from(c.message),
|
||||
logprobs: c.logprobs,
|
||||
finish_reason: c.finish_reason,
|
||||
})
|
||||
.collect();
|
||||
|
||||
OpenaiChatChunk {
|
||||
id: response.id,
|
||||
object: "chat.completion.chunk".to_string(),
|
||||
created: response.created,
|
||||
model: response.model,
|
||||
choices,
|
||||
usage: Some(response.usage),
|
||||
system_fingerprint: response.system_fingerprint,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use crate::llm::types::usage::{CompletionTokensDetails, PromptTokensDetails, Usa
|
||||
/// Phase 2 完成时统一收敛。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[non_exhaustive]
|
||||
pub enum StopReason {
|
||||
/// 自然停止。
|
||||
Stop,
|
||||
@@ -164,11 +165,15 @@ pub enum ContentBlockBuilder {
|
||||
/// `thinking_signature`,最终通过 `finalize()` 回填到 `full_response` 的 `Thinking` block 中。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[non_exhaustive]
|
||||
pub enum StreamEvent {
|
||||
/// 消息开始(元信息)。
|
||||
MessageStart { id: String, model: String },
|
||||
/// 内容块开始(告知块类型,携带 id/name for ToolUse)。
|
||||
ContentBlockStart { index: u32, block_type: ContentBlockType },
|
||||
ContentBlockStart {
|
||||
index: u32,
|
||||
block_type: ContentBlockType,
|
||||
},
|
||||
/// 内容块结束标记。
|
||||
ContentBlockEnd { index: u32 },
|
||||
/// 文本增量。
|
||||
@@ -187,6 +192,24 @@ pub enum StreamEvent {
|
||||
MessageComplete { full_response: MessageResponse },
|
||||
/// 错误事件。
|
||||
Error { message: String },
|
||||
/// 工具开始执行 —— 在 `ToolCallEnd` 之后、`registry.invoke_all` 之前发出。
|
||||
/// 让 UI 层可以显示 "正在执行工具:add(1, 2)"。
|
||||
ToolExecutionStarted {
|
||||
tool_name: String,
|
||||
tool_call_id: String,
|
||||
/// 工具参数(JSON 字符串形式),用于 UI 展示
|
||||
arguments: String,
|
||||
},
|
||||
/// 工具执行完成 —— 在工具返回后、新一轮 LLM 流开始之前发出。
|
||||
ToolExecutionCompleted {
|
||||
tool_name: String,
|
||||
tool_call_id: String,
|
||||
/// 结果摘要(由 `CycleConfig.max_tool_result_bytes` 截断,默认 65536 字节/字符边界安全),
|
||||
/// 用于 UI 反馈。完整结果已在内部 `messages` 中作为 `ToolResult` 回传给 LLM。
|
||||
result_summary: String,
|
||||
/// 是否执行出错
|
||||
is_error: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// 流式响应累积状态。
|
||||
@@ -320,9 +343,8 @@ impl PartialMessageResponse {
|
||||
true
|
||||
}
|
||||
StreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
||||
if let Some(ContentBlockBuilder::ToolUse {
|
||||
arguments: buf, ..
|
||||
}) = self.blocks.get_mut(index)
|
||||
if let Some(ContentBlockBuilder::ToolUse { arguments: buf, .. }) =
|
||||
self.blocks.get_mut(index)
|
||||
{
|
||||
buf.push_str(arguments);
|
||||
}
|
||||
@@ -348,6 +370,10 @@ impl PartialMessageResponse {
|
||||
self.is_errored = true;
|
||||
false
|
||||
}
|
||||
// 元事件:不参与内容块累积,不修改 partial 状态
|
||||
//(Phase 9 —— 工具执行透明化,由 run_tool_loop 在工具前后插入)
|
||||
StreamEvent::ToolExecutionStarted { .. }
|
||||
| StreamEvent::ToolExecutionCompleted { .. } => true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,9 +386,7 @@ impl PartialMessageResponse {
|
||||
let mut content_blocks = Vec::with_capacity(self.blocks.len());
|
||||
for (idx, builder) in self.blocks {
|
||||
let block = Self::builder_to_block(idx, builder, self.thinking_signature.as_deref())
|
||||
.map_err(|e| LlmError::Other(format!(
|
||||
"partial 块 #{idx} finalize 失败: {e}"
|
||||
)))?;
|
||||
.map_err(|e| LlmError::Other(format!("partial 块 #{idx} finalize 失败: {e}")))?;
|
||||
content_blocks.push(block);
|
||||
}
|
||||
|
||||
@@ -743,10 +767,7 @@ mod tests {
|
||||
Message::Assistant { content } => {
|
||||
assert_eq!(content.len(), 2);
|
||||
match (&content[0], &content[1]) {
|
||||
(
|
||||
ContentBlock::Text { text: t1 },
|
||||
ContentBlock::Text { text: t2 },
|
||||
) => {
|
||||
(ContentBlock::Text { text: t1 }, ContentBlock::Text { text: t2 }) => {
|
||||
assert_eq!(t1, "first");
|
||||
assert_eq!(t2, "second");
|
||||
}
|
||||
@@ -772,9 +793,7 @@ mod tests {
|
||||
index: 0,
|
||||
block_type: ContentBlockType::Text,
|
||||
},
|
||||
StreamEvent::TextDelta {
|
||||
text: "x".into(),
|
||||
},
|
||||
StreamEvent::TextDelta { text: "x".into() },
|
||||
StreamEvent::ContentBlockEnd { index: 0 },
|
||||
StreamEvent::MessageComplete {
|
||||
full_response: empty_response(),
|
||||
@@ -832,15 +851,9 @@ mod tests {
|
||||
block_type: ContentBlockType::Text,
|
||||
},
|
||||
StreamEvent::ContentBlockEnd { index: 0 },
|
||||
StreamEvent::TextDelta {
|
||||
text: "t".into(),
|
||||
},
|
||||
StreamEvent::ThinkingDelta {
|
||||
text: "p".into(),
|
||||
},
|
||||
StreamEvent::RefusalDelta {
|
||||
text: "r".into(),
|
||||
},
|
||||
StreamEvent::TextDelta { text: "t".into() },
|
||||
StreamEvent::ThinkingDelta { text: "p".into() },
|
||||
StreamEvent::RefusalDelta { text: "r".into() },
|
||||
StreamEvent::ToolCallArgumentsDelta {
|
||||
index: 1,
|
||||
arguments: "{\"x\":1}".into(),
|
||||
|
||||
@@ -13,6 +13,7 @@ pub enum Role {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[non_exhaustive]
|
||||
pub enum FinishReason {
|
||||
Stop,
|
||||
Length,
|
||||
@@ -67,6 +68,7 @@ pub enum StopSequence {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
#[non_exhaustive]
|
||||
pub enum ResponseFormat {
|
||||
Text,
|
||||
JsonObject,
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Provider 无关的工具定义 IR(v0.2 引入,替换 `ToolDefinition` 别名)。
|
||||
///
|
||||
/// 字段最小化:仅承载跨 Provider 公共的概念(name、description、parameters)。
|
||||
/// OpenAI 专属 `strict` 字段不在此表达,由 OpenAI 适配层通过
|
||||
/// `MessageRequest.extra` 逃生舱在 `convert_request` 内补充。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ToolDef {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub parameters: Value,
|
||||
}
|
||||
|
||||
/// 旧 OpenAI wire-format 工具定义(v0.2 降级为 `#[doc(hidden)]`)。
|
||||
///
|
||||
/// 由 `ToolDef` 替代;保留仅供 OpenAI 适配层消费 `ToolDef → OpenaiToolDefinition`
|
||||
/// 转换与外部反序列化兼容路径使用,不作为公共 API。
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct OpenaiToolDefinition {
|
||||
pub name: String,
|
||||
@@ -12,6 +31,27 @@ pub struct OpenaiToolDefinition {
|
||||
pub strict: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<ToolDef> for OpenaiToolDefinition {
|
||||
fn from(t: ToolDef) -> Self {
|
||||
Self {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
strict: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OpenaiToolDefinition> for ToolDef {
|
||||
fn from(t: OpenaiToolDefinition) -> Self {
|
||||
Self {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FunctionCall {
|
||||
pub name: String,
|
||||
@@ -23,3 +63,93 @@ pub struct FunctionCall {
|
||||
pub enum OpenaiToolCall {
|
||||
Function { id: String, function: FunctionCall },
|
||||
}
|
||||
|
||||
/// 工具选择策略 —— Phase 13 从 `types::request::ToolChoice` 迁入。
|
||||
///
|
||||
/// `#[non_exhaustive]` 预留扩展空间。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[non_exhaustive]
|
||||
pub enum ToolChoice {
|
||||
#[default]
|
||||
None,
|
||||
Auto,
|
||||
Required,
|
||||
Named {
|
||||
name: String,
|
||||
},
|
||||
AllowedTools {
|
||||
tool_names: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Serialize for ToolChoice {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
match self {
|
||||
ToolChoice::None => serializer.serialize_str("none"),
|
||||
ToolChoice::Auto => serializer.serialize_str("auto"),
|
||||
ToolChoice::Required => serializer.serialize_str("required"),
|
||||
ToolChoice::Named { name } => {
|
||||
let obj = serde_json::json!({
|
||||
"type": "function",
|
||||
"function": { "name": name }
|
||||
});
|
||||
obj.serialize(serializer)
|
||||
}
|
||||
ToolChoice::AllowedTools { tool_names } => {
|
||||
let obj = serde_json::json!({
|
||||
"type": "function",
|
||||
"function": { "name": tool_names.first().cloned().unwrap_or_default() }
|
||||
});
|
||||
obj.serialize(serializer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ToolChoice {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = Value::deserialize(deserializer)?;
|
||||
match value {
|
||||
Value::String(s) => match s.as_str() {
|
||||
"none" => Ok(ToolChoice::None),
|
||||
"auto" => Ok(ToolChoice::Auto),
|
||||
"required" => Ok(ToolChoice::Required),
|
||||
_ => Err(serde::de::Error::custom(format!(
|
||||
"unknown tool choice: {s}"
|
||||
))),
|
||||
},
|
||||
Value::Object(obj) => {
|
||||
let typ = obj.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
serde::de::Error::custom("missing 'type' field in tool_choice")
|
||||
})?;
|
||||
if typ == "function" {
|
||||
let func =
|
||||
obj.get("function")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| {
|
||||
serde::de::Error::custom("missing 'function' field in tool_choice")
|
||||
})?;
|
||||
let name = func.get("name").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
serde::de::Error::custom("missing 'function.name' in tool_choice")
|
||||
})?;
|
||||
Ok(ToolChoice::Named {
|
||||
name: name.to_string(),
|
||||
})
|
||||
} else {
|
||||
Err(serde::de::Error::custom(format!(
|
||||
"unknown tool_choice type: {typ}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
_ => Err(serde::de::Error::custom(
|
||||
"tool_choice must be a string or object",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ pub struct PromptTokensDetails {
|
||||
pub cached_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
pub struct CostTracker {
|
||||
accumulated: Usage,
|
||||
}
|
||||
@@ -61,6 +61,12 @@ impl CostTracker {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Usage> for CostTracker {
|
||||
fn from(usage: Usage) -> Self {
|
||||
CostTracker { accumulated: usage }
|
||||
}
|
||||
}
|
||||
|
||||
impl Usage {
|
||||
pub fn from_input_output(input: u32, output: u32) -> Self {
|
||||
let total = input.saturating_add(output);
|
||||
|
||||
+16
-2
@@ -2,21 +2,35 @@
|
||||
|
||||
pub mod conversation;
|
||||
pub mod error;
|
||||
pub mod graph;
|
||||
pub mod knowledge;
|
||||
pub mod retriever;
|
||||
pub mod store;
|
||||
pub mod types;
|
||||
pub mod vector;
|
||||
pub mod vector_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;
|
||||
#[cfg(feature = "memory-sqlite")]
|
||||
pub use store::SqliteStore;
|
||||
pub use store::{InMemoryStore, MemoryStore};
|
||||
#[allow(deprecated)]
|
||||
pub use vector::{InMemoryVectorRetriever, VectorRetriever};
|
||||
pub use vector_store::{InMemoryVectorStore, PersistentVectorStore, RagPipeline, VectorStore};
|
||||
|
||||
// 低频类型(配置/高级使用)
|
||||
pub use conversation::MemoryStrategy;
|
||||
pub use knowledge::{PageIndexEntry, KNOWLEDGE_PREFIX};
|
||||
pub use retriever::{RetrieverConfig, RetrievalResult, ScoredItem};
|
||||
pub use graph::TagConstraints;
|
||||
pub use knowledge::{KNOWLEDGE_PREFIX, PageIndexEntry};
|
||||
#[allow(deprecated)]
|
||||
pub use retriever::ScoredItem;
|
||||
pub use retriever::{RetrievalItem, RetrievalResult, RetrievalStrategy, RetrieverConfig};
|
||||
pub use store::{EvictionConfig, EvictionPolicy};
|
||||
pub use types::{KnowledgePage, MemoryFilter, MemoryItem};
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::memory::types::MemoryItem;
|
||||
|
||||
/// 对话消息管理策略。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum MemoryStrategy {
|
||||
/// 滑动窗口:达到上限时删除最旧消息。
|
||||
SlidingWindow,
|
||||
@@ -160,7 +161,12 @@ impl ConversationMemory {
|
||||
}
|
||||
|
||||
fn make_message_id(&self, index: usize, now: &OffsetDateTime) -> String {
|
||||
format!("{}{:010}_{}", self.session_prefix(), index, now.unix_timestamp_nanos())
|
||||
format!(
|
||||
"{}{:010}_{}",
|
||||
self.session_prefix(),
|
||||
index,
|
||||
now.unix_timestamp_nanos()
|
||||
)
|
||||
}
|
||||
|
||||
async fn maybe_evict_and_compact(&mut self) {
|
||||
@@ -175,7 +181,8 @@ impl ConversationMemory {
|
||||
}
|
||||
|
||||
if let Some(ref compact_config) = self.config.compact_config
|
||||
&& should_compact(&self.messages, compact_config, &self.compact_state) {
|
||||
&& should_compact(&self.messages, compact_config, &self.compact_state)
|
||||
{
|
||||
let keep_recent = compact_config.keep_recent;
|
||||
let freed = microcompact(&mut self.messages, keep_recent);
|
||||
if freed > 0 {
|
||||
@@ -196,7 +203,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn add_and_get_history() {
|
||||
let store = Arc::new(InMemoryStore::new()) as Arc<dyn MemoryStore>;
|
||||
let mut conv = ConversationMemory::new(store, "session1", ConversationMemoryConfig::default());
|
||||
let mut conv =
|
||||
ConversationMemory::new(store, "session1", ConversationMemoryConfig::default());
|
||||
conv.add_message(Message::user_text("hello")).await.unwrap();
|
||||
conv.add_message(Message::user_text("world")).await.unwrap();
|
||||
assert_eq!(conv.len(), 2);
|
||||
@@ -211,9 +219,7 @@ mod tests {
|
||||
conv.add_message(Message::tool_result("call_1", "ok", false))
|
||||
.await
|
||||
.unwrap();
|
||||
conv.add_message(Message::assistant("done"))
|
||||
.await
|
||||
.unwrap();
|
||||
conv.add_message(Message::assistant("done")).await.unwrap();
|
||||
|
||||
let original = conv.get_history().to_vec();
|
||||
assert_eq!(original.len(), 2);
|
||||
@@ -263,7 +269,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn clear_empties_messages() {
|
||||
let store = Arc::new(InMemoryStore::new()) as Arc<dyn MemoryStore>;
|
||||
let mut conv = ConversationMemory::new(store.clone(), "s1", ConversationMemoryConfig::default());
|
||||
let mut conv =
|
||||
ConversationMemory::new(store.clone(), "s1", ConversationMemoryConfig::default());
|
||||
conv.add_message(Message::user_text("hello")).await.unwrap();
|
||||
assert!(!conv.is_empty());
|
||||
conv.clear().await.unwrap();
|
||||
|
||||
@@ -6,6 +6,7 @@ use thiserror::Error;
|
||||
///
|
||||
/// 错误消息面向最终用户(中文),并尽量附带可操作的修复建议(如检查环境变量、重试)。
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum MemoryError {
|
||||
/// 按 ID 未找到指定记忆条目。可重试——通常是 namespace 拼写错误或条目已被淘汰。
|
||||
#[error("未找到记忆条目 '{0}',请检查 ID 或 namespace 是否正确")]
|
||||
|
||||
+1096
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