Compare commits
8
Commits
385560a1dd
...
f6cf583cd7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6cf583cd7 | ||
|
|
0cfd401579 | ||
|
|
5baa170508 | ||
|
|
bc4eac72e1 | ||
|
|
61e6d219dd | ||
|
|
932a06f512 | ||
|
|
249fba8aaf | ||
|
|
703151e363 |
@@ -0,0 +1,61 @@
|
||||
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"
|
||||
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"
|
||||
+134
-13
@@ -1,29 +1,150 @@
|
||||
[package]
|
||||
name = "agcore"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
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 额外依赖 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"]
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
# 始终编译的轻量依赖(5 个,不参与门控)
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
async-trait = "0.1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tokio-stream = "0.1"
|
||||
futures = "0.3"
|
||||
futures-util = "0.3"
|
||||
futures-core = "0.3"
|
||||
bytes = "1"
|
||||
async-stream = "0.3"
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
|
||||
# 12 个重型依赖(全部 optional)
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process", "io-util"], optional = true }
|
||||
reqwest = { version = "0.12", features = ["json", "stream"], optional = true }
|
||||
rusqlite = { version = "0.32", features = ["bundled"], optional = true }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||
tokio-stream = { version = "0.1", optional = true }
|
||||
futures = { version = "0.3", optional = true }
|
||||
futures-util = { version = "0.3", optional = true }
|
||||
futures-core = { version = "0.3", optional = true }
|
||||
bytes = { version = "1", optional = true }
|
||||
async-stream = { version = "0.3", optional = true }
|
||||
tokio-util = { version = "0.7", features = ["rt"], optional = true }
|
||||
time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
|
||||
dotenvy = "0.15.7"
|
||||
wiremock = "0.6"
|
||||
temp-env = "0.3"
|
||||
tempfile = "3"
|
||||
|
||||
# === Examples required-features ===
|
||||
# 每个 example 声明最小 feature 集合,`cargo test --features "full"` 时全部编译;
|
||||
# 其他组合下不兼容的 example 自动跳过。
|
||||
[[example]]
|
||||
name = "prompt_composer"
|
||||
required-features = ["prompt", "llm"]
|
||||
|
||||
[[example]]
|
||||
name = "custom_tool"
|
||||
required-features = ["tools", "llm"]
|
||||
|
||||
[[example]]
|
||||
name = "conversation_memory_demo"
|
||||
required-features = ["memory"]
|
||||
|
||||
[[example]]
|
||||
name = "knowledge_graph_demo"
|
||||
required-features = ["memory"]
|
||||
|
||||
[[example]]
|
||||
name = "knowledge_search_demo"
|
||||
required-features = ["memory"]
|
||||
|
||||
[[example]]
|
||||
name = "agent_session_demo"
|
||||
required-features = ["agent"]
|
||||
|
||||
[[example]]
|
||||
name = "task_agent_demo"
|
||||
required-features = ["agent"]
|
||||
|
||||
[[example]]
|
||||
name = "context_slot_demo"
|
||||
required-features = ["agent"]
|
||||
|
||||
[[example]]
|
||||
name = "quick_start"
|
||||
required-features = ["agent"]
|
||||
|
||||
[[example]]
|
||||
name = "simple_visit"
|
||||
required-features = ["llm", "provider-openai", "tracing-init"]
|
||||
|
||||
[[example]]
|
||||
name = "streaming_events_demo"
|
||||
required-features = ["llm", "provider-openai"]
|
||||
|
||||
[[example]]
|
||||
name = "agent_switch_demo"
|
||||
required-features = ["engine"]
|
||||
|
||||
[[example]]
|
||||
name = "bridge_keys_demo"
|
||||
required-features = ["engine"]
|
||||
|
||||
[[example]]
|
||||
name = "dispatch_stream_demo"
|
||||
required-features = ["engine"]
|
||||
|
||||
[[example]]
|
||||
name = "engine_demo"
|
||||
required-features = ["engine"]
|
||||
|
||||
[[example]]
|
||||
name = "sub_agent_dispatch_demo"
|
||||
required-features = ["engine"]
|
||||
|
||||
[[example]]
|
||||
name = "document_demo"
|
||||
required-features = ["memory", "tracing-init"]
|
||||
|
||||
[[example]]
|
||||
name = "end_to_end"
|
||||
required-features = ["agent", "memory-sqlite", "provider-openai"]
|
||||
|
||||
@@ -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;
|
||||
@@ -133,6 +133,89 @@ let provider = create_provider(
|
||||
| `bridge_keys_demo` | 桥接键:Agent 间上下文键值透传 |
|
||||
| `agent_switch_demo` | Agent 热切换:会话中动态切换 Agent 角色 |
|
||||
|
||||
## 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-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"
|
||||
```
|
||||
|
||||
## 核心模块
|
||||
|
||||
| 模块 | 一句话说明 |
|
||||
|
||||
@@ -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,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)
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
# AG Core Roadmap
|
||||
|
||||
> 拆分式 roadmap:按版本归档 + 未归类内容
|
||||
> 最后更新:2026-07-17(v0.3.0 Phase 19 完成 + M15 里程碑达成 + v0.3.0 全部交付完毕)
|
||||
> 最后更新:2026-07-19(v0.3.2 Step 3 完成 — Phase 26-27 CI 固化 + 文档更新交付,427 测试通过)
|
||||
|
||||
## 文件索引
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
| [`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 交付完毕 |
|
||||
| [`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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! agent_switch_demo —— Agent 角色热切换示例。
|
||||
//! Required features: cargo run --example agent_switch_demo --features "engine"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. 创建 session(绑定 Analyst agent)
|
||||
@@ -110,6 +111,9 @@ async fn main() {
|
||||
};
|
||||
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");
|
||||
assert_eq!(
|
||||
agent_name_owned, "reporter",
|
||||
"current agent should be reporter"
|
||||
);
|
||||
println!("✓ context preserved across agent switch");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! bridge_keys_demo —— bridge_keys 过滤 + 子↔子共享 namespace 示例。
|
||||
//! Required features: cargo run --example bridge_keys_demo --features "engine"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. 父 session 设置 SessionMemory(key: "project_goal", "constraints", "noise")
|
||||
@@ -103,13 +104,24 @@ async fn main() {
|
||||
.dispatch(&parent_id, worker.clone(), "do work", config)
|
||||
.await
|
||||
.expect("dispatch");
|
||||
println!("[3] dispatched sub-agent (child_id={})\n", &result.child_id[..20]);
|
||||
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 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);
|
||||
|
||||
@@ -172,12 +184,7 @@ async fn main() {
|
||||
|
||||
// dispatch 第二个子 agent
|
||||
let _writer_result = sm
|
||||
.dispatch(
|
||||
&parent_id,
|
||||
worker.clone(),
|
||||
"writing task",
|
||||
shared_ns_config,
|
||||
)
|
||||
.dispatch(&parent_id, worker.clone(), "writing task", shared_ns_config)
|
||||
.await
|
||||
.expect("dispatch writer");
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! context_slot_demo —— 多上下文槽位管理示例。
|
||||
//! Required features: cargo run --example context_slot_demo --features "agent"
|
||||
//!
|
||||
//! 场景:法律咨询入口 → 派生两个独立探索方向 → 切换 → 隔离验证 → 删除。
|
||||
//!
|
||||
@@ -13,12 +14,12 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use agcore::agent::{Agent, AgentBuilder, AgentSession};
|
||||
use agcore::llm::LlmProvider;
|
||||
use agcore::llm::hooks::HookExecutor;
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::provider::LlmProvider;
|
||||
use agcore::llm::types::Usage;
|
||||
use agcore::llm::types::message::{ContentBlock, Message};
|
||||
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
|
||||
use agcore::llm::types::Usage;
|
||||
use agcore::tools::ToolRegistry;
|
||||
|
||||
struct LegalAdvisor;
|
||||
@@ -78,11 +79,19 @@ async fn main() {
|
||||
|
||||
println!("\n=== 3. 派生两个独立探索方向的 slot ===");
|
||||
session
|
||||
.derive_slot("option_jurisdiction", "default", agcore::agent::DeriveStrategy::Full)
|
||||
.derive_slot(
|
||||
"option_jurisdiction",
|
||||
"default",
|
||||
agcore::agent::DeriveStrategy::Full,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
session
|
||||
.derive_slot("option_amendment", "default", agcore::agent::DeriveStrategy::Full)
|
||||
.derive_slot(
|
||||
"option_amendment",
|
||||
"default",
|
||||
agcore::agent::DeriveStrategy::Full,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let slots: Vec<_> = session.list_slots().cloned().collect();
|
||||
@@ -158,4 +167,4 @@ fn message_contains(msg: &Message, needle: &str) -> bool {
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! conversation_memory_demo —— 对话记忆滑动窗口与隔离。
|
||||
//! Required features: cargo run --example conversation_memory_demo --features "memory"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. `ConversationMemoryConfig` 构造(SlidingWindow / Full 策略)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! custom_tool —— 自定义工具注册、单次 / 并行调用、权限检查。
|
||||
//! Required features: cargo run --example custom_tool --features "tools,llm"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. 实现 `BaseTool` trait(WeatherTool + DeleteFileTool)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! dispatch_stream_demo —— 流式子代理调度示例。
|
||||
//! Required features: cargo run --example dispatch_stream_demo --features "engine"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. 创建父 session
|
||||
@@ -92,7 +93,11 @@ async fn main() {
|
||||
saw_stream_count += 1;
|
||||
}
|
||||
SubTaskStreamEvent::Completed(r) => {
|
||||
println!(" → Completed(child_id={}, {} tokens)", &r.child_id[..20], r.usage.total().total_tokens);
|
||||
println!(
|
||||
" → Completed(child_id={}, {} tokens)",
|
||||
&r.child_id[..20],
|
||||
r.usage.total().total_tokens
|
||||
);
|
||||
completed = Some(r);
|
||||
break;
|
||||
}
|
||||
@@ -114,7 +119,10 @@ async fn main() {
|
||||
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");
|
||||
assert_eq!(
|
||||
child_turn_index, 1,
|
||||
"turn_index should increment after finalize"
|
||||
);
|
||||
println!("[4] child session turn_index = {child_turn_index} (finalize works)");
|
||||
|
||||
println!("\n✓ dispatch_stream completed successfully");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! document_demo —— Document + RecursiveCharacterSplitter + MockEmbedding + RagPipeline 完整衔接示例。
|
||||
//! Required features: cargo run --example document_demo --features "memory,tracing-init"
|
||||
//!
|
||||
//! 演示 RAG 管线:
|
||||
//! 1. 创建多段落 Document
|
||||
@@ -37,11 +38,7 @@ async fn main() {
|
||||
let embedder: Arc<dyn Embedding> = Arc::new(MockEmbedding::new(4));
|
||||
let store: Arc<dyn VectorStore> = Arc::new(InMemoryVectorStore::new());
|
||||
let splitter = RecursiveCharacterSplitter::new(200, 30);
|
||||
let pipeline = RagPipeline::new(
|
||||
Arc::clone(&embedder),
|
||||
Arc::clone(&store),
|
||||
Some(splitter),
|
||||
);
|
||||
let pipeline = RagPipeline::new(Arc::clone(&embedder), Arc::clone(&store), Some(splitter));
|
||||
|
||||
// 3. 一次性 ingest:自动 split → embed → add
|
||||
pipeline.ingest(std::slice::from_ref(&doc)).await.unwrap();
|
||||
@@ -67,4 +64,4 @@ async fn main() {
|
||||
);
|
||||
|
||||
println!("\n✓ document_demo 完成");
|
||||
}
|
||||
}
|
||||
|
||||
+203
-64
@@ -1,4 +1,5 @@
|
||||
//! end_to_end —— 3 工具 + 3 轮对话 + SqliteStore 持久化跨连接验证。
|
||||
//! Required features: cargo run --example end_to_end --features "agent,memory-sqlite,provider-openai"
|
||||
//!
|
||||
//! 运行:`cargo run --example end_to_end`(离线,零配置)
|
||||
//!
|
||||
@@ -16,10 +17,15 @@ use std::env;
|
||||
use std::sync::Arc;
|
||||
|
||||
use agcore::agent::{Agent, AgentBuilder, AgentSession};
|
||||
use agcore::llm::LlmProvider;
|
||||
use agcore::llm::hooks::HookExecutor;
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::provider::{create_provider, LlmProvider, ProviderConfig, ProviderType};
|
||||
use agcore::llm::types::{Usage, message::{ContentBlock, Message}, response_v2::{MessageResponse, StopReason}};
|
||||
use agcore::llm::provider::{ProviderConfig, ProviderType, create_provider};
|
||||
use agcore::llm::types::{
|
||||
Usage,
|
||||
message::{ContentBlock, Message},
|
||||
response_v2::{MessageResponse, StopReason},
|
||||
};
|
||||
use agcore::memory::store::{MemoryStore, SqliteStore};
|
||||
use agcore::memory::types::{MemoryFilter, MemoryItem};
|
||||
use agcore::tools::{BaseTool, ToolContext, ToolError, ToolRegistry};
|
||||
@@ -32,8 +38,12 @@ use time::OffsetDateTime;
|
||||
|
||||
struct AssistantAgent;
|
||||
impl Agent for AssistantAgent {
|
||||
fn name(&self) -> &str { "end-to-end assistant" }
|
||||
fn system_prompt(&self) -> Option<&str> { Some("简洁助手,必要时调用工具完成任务。") }
|
||||
fn name(&self) -> &str {
|
||||
"end-to-end assistant"
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
Some("简洁助手,必要时调用工具完成任务。")
|
||||
}
|
||||
}
|
||||
|
||||
// === Tools ===
|
||||
@@ -41,14 +51,19 @@ impl Agent for AssistantAgent {
|
||||
struct EchoTool;
|
||||
#[async_trait]
|
||||
impl BaseTool for EchoTool {
|
||||
fn name(&self) -> &str { "echo" }
|
||||
fn description(&self) -> &str { "回显输入文本" }
|
||||
fn name(&self) -> &str {
|
||||
"echo"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"回显输入文本"
|
||||
}
|
||||
fn parameters(&self) -> Value {
|
||||
json!({"type":"object","properties":{"text":{"type":"string"}},"required":["text"]})
|
||||
}
|
||||
async fn execute(&self, args: Value, _: &ToolContext<'_>) -> Result<Value, ToolError> {
|
||||
let text = args.get("text").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidArguments("text".into(), "需要 string 类型的 text 参数".into()))?;
|
||||
let text = args.get("text").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
ToolError::InvalidArguments("text".into(), "需要 string 类型的 text 参数".into())
|
||||
})?;
|
||||
Ok(json!({"echoed": format!("收到: {text}")}))
|
||||
}
|
||||
}
|
||||
@@ -57,8 +72,12 @@ impl BaseTool for EchoTool {
|
||||
struct CalcTool;
|
||||
#[async_trait]
|
||||
impl BaseTool for CalcTool {
|
||||
fn name(&self) -> &str { "calc" }
|
||||
fn description(&self) -> &str { "四则运算:'a op b' 格式,op ∈ {+, -, *, /}" }
|
||||
fn name(&self) -> &str {
|
||||
"calc"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"四则运算:'a op b' 格式,op ∈ {+, -, *, /}"
|
||||
}
|
||||
fn parameters(&self) -> Value {
|
||||
json!({"type":"object","properties":{"expr":{"type":"string"}},"required":["expr"]})
|
||||
}
|
||||
@@ -66,18 +85,30 @@ impl BaseTool for CalcTool {
|
||||
let expr = args["expr"].as_str().unwrap_or("");
|
||||
let parts: Vec<&str> = expr.split_whitespace().collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(ToolError::InvalidArguments("expr".into(), "需要 'a op b' 三段式".into()));
|
||||
return Err(ToolError::InvalidArguments(
|
||||
"expr".into(),
|
||||
"需要 'a op b' 三段式".into(),
|
||||
));
|
||||
}
|
||||
let a: i64 = parts[0].parse().map_err(|_| ToolError::InvalidArguments("expr".into(), format!("无法解析 '{}'", parts[0])))?;
|
||||
let b: i64 = parts[2].parse().map_err(|_| ToolError::InvalidArguments("expr".into(), format!("无法解析 '{}'", parts[2])))?;
|
||||
let a: i64 = parts[0].parse().map_err(|_| {
|
||||
ToolError::InvalidArguments("expr".into(), format!("无法解析 '{}'", parts[0]))
|
||||
})?;
|
||||
let b: i64 = parts[2].parse().map_err(|_| {
|
||||
ToolError::InvalidArguments("expr".into(), format!("无法解析 '{}'", parts[2]))
|
||||
})?;
|
||||
let result = match parts[1] {
|
||||
"+" => a + b,
|
||||
"-" => a - b,
|
||||
"*" => a * b,
|
||||
"/" => a.checked_div(b).ok_or_else(|| {
|
||||
ToolError::InvalidArguments("expr".into(), "除数不能为 0".into())
|
||||
})?,
|
||||
op => return Err(ToolError::InvalidArguments("expr".into(), format!("不支持的运算符: {op}"))),
|
||||
"/" => a
|
||||
.checked_div(b)
|
||||
.ok_or_else(|| ToolError::InvalidArguments("expr".into(), "除数不能为 0".into()))?,
|
||||
op => {
|
||||
return Err(ToolError::InvalidArguments(
|
||||
"expr".into(),
|
||||
format!("不支持的运算符: {op}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(json!({"result": result}))
|
||||
}
|
||||
@@ -86,13 +117,21 @@ impl BaseTool for CalcTool {
|
||||
/// 通过 MemoryStore trait 读写笔记:直接持有 Arc<dyn MemoryStore>,
|
||||
/// 绕开 AgentSession 封装(NoteTool 在 tool.execute 中直接操作 store)。
|
||||
/// 关键前缀 "note:" 用于 list 过滤。
|
||||
struct NoteTool { store: Arc<dyn MemoryStore> }
|
||||
impl NoteTool { const PREFIX: &'static str = "note:"; }
|
||||
struct NoteTool {
|
||||
store: Arc<dyn MemoryStore>,
|
||||
}
|
||||
impl NoteTool {
|
||||
const PREFIX: &'static str = "note:";
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BaseTool for NoteTool {
|
||||
fn name(&self) -> &str { "note" }
|
||||
fn description(&self) -> &str { "笔记 save/query: save(key, content) / query()" }
|
||||
fn name(&self) -> &str {
|
||||
"note"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"笔记 save/query: save(key, content) / query()"
|
||||
}
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type":"object",
|
||||
@@ -116,18 +155,29 @@ impl BaseTool for NoteTool {
|
||||
metadata: json!({}),
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
};
|
||||
self.store.save(item).await
|
||||
self.store
|
||||
.save(item)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed("note".into(), e.to_string()))?;
|
||||
Ok(json!({"saved": key}))
|
||||
}
|
||||
"query" => {
|
||||
let filter = MemoryFilter { prefix: Some(Self::PREFIX.into()), ..Default::default() };
|
||||
let items = self.store.list(&filter).await
|
||||
let filter = MemoryFilter {
|
||||
prefix: Some(Self::PREFIX.into()),
|
||||
..Default::default()
|
||||
};
|
||||
let items = self
|
||||
.store
|
||||
.list(&filter)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed("note".into(), e.to_string()))?;
|
||||
let notes: Vec<String> = items.into_iter().map(|i| i.content).collect();
|
||||
Ok(json!({"notes": notes}))
|
||||
}
|
||||
_ => Err(ToolError::InvalidArguments("action".into(), format!("未知 action: {action}"))),
|
||||
_ => Err(ToolError::InvalidArguments(
|
||||
"action".into(),
|
||||
format!("未知 action: {action}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,31 +185,91 @@ impl BaseTool for NoteTool {
|
||||
// === Mock response helper ===
|
||||
|
||||
fn resp(content: Vec<ContentBlock>, stop: StopReason, u: (u32, u32)) -> MessageResponse {
|
||||
MessageResponse { id: String::new(), model: "mock".into(),
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: "mock".into(),
|
||||
message: Message::Assistant { content },
|
||||
usage: Usage::from_input_output(u.0, u.1),
|
||||
stop_reason: stop, extra: Default::default() }
|
||||
stop_reason: stop,
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_responses() -> Vec<MessageResponse> {
|
||||
vec![
|
||||
// 第 1 轮:calc(25 * 4) → tool_result(100) → 文本回答
|
||||
resp(vec![ContentBlock::ToolUse { id: "t1".into(), name: "calc".into(),
|
||||
input: json!({"expr": "25 * 4"}) }], StopReason::ToolUse, (5, 8)),
|
||||
resp(vec![ContentBlock::Text { text: "25 * 4 = 100".into() }], StopReason::Stop, (8, 12)),
|
||||
resp(
|
||||
vec![ContentBlock::ToolUse {
|
||||
id: "t1".into(),
|
||||
name: "calc".into(),
|
||||
input: json!({"expr": "25 * 4"}),
|
||||
}],
|
||||
StopReason::ToolUse,
|
||||
(5, 8),
|
||||
),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "25 * 4 = 100".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(8, 12),
|
||||
),
|
||||
// 第 2 轮:note(save, last_calc, "100") → tool_result(saved) → 文本回答
|
||||
resp(vec![ContentBlock::ToolUse { id: "t2".into(), name: "note".into(),
|
||||
input: json!({"action": "save", "key": "last_calc", "content": "100"}) }],
|
||||
StopReason::ToolUse, (10, 14)),
|
||||
resp(vec![ContentBlock::Text { text: "已记录:last_calc = 100".into() }], StopReason::Stop, (12, 16)),
|
||||
resp(
|
||||
vec![ContentBlock::ToolUse {
|
||||
id: "t2".into(),
|
||||
name: "note".into(),
|
||||
input: json!({"action": "save", "key": "last_calc", "content": "100"}),
|
||||
}],
|
||||
StopReason::ToolUse,
|
||||
(10, 14),
|
||||
),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "已记录:last_calc = 100".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(12, 16),
|
||||
),
|
||||
// 第 3 轮:note(query) → tool_result([100]) → 文本回答
|
||||
resp(vec![ContentBlock::ToolUse { id: "t3".into(), name: "note".into(),
|
||||
input: json!({"action": "query"}) }], StopReason::ToolUse, (8, 8)),
|
||||
resp(vec![ContentBlock::Text { text: "您刚才的计算结果是 100".into() }], StopReason::Stop, (10, 14)),
|
||||
resp(
|
||||
vec![ContentBlock::ToolUse {
|
||||
id: "t3".into(),
|
||||
name: "note".into(),
|
||||
input: json!({"action": "query"}),
|
||||
}],
|
||||
StopReason::ToolUse,
|
||||
(8, 8),
|
||||
),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "您刚才的计算结果是 100".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(10, 14),
|
||||
),
|
||||
// 后续冗余响应(防止队列耗尽报错)
|
||||
resp(vec![ContentBlock::Text { text: "done".into() }], StopReason::Stop, (1, 1)),
|
||||
resp(vec![ContentBlock::Text { text: "done".into() }], StopReason::Stop, (1, 1)),
|
||||
resp(vec![ContentBlock::Text { text: "done".into() }], StopReason::Stop, (1, 1)),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "done".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(1, 1),
|
||||
),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "done".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(1, 1),
|
||||
),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "done".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(1, 1),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -168,14 +278,21 @@ fn mock_responses() -> Vec<MessageResponse> {
|
||||
fn select_provider() -> Arc<dyn LlmProvider> {
|
||||
if env::var("AG_LLM_BASE_URL").is_ok() && env::var("AG_LLM_API_KEY").is_ok() {
|
||||
let cfg = ProviderConfig::from_env("AG_LLM").expect("AG_LLM_* 环境变量解析失败");
|
||||
let provider_type = env::var("AG_LLM_PROVIDER").ok()
|
||||
let provider_type = env::var("AG_LLM_PROVIDER")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<ProviderType>().ok())
|
||||
.unwrap_or(ProviderType::OpenaiChat);
|
||||
Arc::from(create_provider(provider_type, cfg).expect("Provider 创建失败"))
|
||||
} else {
|
||||
let found: Vec<&str> = ["AG_LLM_BASE_URL", "AG_LLM_API_KEY", "AG_LLM_MODEL"]
|
||||
.iter().filter(|k| env::var(k).is_ok()).copied().collect();
|
||||
eprintln!("AG_LLM_* 环境变量不完整(检测到: {:?}),回退到 MockProvider", found);
|
||||
.iter()
|
||||
.filter(|k| env::var(k).is_ok())
|
||||
.copied()
|
||||
.collect();
|
||||
eprintln!(
|
||||
"AG_LLM_* 环境变量不完整(检测到: {:?}),回退到 MockProvider",
|
||||
found
|
||||
);
|
||||
Arc::new(MockProvider::new(mock_responses()))
|
||||
}
|
||||
}
|
||||
@@ -190,52 +307,74 @@ async fn main() {
|
||||
let backend: Arc<dyn MemoryStore> =
|
||||
Arc::new(SqliteStore::open(&db_path).expect("SqliteStore 打开失败"));
|
||||
println!("💾 SqliteStore: {}", db_path.display());
|
||||
let provider_label = if env::var("AG_LLM_BASE_URL").is_ok() && env::var("AG_LLM_API_KEY").is_ok() {
|
||||
"真实 LLM Provider"
|
||||
} else {
|
||||
"MockProvider (离线回退模式)"
|
||||
};
|
||||
let provider_label =
|
||||
if env::var("AG_LLM_BASE_URL").is_ok() && env::var("AG_LLM_API_KEY").is_ok() {
|
||||
"真实 LLM Provider"
|
||||
} else {
|
||||
"MockProvider (离线回退模式)"
|
||||
};
|
||||
println!("🔄 Provider: {provider_label}");
|
||||
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Arc::new(EchoTool)).unwrap();
|
||||
registry.register(Arc::new(CalcTool)).unwrap();
|
||||
registry.register(Arc::new(NoteTool { store: backend.clone() })).unwrap();
|
||||
registry
|
||||
.register(Arc::new(NoteTool {
|
||||
store: backend.clone(),
|
||||
}))
|
||||
.unwrap();
|
||||
println!("🔧 注册工具: {:?}", registry.list_tools());
|
||||
|
||||
let bundle = Arc::new(AgentBuilder::new()
|
||||
.provider(select_provider())
|
||||
.tool_registry(Arc::new(registry))
|
||||
.hook_executor(Arc::new(HookExecutor::new()))
|
||||
.build().expect("RuntimeBundle 装配失败"));
|
||||
let bundle = Arc::new(
|
||||
AgentBuilder::new()
|
||||
.provider(select_provider())
|
||||
.tool_registry(Arc::new(registry))
|
||||
.hook_executor(Arc::new(HookExecutor::new()))
|
||||
.build()
|
||||
.expect("RuntimeBundle 装配失败"),
|
||||
);
|
||||
|
||||
let mut session = AgentSession::new(Arc::new(AssistantAgent), "e2e-1", bundle.clone());
|
||||
|
||||
println!("\n第 1 轮 用户: 帮我算 25 * 4");
|
||||
let r1 = session.submit_turn("帮我算 25 * 4").await.expect("turn 1 失败");
|
||||
let r1 = session
|
||||
.submit_turn("帮我算 25 * 4")
|
||||
.await
|
||||
.expect("turn 1 失败");
|
||||
println!(" → 回答: {}", r1.text());
|
||||
|
||||
println!("\n第 2 轮 用户: 记下来:结果是 100");
|
||||
let r2 = session.submit_turn("记下来:结果是 100").await.expect("turn 2 失败");
|
||||
let r2 = session
|
||||
.submit_turn("记下来:结果是 100")
|
||||
.await
|
||||
.expect("turn 2 失败");
|
||||
println!(" → 回答: {}", r2.text());
|
||||
|
||||
println!("\n第 3 轮 用户: 我刚才算了什么?");
|
||||
let r3 = session.submit_turn("我刚才算了什么?").await.expect("turn 3 失败");
|
||||
let r3 = session
|
||||
.submit_turn("我刚才算了什么?")
|
||||
.await
|
||||
.expect("turn 3 失败");
|
||||
println!(" → 回答: {}", r3.text());
|
||||
|
||||
let total = session.usage().total();
|
||||
println!("\n📊 用量: prompt={}, completion={}, total={}",
|
||||
total.prompt_tokens, total.completion_tokens, total.total_tokens);
|
||||
println!(
|
||||
"\n📊 用量: prompt={}, completion={}, total={}",
|
||||
total.prompt_tokens, total.completion_tokens, total.total_tokens
|
||||
);
|
||||
|
||||
println!("\n=== 持久化验证 ===");
|
||||
// 显式释放所有对 backend 的 Arc 引用,确保 SqliteStore Connection 真正关闭。
|
||||
// 释放顺序:session → bundle(间接持有 NoteTool → backend clone)→ backend 局部变量。
|
||||
drop(session); // session.bundle Arc 计数 -1
|
||||
drop(bundle); // bundle Arc 计数归零 → registry → NoteTool → backend clone Arc 计数 2→1
|
||||
drop(backend); // backend 局部变量 Arc 计数 1→0 → SqliteStore::drop → Connection 自动 close
|
||||
drop(session); // session.bundle Arc 计数 -1
|
||||
drop(bundle); // bundle Arc 计数归零 → registry → NoteTool → backend clone Arc 计数 2→1
|
||||
drop(backend); // backend 局部变量 Arc 计数 1→0 → SqliteStore::drop → Connection 自动 close
|
||||
let backend2: Arc<dyn MemoryStore> =
|
||||
Arc::new(SqliteStore::open(&db_path).expect("重开 SqliteStore 失败"));
|
||||
let filter = MemoryFilter { prefix: Some("note:".into()), ..Default::default() };
|
||||
let filter = MemoryFilter {
|
||||
prefix: Some("note:".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let items = backend2.list(&filter).await.expect("list 失败");
|
||||
println!("✓ 跨连接数据存活: 找到 {} 条 note", items.len());
|
||||
assert!(!items.is_empty(), "持久化验证失败:重开后无数据");
|
||||
@@ -244,4 +383,4 @@ async fn main() {
|
||||
}
|
||||
|
||||
println!("\n✓ 端到端演示完成");
|
||||
}
|
||||
}
|
||||
|
||||
+12
-5
@@ -1,4 +1,5 @@
|
||||
//! engine_demo —— SessionManager + Checkpointer 端到端示例。
|
||||
//! Required features: cargo run --example engine_demo --features "engine"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. SessionManager::create 创建 session
|
||||
@@ -190,8 +191,8 @@ async fn main() {
|
||||
snapshot_turn, snapshot_data_count
|
||||
);
|
||||
|
||||
let mut rolled_back =
|
||||
AgentSession::from_snapshot(snapshot, agent.clone(), bundle.clone()).expect("from_snapshot");
|
||||
let mut rolled_back = AgentSession::from_snapshot(snapshot, agent.clone(), bundle.clone())
|
||||
.expect("from_snapshot");
|
||||
rolled_back
|
||||
.restore_memory()
|
||||
.await
|
||||
@@ -216,8 +217,14 @@ async fn main() {
|
||||
after_turn,
|
||||
before_turn
|
||||
);
|
||||
assert_eq!(after_turn, snapshot_turn, "rollback 后 turn_index 应等于 checkpoint 时刻值");
|
||||
assert!(after_cost <= before_cost, "rollback 后 cost 应 ≤ rollback 前");
|
||||
assert_eq!(
|
||||
after_turn, snapshot_turn,
|
||||
"rollback 后 turn_index 应等于 checkpoint 时刻值"
|
||||
);
|
||||
assert!(
|
||||
after_cost <= before_cost,
|
||||
"rollback 后 cost 应 ≤ rollback 前"
|
||||
);
|
||||
println!("✓ rollback + replace 一致性验证通过");
|
||||
|
||||
// 9. destroy 父子 session
|
||||
@@ -249,4 +256,4 @@ async fn main() {
|
||||
sm.destroy(&c_id).await.unwrap();
|
||||
|
||||
println!("\n✓ engine_demo 完成");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! knowledge_graph_demo -- 知识图谱 + 双通道检索演示。
|
||||
//! Required features: cargo run --example knowledge_graph_demo --features "memory"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. 构建 KnowledgeGraph(实体 + 关系)
|
||||
@@ -52,15 +53,30 @@ async fn main() {
|
||||
graph.add_entity(e.clone()).await.unwrap();
|
||||
}
|
||||
graph
|
||||
.add_relation(GraphRelation::new("langchain", "langgraph", "includes", 0.9))
|
||||
.add_relation(GraphRelation::new(
|
||||
"langchain",
|
||||
"langgraph",
|
||||
"includes",
|
||||
0.9,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
graph
|
||||
.add_relation(GraphRelation::new("langchain", "langsmith", "includes", 0.7))
|
||||
.add_relation(GraphRelation::new(
|
||||
"langchain",
|
||||
"langsmith",
|
||||
"includes",
|
||||
0.7,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
graph
|
||||
.add_relation(GraphRelation::new("langchain", "python", "built_with", 0.95))
|
||||
.add_relation(GraphRelation::new(
|
||||
"langchain",
|
||||
"python",
|
||||
"built_with",
|
||||
0.95,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
graph
|
||||
@@ -86,7 +102,10 @@ async fn main() {
|
||||
// ── 3. 标签管理 ──
|
||||
println!("\n=== 3. 标签管理 ===");
|
||||
graph
|
||||
.set_entity_tags("langchain", vec!["ai".into(), "framework".into(), "llm".into()])
|
||||
.set_entity_tags(
|
||||
"langchain",
|
||||
vec!["ai".into(), "framework".into(), "llm".into()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
graph
|
||||
@@ -118,8 +137,8 @@ async fn main() {
|
||||
.unwrap();
|
||||
|
||||
// Hybrid 策略(默认)
|
||||
let retriever = MemoryRetriever::new(ks, RetrieverConfig::default())
|
||||
.with_knowledge_graph(graph.clone());
|
||||
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);
|
||||
@@ -129,7 +148,10 @@ async fn main() {
|
||||
println!(" [Store] {} (score={:.3})", page.title, score);
|
||||
}
|
||||
RetrievalItem::GraphEntity {
|
||||
entity, score, path, ..
|
||||
entity,
|
||||
score,
|
||||
path,
|
||||
..
|
||||
} => {
|
||||
println!(
|
||||
" [Graph] {} (score={:.3}, path={:?})",
|
||||
@@ -172,7 +194,10 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
result.items.iter().all(|i| matches!(i, RetrievalItem::GraphEntity { .. })),
|
||||
result
|
||||
.items
|
||||
.iter()
|
||||
.all(|i| matches!(i, RetrievalItem::GraphEntity { .. })),
|
||||
"GraphOnly 应只返回 Graph 结果"
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! knowledge_search_demo —— 知识页面存储与关键词检索。
|
||||
//! Required features: cargo run --example knowledge_search_demo --features "memory"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. `KnowledgeStore` 存储多个 `KnowledgePage`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! prompt_composer —— 提示词模板与组合器离线示例。
|
||||
//! Required features: cargo run --example prompt_composer --features "prompt,llm"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. `PromptTemplate::compile` + `render` 变量插值(`{{var}}` 语法)
|
||||
|
||||
+55
-18
@@ -1,41 +1,61 @@
|
||||
//! quick_start —— 30 行最小可运行示例,展示 Agent / BaseTool / Builder / Session 四层抽象。
|
||||
//! Required features: cargo run --example quick_start --features "agent"
|
||||
//!
|
||||
//! 运行:`cargo run --example quick_start`(离线,零配置)
|
||||
|
||||
use std::sync::Arc;
|
||||
use agcore::agent::{Agent, AgentBuilder, AgentSession};
|
||||
use agcore::llm::LlmProvider;
|
||||
use agcore::llm::hooks::HookExecutor;
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::provider::LlmProvider;
|
||||
use agcore::llm::types::{Usage, message::{ContentBlock, Message}, response_v2::{MessageResponse, StopReason}};
|
||||
use agcore::llm::types::{
|
||||
Usage,
|
||||
message::{ContentBlock, Message},
|
||||
response_v2::{MessageResponse, StopReason},
|
||||
};
|
||||
use agcore::tools::{BaseTool, ToolContext, ToolError, ToolRegistry};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Greeter;
|
||||
impl Agent for Greeter {
|
||||
fn name(&self) -> &str { "greeter" }
|
||||
fn system_prompt(&self) -> Option<&str> { Some("中文助手,先调用 echo 工具,再总结。") }
|
||||
fn name(&self) -> &str {
|
||||
"greeter"
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
Some("中文助手,先调用 echo 工具,再总结。")
|
||||
}
|
||||
}
|
||||
|
||||
struct EchoTool;
|
||||
#[async_trait]
|
||||
impl BaseTool for EchoTool {
|
||||
fn name(&self) -> &str { "echo" }
|
||||
fn description(&self) -> &str { "回显文本" }
|
||||
fn name(&self) -> &str {
|
||||
"echo"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"回显文本"
|
||||
}
|
||||
fn parameters(&self) -> Value {
|
||||
json!({"type":"object","properties":{"text":{"type":"string"}},"required":["text"]})
|
||||
}
|
||||
async fn execute(&self, args: Value, _: &ToolContext<'_>) -> Result<Value, ToolError> {
|
||||
let text = args.get("text").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidArguments("text".into(), "需要 string 类型的 text 参数".into()))?;
|
||||
let text = args.get("text").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
ToolError::InvalidArguments("text".into(), "需要 string 类型的 text 参数".into())
|
||||
})?;
|
||||
Ok(json!({"echoed": format!("收到: {text}")}))
|
||||
}
|
||||
}
|
||||
|
||||
fn resp(content: Vec<ContentBlock>, stop: StopReason, u: (u32, u32)) -> MessageResponse {
|
||||
MessageResponse { id: String::new(), model: "mock".into(), message: Message::Assistant { content },
|
||||
usage: Usage::from_input_output(u.0, u.1), stop_reason: stop, extra: Default::default() }
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: "mock".into(),
|
||||
message: Message::Assistant { content },
|
||||
usage: Usage::from_input_output(u.0, u.1),
|
||||
stop_reason: stop,
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -43,14 +63,31 @@ async fn main() {
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(Arc::new(EchoTool)).unwrap();
|
||||
let provider: Arc<dyn LlmProvider> = Arc::new(MockProvider::new(vec![
|
||||
resp(vec![ContentBlock::ToolUse { id: "c1".into(), name: "echo".into(),
|
||||
input: json!({"text": "你好"}) }], StopReason::ToolUse, (5, 8)),
|
||||
resp(vec![ContentBlock::Text { text: "EchoTool 已收到您的消息并完成回传。".into() }],
|
||||
StopReason::Stop, (8, 16)),
|
||||
resp(
|
||||
vec![ContentBlock::ToolUse {
|
||||
id: "c1".into(),
|
||||
name: "echo".into(),
|
||||
input: json!({"text": "你好"}),
|
||||
}],
|
||||
StopReason::ToolUse,
|
||||
(5, 8),
|
||||
),
|
||||
resp(
|
||||
vec![ContentBlock::Text {
|
||||
text: "EchoTool 已收到您的消息并完成回传。".into(),
|
||||
}],
|
||||
StopReason::Stop,
|
||||
(8, 16),
|
||||
),
|
||||
]));
|
||||
let bundle = Arc::new(AgentBuilder::new()
|
||||
.provider(provider).tool_registry(Arc::new(registry))
|
||||
.hook_executor(Arc::new(HookExecutor::new())).build().unwrap());
|
||||
let bundle = Arc::new(
|
||||
AgentBuilder::new()
|
||||
.provider(provider)
|
||||
.tool_registry(Arc::new(registry))
|
||||
.hook_executor(Arc::new(HookExecutor::new()))
|
||||
.build()
|
||||
.unwrap(),
|
||||
);
|
||||
let mut session = AgentSession::new(Arc::new(Greeter), "qs", bundle);
|
||||
let resp = session.submit_turn("你好").await.unwrap();
|
||||
let text = resp.text();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Required features: cargo run --example simple_visit --features "llm,provider-openai,tracing-init"
|
||||
|
||||
use std::env;
|
||||
|
||||
use agcore::init_tracing;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! streaming_events_demo —— LLM 流式响应事件流消费(含错误路径)。
|
||||
//! Required features: cargo run --example streaming_events_demo --features "llm,provider-openai"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. `MockProvider::chat_stream` 输出标准 `StreamEvent` 流(离线可跑)
|
||||
@@ -14,9 +15,9 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agcore::llm::LlmProvider;
|
||||
use agcore::llm::cycle::{CycleConfig, LlmCycle};
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::provider::LlmProvider;
|
||||
use agcore::llm::types::Usage;
|
||||
use agcore::llm::types::message::{ContentBlock, Message};
|
||||
use agcore::llm::types::response_v2::{MessageResponse, StopReason, StreamEvent};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! sub_agent_dispatch_demo —— SubAgent 并行派发示例。
|
||||
//! Required features: cargo run --example sub_agent_dispatch_demo --features "engine"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. 创建父 session("主编" agent)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! task_agent_demo —— Plan 解析、Step 状态机、错误路径。
|
||||
//! Required features: cargo run --example task_agent_demo --features "agent"
|
||||
//!
|
||||
//! 演示:
|
||||
//! 1. `JsonPlanParser::parse` 解析合法 JSON 输入
|
||||
@@ -12,9 +13,9 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use agcore::agent::{AgentError, JsonPlanParser, PlanParser, Step, StepStatus};
|
||||
use agcore::llm::types::Usage;
|
||||
use agcore::llm::types::message::Message;
|
||||
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
|
||||
use agcore::llm::types::Usage;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@@ -111,4 +112,4 @@ async fn main() {
|
||||
assert!(matches!(err, AgentError::PlanParse(_)));
|
||||
|
||||
println!("\n✓ task_agent_demo 完成");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@ pub mod task;
|
||||
pub use agent::Agent;
|
||||
pub use builder::AgentBuilder;
|
||||
pub use context::{
|
||||
ContextBudget, ContextSlot, DeriveStrategy, FocusedConfig, MergeStrategy, SlotConfig,
|
||||
SlotMeta, SlotMode, SlotSource,
|
||||
ContextBudget, ContextSlot, DeriveStrategy, FocusedConfig, MergeStrategy, SlotConfig, SlotMeta,
|
||||
SlotMode, SlotSource,
|
||||
};
|
||||
pub use error::AgentError;
|
||||
pub use runtime::{AgentConfig, RuntimeBundle};
|
||||
|
||||
@@ -12,8 +12,8 @@ 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;
|
||||
@@ -132,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;
|
||||
|
||||
+136
-44
@@ -250,12 +250,12 @@ impl ContextSlot {
|
||||
|
||||
/// 保存 slot 数据到存储后端(全量写入,含 config)。
|
||||
pub async fn save(&self, store: &dyn MemoryStore) -> Result<(), AgentError> {
|
||||
let data = serde_json::to_string(&self.messages)
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
let meta = serde_json::to_string(&self.meta)
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
let config = serde_json::to_string(&self.config)
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
let data =
|
||||
serde_json::to_string(&self.messages).map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
let meta =
|
||||
serde_json::to_string(&self.meta).map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
let config =
|
||||
serde_json::to_string(&self.config).map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
|
||||
store
|
||||
.save(Self::make_item(
|
||||
@@ -551,8 +551,10 @@ mod tests {
|
||||
async fn slot_save_load_roundtrip() {
|
||||
let store = make_store();
|
||||
let mut slot = make_slot("default", "s1");
|
||||
slot.append_messages(vec![Message::user_text("hi")]).unwrap();
|
||||
slot.append_messages(vec![Message::assistant("hello")]).unwrap();
|
||||
slot.append_messages(vec![Message::user_text("hi")])
|
||||
.unwrap();
|
||||
slot.append_messages(vec![Message::assistant("hello")])
|
||||
.unwrap();
|
||||
|
||||
slot.save(&*store).await.unwrap();
|
||||
let loaded = ContextSlot::load("default", "s1", &*store).await.unwrap();
|
||||
@@ -576,8 +578,14 @@ mod tests {
|
||||
.unwrap();
|
||||
b.save(&*store).await.unwrap();
|
||||
|
||||
let loaded_a = ContextSlot::load("main", "sA", &*store).await.unwrap().unwrap();
|
||||
let loaded_b = ContextSlot::load("main", "sB", &*store).await.unwrap().unwrap();
|
||||
let loaded_a = ContextSlot::load("main", "sA", &*store)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let loaded_b = ContextSlot::load("main", "sB", &*store)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(extract_text(&loaded_a.messages[0]), "only in A");
|
||||
assert_eq!(extract_text(&loaded_b.messages[0]), "only in B");
|
||||
}
|
||||
@@ -622,10 +630,13 @@ mod tests {
|
||||
async fn slot_delete_then_load_none() {
|
||||
let store = make_store();
|
||||
let mut slot = make_slot("to_delete", "s1");
|
||||
slot.append_messages(vec![Message::user_text("hi")]).unwrap();
|
||||
slot.append_messages(vec![Message::user_text("hi")])
|
||||
.unwrap();
|
||||
slot.save(&*store).await.unwrap();
|
||||
|
||||
ContextSlot::delete("to_delete", "s1", &*store).await.unwrap();
|
||||
ContextSlot::delete("to_delete", "s1", &*store)
|
||||
.await
|
||||
.unwrap();
|
||||
let loaded = ContextSlot::load("to_delete", "s1", &*store).await.unwrap();
|
||||
assert!(loaded.is_none());
|
||||
}
|
||||
@@ -723,7 +734,10 @@ mod tests {
|
||||
let store = make_store();
|
||||
let slot = make_slot("empty", "s1");
|
||||
slot.save(&*store).await.unwrap();
|
||||
let loaded = ContextSlot::load("empty", "s1", &*store).await.unwrap().unwrap();
|
||||
let loaded = ContextSlot::load("empty", "s1", &*store)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(loaded.messages.is_empty());
|
||||
assert_eq!(loaded.meta.message_count, 0);
|
||||
}
|
||||
@@ -745,7 +759,8 @@ mod tests {
|
||||
async fn derive_full_copies_parent_messages() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
for i in 0..3 {
|
||||
parent.append_messages(vec![Message::user_text(format!("u{i}"))])
|
||||
parent
|
||||
.append_messages(vec![Message::user_text(format!("u{i}"))])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -769,7 +784,10 @@ mod tests {
|
||||
let store = make_store();
|
||||
child.save(&*store).await.unwrap();
|
||||
|
||||
let loaded = ContextSlot::load("c", "s1", &*store).await.unwrap().unwrap();
|
||||
let loaded = ContextSlot::load("c", "s1", &*store)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(loaded.messages.len(), 3);
|
||||
assert!(matches!(loaded.config.source, SlotSource::Derived { .. }));
|
||||
}
|
||||
@@ -777,9 +795,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn derive_focused_filters_parent_messages() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::system("sys")]).unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::system("sys")])
|
||||
.unwrap();
|
||||
for i in 0..5 {
|
||||
parent.append_messages(vec![Message::user_text(format!("u{i}"))])
|
||||
parent
|
||||
.append_messages(vec![Message::user_text(format!("u{i}"))])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -818,7 +839,9 @@ mod tests {
|
||||
async fn derived_slot_loadable_independently() {
|
||||
let store = make_store();
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("u")]).unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::user_text("u")])
|
||||
.unwrap();
|
||||
parent.save(&*store).await.unwrap();
|
||||
|
||||
// 派生 child
|
||||
@@ -835,12 +858,16 @@ mod tests {
|
||||
compact: true,
|
||||
},
|
||||
);
|
||||
child.append_messages(vec![Message::user_text("derived msg")])
|
||||
child
|
||||
.append_messages(vec![Message::user_text("derived msg")])
|
||||
.unwrap();
|
||||
child.save(&*store).await.unwrap();
|
||||
|
||||
// child 可独立加载
|
||||
let loaded = ContextSlot::load("c", "s1", &*store).await.unwrap().unwrap();
|
||||
let loaded = ContextSlot::load("c", "s1", &*store)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(loaded.messages.len(), 1);
|
||||
assert_eq!(extract_text(&loaded.messages[0]), "derived msg");
|
||||
}
|
||||
@@ -867,17 +894,59 @@ mod tests {
|
||||
slot.save(&*store).await.unwrap();
|
||||
|
||||
// 确认所有记录存在
|
||||
assert!(store.get(&ContextSlot::data_key("s1", "x")).await.unwrap().is_some());
|
||||
assert!(store.get(&ContextSlot::meta_key("s1", "x")).await.unwrap().is_some());
|
||||
assert!(store.get(&ContextSlot::config_key("s1", "x")).await.unwrap().is_some());
|
||||
assert!(store.get(&ContextSlot::rel_key("s1", "x")).await.unwrap().is_some());
|
||||
assert!(
|
||||
store
|
||||
.get(&ContextSlot::data_key("s1", "x"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get(&ContextSlot::meta_key("s1", "x"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get(&ContextSlot::config_key("s1", "x"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get(&ContextSlot::rel_key("s1", "x"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
|
||||
ContextSlot::delete("x", "s1", &*store).await.unwrap();
|
||||
|
||||
// data/meta/config 已删
|
||||
assert!(store.get(&ContextSlot::data_key("s1", "x")).await.unwrap().is_none());
|
||||
assert!(store.get(&ContextSlot::meta_key("s1", "x")).await.unwrap().is_none());
|
||||
assert!(store.get(&ContextSlot::config_key("s1", "x")).await.unwrap().is_none());
|
||||
assert!(
|
||||
store
|
||||
.get(&ContextSlot::data_key("s1", "x"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get(&ContextSlot::meta_key("s1", "x"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get(&ContextSlot::config_key("s1", "x"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 基础类型测试 =====
|
||||
@@ -893,7 +962,10 @@ mod tests {
|
||||
#[test]
|
||||
fn context_budget_default_sum_128k() {
|
||||
let b = ContextBudget::default();
|
||||
assert_eq!(b.system + b.history + b.tools + b.tool_results + b.reserve, 128_000);
|
||||
assert_eq!(
|
||||
b.system + b.history + b.tools + b.tool_results + b.reserve,
|
||||
128_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -939,10 +1011,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn filter_focused_injects_summary() {
|
||||
let messages = vec![
|
||||
Message::user_text("u"),
|
||||
Message::assistant("a"),
|
||||
];
|
||||
let messages = vec![Message::user_text("u"), Message::assistant("a")];
|
||||
let cfg = FocusedConfig {
|
||||
keep_system: false,
|
||||
recent_messages: 100,
|
||||
@@ -964,8 +1033,12 @@ mod tests {
|
||||
#[test]
|
||||
fn fork_full_copies_messages() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("a")]).unwrap();
|
||||
parent.append_messages(vec![Message::assistant("b")]).unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::user_text("a")])
|
||||
.unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::assistant("b")])
|
||||
.unwrap();
|
||||
let child = parent.fork("c".into(), DeriveStrategy::Full);
|
||||
assert_eq!(child.messages.len(), 2);
|
||||
assert!(matches!(child.config.mode, SlotMode::Full));
|
||||
@@ -974,7 +1047,9 @@ mod tests {
|
||||
#[test]
|
||||
fn fork_focused_filters_messages() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::system("sys")]).unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::system("sys")])
|
||||
.unwrap();
|
||||
for i in 0..5 {
|
||||
parent
|
||||
.append_messages(vec![Message::user_text(format!("u{i}"))])
|
||||
@@ -994,7 +1069,9 @@ mod tests {
|
||||
#[test]
|
||||
fn fork_preserves_independence() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("a")]).unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::user_text("a")])
|
||||
.unwrap();
|
||||
let mut child = parent.fork("c".into(), DeriveStrategy::Full);
|
||||
let child_count_at_fork = child.messages.len();
|
||||
|
||||
@@ -1003,7 +1080,9 @@ mod tests {
|
||||
.append_messages(vec![Message::user_text("b")])
|
||||
.unwrap();
|
||||
// 子 slot 追加
|
||||
child.append_messages(vec![Message::user_text("c")]).unwrap();
|
||||
child
|
||||
.append_messages(vec![Message::user_text("c")])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(parent.messages.len(), 2);
|
||||
assert_eq!(child.messages.len(), child_count_at_fork + 1);
|
||||
@@ -1013,10 +1092,15 @@ mod tests {
|
||||
#[test]
|
||||
fn fork_sets_derived_source() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("a")]).unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::user_text("a")])
|
||||
.unwrap();
|
||||
let child = parent.fork("c".into(), DeriveStrategy::Full);
|
||||
match &child.config.source {
|
||||
SlotSource::Derived { parent_id, strategy } => {
|
||||
SlotSource::Derived {
|
||||
parent_id,
|
||||
strategy,
|
||||
} => {
|
||||
assert_eq!(parent_id, "p");
|
||||
assert!(matches!(strategy, DeriveStrategy::Full));
|
||||
}
|
||||
@@ -1030,7 +1114,9 @@ mod tests {
|
||||
#[test]
|
||||
fn merge_append_appends_messages() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("p1")]).unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::user_text("p1")])
|
||||
.unwrap();
|
||||
let child = {
|
||||
let mut c = parent.fork("c".into(), DeriveStrategy::Full);
|
||||
// fork 时 child 继承父的 "p1";再追加一条 c1
|
||||
@@ -1049,8 +1135,12 @@ mod tests {
|
||||
#[test]
|
||||
fn merge_replace_replaces_messages() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("p1")]).unwrap();
|
||||
parent.append_messages(vec![Message::user_text("p2")]).unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::user_text("p1")])
|
||||
.unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::user_text("p2")])
|
||||
.unwrap();
|
||||
let child = {
|
||||
let mut c = parent.fork("c".into(), DeriveStrategy::Full);
|
||||
// 清空 child 再追加
|
||||
@@ -1088,7 +1178,9 @@ mod tests {
|
||||
#[test]
|
||||
fn merge_cross_session_rejected() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("a")]).unwrap();
|
||||
parent
|
||||
.append_messages(vec![Message::user_text("a")])
|
||||
.unwrap();
|
||||
let child = ContextSlot::new("OTHER_SESSION", "c", SlotConfig::default());
|
||||
let err = parent.merge(child, MergeStrategy::Append).unwrap_err();
|
||||
assert!(matches!(err, AgentError::Config(_)));
|
||||
@@ -1121,4 +1213,4 @@ mod tests {
|
||||
.unwrap()
|
||||
.block_on(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,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::hooks::HookExecutor;
|
||||
use crate::llm::provider::LlmProvider;
|
||||
use crate::memory::retriever::MemoryRetriever;
|
||||
use crate::memory::store::MemoryStore;
|
||||
use crate::tools::ToolRegistry;
|
||||
|
||||
+82
-64
@@ -15,22 +15,22 @@ use std::sync::Arc;
|
||||
use futures_core::Stream;
|
||||
|
||||
use crate::agent::agent::Agent;
|
||||
use crate::agent::context::{
|
||||
ContextSlot, DeriveStrategy, SlotConfig, SlotMode,
|
||||
};
|
||||
use crate::agent::context::{ContextSlot, DeriveStrategy, SlotConfig, SlotMode};
|
||||
// SlotSource 仅在 `mod tests` 中使用(通过 `use super::*;` 引入),lib 主体保留以避免测试 import 变更。
|
||||
#[allow(unused_imports)]
|
||||
use crate::agent::context::SlotSource;
|
||||
use crate::agent::error::AgentError;
|
||||
use crate::agent::runtime::RuntimeBundle;
|
||||
use crate::agent::session_memory::SessionMemory;
|
||||
use crate::agent::summary::{format_messages_as_text, SummaryConfig};
|
||||
use crate::engine::snapshot::{SessionMemoryEntry, SessionSnapshot};
|
||||
use crate::agent::summary::{SummaryConfig, format_messages_as_text};
|
||||
#[cfg(feature = "engine")]
|
||||
use crate::engine::EngineError;
|
||||
#[cfg(feature = "engine")]
|
||||
use crate::engine::snapshot::{SessionMemoryEntry, SessionSnapshot};
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::llm::cycle::{CostTracker, CycleConfig, LlmCycle};
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::hooks::{HookContext, HookEvent};
|
||||
use crate::llm::provider::LlmProvider;
|
||||
use crate::llm::stream::StreamEvent;
|
||||
use crate::llm::types::message::Message;
|
||||
use crate::llm::types::response_v2::MessageResponse;
|
||||
@@ -67,6 +67,7 @@ pub struct AgentSession {
|
||||
/// `None` 表示无 pending restore(正常状态)。
|
||||
/// 调用 `restore_memory()` 后会被消费并设为 `None`。
|
||||
/// 这是 transient state,不参与序列化(AgentSession 本身不 derive Serialize)。
|
||||
#[cfg(feature = "engine")]
|
||||
pending_memory_restore: Option<HashMap<String, SessionMemoryEntry>>,
|
||||
}
|
||||
|
||||
@@ -109,11 +110,7 @@ impl AgentSession {
|
||||
let session_memory = SessionMemory::new(backend, &session_id_str);
|
||||
|
||||
// 自动创建 "default" slot
|
||||
let default_slot = ContextSlot::new(
|
||||
&session_id_str,
|
||||
"default",
|
||||
SlotConfig::default(),
|
||||
);
|
||||
let default_slot = ContextSlot::new(&session_id_str, "default", SlotConfig::default());
|
||||
let mut slots = HashMap::new();
|
||||
slots.insert("default".to_string(), default_slot);
|
||||
|
||||
@@ -127,6 +124,7 @@ impl AgentSession {
|
||||
slots,
|
||||
current_slot_id: "default".to_string(),
|
||||
last_summary_turn: None,
|
||||
#[cfg(feature = "engine")]
|
||||
pending_memory_restore: None,
|
||||
}
|
||||
}
|
||||
@@ -147,6 +145,7 @@ impl AgentSession {
|
||||
}
|
||||
|
||||
/// RuntimeBundle 引用(Phase 17 新增,供 SessionManager::create_child 继承父 bundle)。
|
||||
#[cfg(feature = "engine")]
|
||||
pub(crate) fn bundle(&self) -> &Arc<RuntimeBundle> {
|
||||
&self.bundle
|
||||
}
|
||||
@@ -157,9 +156,7 @@ impl AgentSession {
|
||||
key: impl Into<String>,
|
||||
value: impl Into<String>,
|
||||
) -> Result<(), AgentError> {
|
||||
self.session_memory
|
||||
.set(&key.into(), &value.into())
|
||||
.await
|
||||
self.session_memory.set(&key.into(), &value.into()).await
|
||||
}
|
||||
|
||||
/// 读取一条会话级数据。
|
||||
@@ -199,11 +196,7 @@ impl AgentSession {
|
||||
if self.slots.contains_key(&id) {
|
||||
return Err(AgentError::SlotAlreadyExists(id));
|
||||
}
|
||||
let slot = ContextSlot::new(
|
||||
&self.session_id,
|
||||
&id,
|
||||
config.unwrap_or_default(),
|
||||
);
|
||||
let slot = ContextSlot::new(&self.session_id, &id, config.unwrap_or_default());
|
||||
slot.save(&*self.resolve_store()).await?;
|
||||
self.slots.insert(id, slot);
|
||||
Ok(())
|
||||
@@ -259,7 +252,9 @@ impl AgentSession {
|
||||
/// - 已删除后再 load 返回 None
|
||||
pub async fn delete_slot(&mut self, id: &str) -> Result<(), AgentError> {
|
||||
if id == "default" {
|
||||
return Err(AgentError::Config("Cannot delete the 'default' slot".into()));
|
||||
return Err(AgentError::Config(
|
||||
"Cannot delete the 'default' slot".into(),
|
||||
));
|
||||
}
|
||||
if self.slots.len() <= 1 {
|
||||
return Err(AgentError::Config("Cannot delete the last slot".into()));
|
||||
@@ -349,12 +344,7 @@ impl AgentSession {
|
||||
// 6. 只将本轮新增消息追加到当前 slot(保留全量历史,确保 Focused 模式的"读时过滤"语义不丢失数据)
|
||||
// cycle.messages() 包含 [system_prompt?, history..., user_input, tool_calls..., final_response]
|
||||
// 新增消息 = cycle.messages()[input_len..](跳过 initial_messages,即跳过已被持久化的内容)
|
||||
let new_messages: Vec<Message> = cycle
|
||||
.messages()
|
||||
.iter()
|
||||
.skip(input_len)
|
||||
.cloned()
|
||||
.collect();
|
||||
let new_messages: Vec<Message> = cycle.messages().iter().skip(input_len).cloned().collect();
|
||||
let store = self.resolve_store();
|
||||
if let Some(slot) = self.slots.get_mut(&self.current_slot_id) {
|
||||
slot.append_messages(new_messages)?;
|
||||
@@ -432,10 +422,7 @@ impl AgentSession {
|
||||
|
||||
// 5. 调用流式工具循环
|
||||
let stream = cycle
|
||||
.submit_with_tools_stream(
|
||||
user_input.into(),
|
||||
Arc::clone(&self.bundle.tool_registry),
|
||||
)
|
||||
.submit_with_tools_stream(user_input.into(), Arc::clone(&self.bundle.tool_registry))
|
||||
.await?;
|
||||
|
||||
// 6. turn_index 递增 —— 配合 finalize_turn 用 (turn_index - 1) 传递正确的 OnTurnEnd 序号
|
||||
@@ -487,7 +474,8 @@ impl AgentSession {
|
||||
.await;
|
||||
|
||||
// Phase 16: 摘要检查点(流式路径 turn_index 已被 submit_turn_stream 提前 ++1)
|
||||
self.maybe_summarize(self.turn_index.saturating_sub(1)).await;
|
||||
self.maybe_summarize(self.turn_index.saturating_sub(1))
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -500,6 +488,7 @@ impl AgentSession {
|
||||
/// 通过 `SessionMemory::list_entries()` 获取完整条目(保留 `metadata` 和 `created_at`)。
|
||||
///
|
||||
/// `Arc<dyn Agent>` 和 `Arc<RuntimeBundle>` **不进入快照**——由 `from_snapshot()` 调用方注入。
|
||||
#[cfg(feature = "engine")]
|
||||
pub async fn to_snapshot(&self) -> SessionSnapshot {
|
||||
// 拍平 session_memory → HashMap<String, SessionMemoryEntry>
|
||||
// 失败时回退到空 map(错误已记录,不阻断 checkpoint 主流程)。
|
||||
@@ -541,6 +530,7 @@ impl AgentSession {
|
||||
/// 由调用方显式 `await session.restore_memory()` 写回持久层。
|
||||
///
|
||||
/// 调用方负责提供与 `snapshot.agent_name` 对应的 `Arc<dyn Agent>`(引擎层只保留名字做调试用)。
|
||||
#[cfg(feature = "engine")]
|
||||
pub fn from_snapshot(
|
||||
snapshot: SessionSnapshot,
|
||||
agent: Arc<dyn Agent>,
|
||||
@@ -562,11 +552,7 @@ impl AgentSession {
|
||||
if slots.is_empty() {
|
||||
slots.insert(
|
||||
"default".to_string(),
|
||||
ContextSlot::new(
|
||||
&snapshot.session_id,
|
||||
"default",
|
||||
SlotConfig::default(),
|
||||
),
|
||||
ContextSlot::new(&snapshot.session_id, "default", SlotConfig::default()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -601,6 +587,7 @@ impl AgentSession {
|
||||
///
|
||||
/// **完整恢复**:使用 `SessionMemory::set_with_meta()` 保留原始 `metadata` 和 `created_at`
|
||||
/// ——不像 `set()` 会清空 metadata 并把 created_at 设为当前时间。
|
||||
#[cfg(feature = "engine")]
|
||||
pub async fn restore_memory(&mut self) -> Result<(), EngineError> {
|
||||
// 取出 pending 并立即清空(避免重复 restore 时二次写入;幂等性保证)
|
||||
let entries = self.pending_memory_restore.take();
|
||||
@@ -611,12 +598,7 @@ impl AgentSession {
|
||||
|
||||
for (key, entry) in entries {
|
||||
self.session_memory
|
||||
.set_with_meta(
|
||||
&key,
|
||||
&entry.value,
|
||||
entry.metadata.clone(),
|
||||
entry.created_at,
|
||||
)
|
||||
.set_with_meta(&key, &entry.value, entry.metadata.clone(), entry.created_at)
|
||||
.await
|
||||
.map_err(EngineError::Agent)?;
|
||||
}
|
||||
@@ -624,6 +606,7 @@ impl AgentSession {
|
||||
}
|
||||
|
||||
/// 是否有待写回的 `session_memory_data`(`from_snapshot()` 后尚未 `restore_memory()`)。
|
||||
#[cfg(feature = "engine")]
|
||||
pub fn has_pending_memory_restore(&self) -> bool {
|
||||
self.pending_memory_restore
|
||||
.as_ref()
|
||||
@@ -677,13 +660,22 @@ impl AgentSession {
|
||||
let model = cfg.summary_model.clone();
|
||||
let prompt = cfg.summary_prompt.clone();
|
||||
|
||||
let result =
|
||||
Self::generate_summary(&provider, &messages, &prompt, model.as_deref(), max_tool_result_chars)
|
||||
.await;
|
||||
let result = Self::generate_summary(
|
||||
&provider,
|
||||
&messages,
|
||||
&prompt,
|
||||
model.as_deref(),
|
||||
max_tool_result_chars,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(text) => {
|
||||
tracing::info!(turn = current_turn, summary_len = text.len(), "摘要自动生成成功");
|
||||
tracing::info!(
|
||||
turn = current_turn,
|
||||
summary_len = text.len(),
|
||||
"摘要自动生成成功"
|
||||
);
|
||||
// Resolve store first (immutable borrow on self) before mutable borrow on slots.
|
||||
let store = self.resolve_store();
|
||||
if let Some(slot) = self.slots.get_mut(&self.current_slot_id)
|
||||
@@ -746,8 +738,8 @@ impl AgentSession {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agent::builder::AgentBuilder;
|
||||
use crate::agent::FocusedConfig;
|
||||
use crate::agent::builder::AgentBuilder;
|
||||
use crate::llm::hooks::{Hook, HookContext, HookExecutor, HookResult};
|
||||
use crate::llm::mock::MockProvider;
|
||||
use crate::llm::stream::StreamEvent;
|
||||
@@ -806,7 +798,9 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_session(provider_responses: Vec<MessageResponse>) -> (AgentSession, Arc<CountHook>, Arc<CountHook>) {
|
||||
fn build_session(
|
||||
provider_responses: Vec<MessageResponse>,
|
||||
) -> (AgentSession, Arc<CountHook>, Arc<CountHook>) {
|
||||
let mut hook_executor = HookExecutor::new();
|
||||
let start_count = Arc::new(CountHook(AtomicU32::new(0)));
|
||||
let end_count = Arc::new(CountHook(AtomicU32::new(0)));
|
||||
@@ -840,7 +834,8 @@ mod tests {
|
||||
/// 烟雾测试 1:AgentSession::submit_turn 跑通 mock provider(向后兼容)。
|
||||
#[tokio::test]
|
||||
async fn submit_turn_runs_with_mock_provider() {
|
||||
let (mut session, start_count, end_count) = build_session(vec![assistant_text("hello back")]);
|
||||
let (mut session, start_count, end_count) =
|
||||
build_session(vec![assistant_text("hello back")]);
|
||||
assert_eq!(session.turn_index(), 0);
|
||||
|
||||
let response = session.submit_turn("hi").await.unwrap();
|
||||
@@ -875,10 +870,8 @@ mod tests {
|
||||
/// 烟雾测试 3:submit_turn 触发 OnTurnStart / OnTurnEnd hook。
|
||||
#[tokio::test]
|
||||
async fn submit_turn_triggers_turn_hooks() {
|
||||
let (mut session, start_count, end_count) = build_session(vec![
|
||||
assistant_text("ok"),
|
||||
assistant_text("ok 2"),
|
||||
]);
|
||||
let (mut session, start_count, end_count) =
|
||||
build_session(vec![assistant_text("ok"), assistant_text("ok 2")]);
|
||||
|
||||
session.submit_turn("hi").await.unwrap();
|
||||
assert_eq!(start_count.0.load(Ordering::SeqCst), 1);
|
||||
@@ -931,9 +924,15 @@ mod tests {
|
||||
// 即 [user_input, tool_results?, final_response](不含 system_prompt,system 由 agent 提供)
|
||||
assert!(slot.messages.len() >= 2, "应至少包含 user 和 assistant");
|
||||
// 验证 user 输入和 assistant 响应都已写入
|
||||
let has_user = slot.messages.iter().any(|m| extract_text(m) == "user input");
|
||||
let has_user = slot
|
||||
.messages
|
||||
.iter()
|
||||
.any(|m| extract_text(m) == "user input");
|
||||
let has_resp = slot.messages.iter().any(|m| extract_text(m) == "resp");
|
||||
assert!(has_user && has_resp, "slot 应包含 user input 和 assistant response");
|
||||
assert!(
|
||||
has_user && has_resp,
|
||||
"slot 应包含 user input 和 assistant response"
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 10: create_slot 创建新 slot。
|
||||
@@ -977,7 +976,11 @@ mod tests {
|
||||
// 3. 检查 slot_a 的消息数
|
||||
let slot_a = session.slots.get("slot_a").unwrap();
|
||||
let slot_a_count = slot_a.messages.len();
|
||||
assert!(slot_a_count >= 2, "slot_a 至少 2 条消息,实际 {}", slot_a_count);
|
||||
assert!(
|
||||
slot_a_count >= 2,
|
||||
"slot_a 至少 2 条消息,实际 {}",
|
||||
slot_a_count
|
||||
);
|
||||
|
||||
// 4. 切回 default,验证 default 不包含 slot_a 的消息
|
||||
session.switch_slot("default").await.unwrap();
|
||||
@@ -1272,7 +1275,10 @@ mod tests {
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::User { .. }));
|
||||
let has_resp = slot.messages.iter().any(|m| extract_text(m) == "hi back");
|
||||
assert!(has_user && has_resp, "default slot 应包含 user 和 assistant 消息");
|
||||
assert!(
|
||||
has_user && has_resp,
|
||||
"default slot 应包含 user 和 assistant 消息"
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 9 Step 5.2 — `submit_turn_stream` 触发 OnTurnStart / OnTurnEnd hook。
|
||||
@@ -1371,7 +1377,11 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn summary_not_generated_below_threshold() {
|
||||
let mut session = build_session_with_summary(
|
||||
vec![assistant_text("a"), assistant_text("b"), assistant_text("c")],
|
||||
vec![
|
||||
assistant_text("a"),
|
||||
assistant_text("b"),
|
||||
assistant_text("c"),
|
||||
],
|
||||
vec![assistant_text("should_not_appear")],
|
||||
SummaryConfig::default(),
|
||||
);
|
||||
@@ -1391,16 +1401,20 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn summary_generated_above_threshold() {
|
||||
let mut session = build_session_with_summary(
|
||||
vec![assistant_text("a"), assistant_text("b"), assistant_text("c")],
|
||||
vec![
|
||||
assistant_text("a"),
|
||||
assistant_text("b"),
|
||||
assistant_text("c"),
|
||||
],
|
||||
vec![
|
||||
assistant_text("summary-1"),
|
||||
assistant_text("summary-2"),
|
||||
assistant_text("summary-3"),
|
||||
],
|
||||
SummaryConfig {
|
||||
max_context_tokens: 20, // 阈值 20 * 0.5 = 10
|
||||
max_context_tokens: 20, // 阈值 20 * 0.5 = 10
|
||||
trigger_token_ratio: 0.5,
|
||||
debounce_turns: 0, // 关闭防抖便于测试
|
||||
debounce_turns: 0, // 关闭防抖便于测试
|
||||
..SummaryConfig::default()
|
||||
},
|
||||
);
|
||||
@@ -1450,7 +1464,11 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn summary_written_to_session_memory_but_full_mode_does_not_inject() {
|
||||
let mut session = build_session_with_summary(
|
||||
vec![assistant_text("a"), assistant_text("b"), assistant_text("c")],
|
||||
vec![
|
||||
assistant_text("a"),
|
||||
assistant_text("b"),
|
||||
assistant_text("c"),
|
||||
],
|
||||
vec![assistant_text("captured-summary")],
|
||||
SummaryConfig {
|
||||
max_context_tokens: 20,
|
||||
@@ -1476,7 +1494,7 @@ mod tests {
|
||||
// 故意只提供 1 个对话响应;摘要调用时队列耗尽,MockProvider 返回 LlmError::Other
|
||||
let mut session = build_session_with_summary(
|
||||
vec![assistant_text("only-one")], // 后续摘要会失败
|
||||
vec![], // 无摘要响应
|
||||
vec![], // 无摘要响应
|
||||
SummaryConfig {
|
||||
max_context_tokens: 20,
|
||||
trigger_token_ratio: 0.5,
|
||||
@@ -1630,4 +1648,4 @@ mod tests {
|
||||
let summary = session.get_conversation_summary().await.unwrap();
|
||||
assert!(summary.is_none(), "巨型 max_context_tokens 应永不触发");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ impl SessionMemory {
|
||||
/// **不保留 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
|
||||
self.set_with_meta(key, value, serde_json::json!({}), None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 写入一条 key-value 条目(含完整 metadata + created_at)。
|
||||
|
||||
@@ -98,7 +98,11 @@ pub fn format_messages_as_text(messages: &[Message], max_tool_result_chars: usiz
|
||||
content,
|
||||
is_error,
|
||||
} => {
|
||||
let label = if *is_error { "Tool Error" } else { "Tool Result" };
|
||||
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));
|
||||
|
||||
+64
-14
@@ -339,7 +339,14 @@ impl RecursiveCharacterSplitter {
|
||||
let take_n = overlap.min(prev_chars_count);
|
||||
|
||||
// 字符级安全地取 prev 末尾 take_n 个字符
|
||||
let tail: String = prev.chars().rev().take(take_n).collect::<Vec<_>>().into_iter().rev().collect();
|
||||
let tail: String = prev
|
||||
.chars()
|
||||
.rev()
|
||||
.take(take_n)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
chunks[i] = format!("{}{}", tail, chunks[i]);
|
||||
}
|
||||
|
||||
@@ -408,8 +415,14 @@ mod tests {
|
||||
let chunks = splitter.split(&[doc]);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].content, "hello");
|
||||
assert_eq!(chunks[0].metadata.get("chunk_index").map(|s| s.as_str()), Some("0"));
|
||||
assert_eq!(chunks[0].metadata.get("chunk_count").map(|s| s.as_str()), Some("1"));
|
||||
assert_eq!(
|
||||
chunks[0].metadata.get("chunk_index").map(|s| s.as_str()),
|
||||
Some("0")
|
||||
);
|
||||
assert_eq!(
|
||||
chunks[0].metadata.get("chunk_count").map(|s| s.as_str()),
|
||||
Some("1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -464,7 +477,11 @@ mod tests {
|
||||
// para1 (5 chars) > chunk_size=4 → 递归降级到 char 级拆分
|
||||
// para2 同理
|
||||
// 总共应该产生多个 chunk
|
||||
assert!(chunks.len() >= 2, "expected >= 2 chunks, got {}", chunks.len());
|
||||
assert!(
|
||||
chunks.len() >= 2,
|
||||
"expected >= 2 chunks, got {}",
|
||||
chunks.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -474,10 +491,18 @@ mod tests {
|
||||
let text: String = "a".repeat(200);
|
||||
let doc = Document::from_raw("long", &text);
|
||||
let chunks = splitter.split(&[doc]);
|
||||
assert!(chunks.len() >= 3, "expected >= 3 chunks, got {}", chunks.len());
|
||||
assert!(
|
||||
chunks.len() >= 3,
|
||||
"expected >= 3 chunks, got {}",
|
||||
chunks.len()
|
||||
);
|
||||
for chunk in &chunks {
|
||||
// chunk 内容 = overlap_tail(≤5) + new_content(≤50),故 ≤ 55
|
||||
assert!(chars_len(&chunk.content) <= 55, "chunk too long: {} chars", chars_len(&chunk.content));
|
||||
assert!(
|
||||
chars_len(&chunk.content) <= 55,
|
||||
"chunk too long: {} chars",
|
||||
chars_len(&chunk.content)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,7 +513,11 @@ mod tests {
|
||||
let doc = Document::from_raw("g", "aa\n\nbb\n\ncc\n\ndd");
|
||||
let chunks = splitter.split(&[doc]);
|
||||
// 短段应被合并:总共应该少于 4 个 chunk
|
||||
assert!(chunks.len() <= 3, "expected <= 3 chunks after merge, got {}", chunks.len());
|
||||
assert!(
|
||||
chunks.len() <= 3,
|
||||
"expected <= 3 chunks after merge, got {}",
|
||||
chunks.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -538,11 +567,19 @@ mod tests {
|
||||
let doc = Document::from_raw("cjk", &text);
|
||||
let chunks = splitter.split(&[doc]);
|
||||
// 30 字符 / 10 chunk_size = 3 个 chunk
|
||||
assert!(chunks.len() >= 3, "expected >= 3 chunks for 30 chars / chunk_size=10, got {}", chunks.len());
|
||||
assert!(
|
||||
chunks.len() >= 3,
|
||||
"expected >= 3 chunks for 30 chars / chunk_size=10, got {}",
|
||||
chunks.len()
|
||||
);
|
||||
for chunk in &chunks {
|
||||
let char_count = chars_len(&chunk.content);
|
||||
// chunk = overlap_tail(≤2) + new_content(≤10),故 ≤ 12
|
||||
assert!(char_count <= 12, "chunk char count {} exceeds 10+overlap", char_count);
|
||||
assert!(
|
||||
char_count <= 12,
|
||||
"chunk char count {} exceeds 10+overlap",
|
||||
char_count
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,12 +606,25 @@ mod tests {
|
||||
fn split_metadata_inheritance() {
|
||||
let splitter = RecursiveCharacterSplitter::new(100, 10);
|
||||
let mut doc = Document::new("m", "short content", "text/plain");
|
||||
doc.metadata.insert("author".to_string(), "alice".to_string());
|
||||
doc.metadata
|
||||
.insert("author".to_string(), "alice".to_string());
|
||||
let chunks = splitter.split(&[doc]);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].metadata.get("author").map(|s| s.as_str()), Some("alice"));
|
||||
assert_eq!(chunks[0].metadata.get("source_id").map(|s| s.as_str()), Some("m"));
|
||||
assert_eq!(chunks[0].metadata.get("chunk_index").map(|s| s.as_str()), Some("0"));
|
||||
assert_eq!(chunks[0].metadata.get("chunk_count").map(|s| s.as_str()), Some("1"));
|
||||
assert_eq!(
|
||||
chunks[0].metadata.get("author").map(|s| s.as_str()),
|
||||
Some("alice")
|
||||
);
|
||||
assert_eq!(
|
||||
chunks[0].metadata.get("source_id").map(|s| s.as_str()),
|
||||
Some("m")
|
||||
);
|
||||
assert_eq!(
|
||||
chunks[0].metadata.get("chunk_index").map(|s| s.as_str()),
|
||||
Some("0")
|
||||
);
|
||||
assert_eq!(
|
||||
chunks[0].metadata.get("chunk_count").map(|s| s.as_str()),
|
||||
Some("1")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::agent::session::AgentSession;
|
||||
use crate::engine::snapshot::SessionSnapshot;
|
||||
use crate::engine::EngineError;
|
||||
use crate::engine::snapshot::SessionSnapshot;
|
||||
use crate::memory::store::MemoryStore;
|
||||
use crate::memory::types::{MemoryFilter, MemoryItem};
|
||||
|
||||
@@ -80,9 +80,8 @@ impl Checkpointer {
|
||||
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 json = serde_json::to_string(&snapshot)
|
||||
.map_err(|e| EngineError::Serialization(format!("snapshot serialize failed: {e}")))?;
|
||||
|
||||
let item = MemoryItem {
|
||||
id: key,
|
||||
@@ -141,10 +140,7 @@ impl Checkpointer {
|
||||
/// 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> {
|
||||
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),
|
||||
@@ -346,10 +342,7 @@ mod tests {
|
||||
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();
|
||||
session.set_session_data("v", "2").await.unwrap();
|
||||
cp.checkpoint(&session).await.unwrap();
|
||||
|
||||
let latest = cp.latest_snapshot("latest-session").await.unwrap().unwrap();
|
||||
@@ -374,4 +367,4 @@ mod tests {
|
||||
assert_eq!(metas_a[0].session_id, "iso-a");
|
||||
assert_eq!(metas_b[0].session_id, "iso-b");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -48,4 +48,4 @@ pub enum EngineError {
|
||||
/// 调用方收到此错误时,子 session 已通过 `destroy()` 清理(SessionMeta + checkpoint 全部清空)。
|
||||
#[error("Dispatch failed: {0}")]
|
||||
DispatchFailed(String),
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,4 +20,4 @@ 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};
|
||||
pub use sub_agent::{DispatchConfig, SubTaskResult, SubTaskStreamEvent};
|
||||
|
||||
@@ -141,11 +141,11 @@ impl SessionManager {
|
||||
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?;
|
||||
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| {
|
||||
@@ -241,10 +241,7 @@ impl SessionManager {
|
||||
/// 按 ID 获取 session(**仅查内存**,不自动从存储恢复)。
|
||||
///
|
||||
/// 冷启动时 `get()` 未命中返回 `SessionNotFound`。如需从存储恢复,使用 `recover()` 方法。
|
||||
pub async fn get(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<Arc<Mutex<AgentSession>>, EngineError> {
|
||||
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!(
|
||||
@@ -392,12 +389,7 @@ impl SessionManager {
|
||||
session_id: &str,
|
||||
user_input: impl Into<String>,
|
||||
) -> Result<
|
||||
std::pin::Pin<
|
||||
Box<
|
||||
dyn futures_core::Stream<Item = crate::llm::stream::StreamEvent>
|
||||
+ Send,
|
||||
>,
|
||||
>,
|
||||
std::pin::Pin<Box<dyn futures_core::Stream<Item = crate::llm::stream::StreamEvent> + Send>>,
|
||||
EngineError,
|
||||
> {
|
||||
let session = self.get(session_id).await?;
|
||||
@@ -593,8 +585,14 @@ mod tests {
|
||||
.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.checkpointer()
|
||||
.checkpoint(&*session.lock().await)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
sm.checkpointer().list_checkpoints(&id).await.unwrap().len(),
|
||||
1
|
||||
);
|
||||
|
||||
sm.destroy(&id).await.unwrap();
|
||||
|
||||
@@ -603,7 +601,10 @@ mod tests {
|
||||
// SessionMeta 已删除
|
||||
assert!(sm.load_session_meta(&id).await.unwrap().is_none());
|
||||
// Checkpoint 已删除
|
||||
assert_eq!(sm.checkpointer().list_checkpoints(&id).await.unwrap().len(), 0);
|
||||
assert_eq!(
|
||||
sm.checkpointer().list_checkpoints(&id).await.unwrap().len(),
|
||||
0
|
||||
);
|
||||
|
||||
// destroy 不存在的 session 不报错
|
||||
sm.destroy(&id).await.unwrap();
|
||||
@@ -682,7 +683,10 @@ mod tests {
|
||||
{
|
||||
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();
|
||||
sm.checkpointer()
|
||||
.checkpoint(&*s.lock().await)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 2. 模拟"进程重启"——清空内存但保留 store
|
||||
@@ -710,7 +714,10 @@ mod tests {
|
||||
.unwrap();
|
||||
{
|
||||
let s = sm.get(&id).await.unwrap();
|
||||
sm.checkpointer().checkpoint(&*s.lock().await).await.unwrap();
|
||||
sm.checkpointer()
|
||||
.checkpoint(&*s.lock().await)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let err = sm
|
||||
@@ -750,7 +757,10 @@ mod tests {
|
||||
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),
|
||||
other => panic!(
|
||||
"expected EngineError::Agent(AgentError::Llm), got {:?}",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,8 +782,14 @@ mod tests {
|
||||
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);
|
||||
sm.checkpointer()
|
||||
.checkpoint(&*s.lock().await)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
sm.checkpointer().list_checkpoints(&id).await.unwrap().len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -787,7 +803,8 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
// 构造一个新 session(同 session_id)然后 replace
|
||||
let mut new_session = AgentSession::new(Arc::new(StubAgent("a".into())), &id, make_bundle());
|
||||
let mut new_session =
|
||||
AgentSession::new(Arc::new(StubAgent("a".into())), &id, make_bundle());
|
||||
new_session
|
||||
.set_session_data("replaced", "yes")
|
||||
.await
|
||||
@@ -838,8 +855,7 @@ mod tests {
|
||||
/// 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 store: Arc<dyn MemoryStore> = Arc::new(crate::memory::store::InMemoryStore::new());
|
||||
let sm = SessionManager::new(store.clone());
|
||||
|
||||
let id = sm
|
||||
@@ -848,12 +864,11 @@ mod tests {
|
||||
.unwrap();
|
||||
{
|
||||
let s = sm.get(&id).await.unwrap();
|
||||
s.lock()
|
||||
.await
|
||||
.set_session_data("k", "v")
|
||||
s.lock().await.set_session_data("k", "v").await.unwrap();
|
||||
sm.checkpointer()
|
||||
.checkpoint(&*s.lock().await)
|
||||
.await
|
||||
.unwrap();
|
||||
sm.checkpointer().checkpoint(&*s.lock().await).await.unwrap();
|
||||
}
|
||||
|
||||
// 模拟"进程重启"——新建 SessionManager,复用 store
|
||||
@@ -877,8 +892,7 @@ mod tests {
|
||||
/// 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 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);
|
||||
@@ -905,4 +919,4 @@ mod tests {
|
||||
assert!(sm.get(id).await.is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,4 +36,4 @@ pub struct SessionSnapshot {
|
||||
pub last_summary_turn: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub session_memory_data: std::collections::HashMap<String, SessionMemoryEntry>,
|
||||
}
|
||||
}
|
||||
|
||||
+23
-22
@@ -77,10 +77,7 @@ pub enum SubTaskStreamEvent {
|
||||
/// 执行完成,携带完整结果。
|
||||
Completed(SubTaskResult),
|
||||
/// 流式调度中的错误。
|
||||
Error {
|
||||
child_id: String,
|
||||
error: String,
|
||||
},
|
||||
Error { child_id: String, error: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SubTaskStreamEvent {
|
||||
@@ -150,7 +147,7 @@ impl SessionManager {
|
||||
|
||||
// 3. 过滤(按 bridge_keys)
|
||||
let filtered: Vec<_> = match &config.bridge_keys {
|
||||
None => Vec::new(), // None = 不继承任何
|
||||
None => Vec::new(), // None = 不继承任何
|
||||
Some(keys) if keys.is_empty() => entries, // 空列表 = 全部继承
|
||||
Some(keys) => entries
|
||||
.into_iter()
|
||||
@@ -343,9 +340,7 @@ impl SessionManager {
|
||||
task: impl Into<String>,
|
||||
config: DispatchConfig,
|
||||
) -> Result<
|
||||
std::pin::Pin<
|
||||
Box<dyn futures_core::Stream<Item = SubTaskStreamEvent> + Send>,
|
||||
>,
|
||||
std::pin::Pin<Box<dyn futures_core::Stream<Item = SubTaskStreamEvent> + Send>>,
|
||||
EngineError,
|
||||
> {
|
||||
use futures_util::StreamExt;
|
||||
@@ -473,9 +468,7 @@ impl SessionManager {
|
||||
}
|
||||
};
|
||||
let mut guard = session.lock().await;
|
||||
guard
|
||||
.finalize_turn(response, new_messages)
|
||||
.await
|
||||
guard.finalize_turn(response, new_messages).await
|
||||
};
|
||||
|
||||
if let Err(e) = lock_result {
|
||||
@@ -575,8 +568,7 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn make_manager_and_bundle() -> (Arc<SessionManager>, Arc<RuntimeBundle>) {
|
||||
let store: Arc<dyn crate::memory::store::MemoryStore> =
|
||||
Arc::new(InMemoryStore::new());
|
||||
let store: Arc<dyn crate::memory::store::MemoryStore> = Arc::new(InMemoryStore::new());
|
||||
let provider = Arc::new(MockProvider::new(vec![
|
||||
assistant_text("child response 1"),
|
||||
assistant_text("child response 2"),
|
||||
@@ -702,7 +694,12 @@ mod tests {
|
||||
|
||||
// dispatch 到不存在的 parent_id
|
||||
let result = sm
|
||||
.dispatch("nonexistent_parent", child, "task", DispatchConfig::default())
|
||||
.dispatch(
|
||||
"nonexistent_parent",
|
||||
child,
|
||||
"task",
|
||||
DispatchConfig::default(),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
|
||||
@@ -716,7 +713,12 @@ mod tests {
|
||||
let (sm, _bundle) = make_manager_and_bundle().await;
|
||||
let child: Arc<dyn Agent> = Arc::new(MockAgent::new("child"));
|
||||
let result = sm
|
||||
.dispatch("nonexistent_parent", child, "task", DispatchConfig::default())
|
||||
.dispatch(
|
||||
"nonexistent_parent",
|
||||
child,
|
||||
"task",
|
||||
DispatchConfig::default(),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result, Err(EngineError::SessionNotFound(_))));
|
||||
}
|
||||
@@ -724,9 +726,10 @@ mod tests {
|
||||
// ====== dispatch_all 测试 ======
|
||||
|
||||
/// 提供充足的 mock response(>= 3)
|
||||
async fn make_manager_and_bundle_for_all(n: usize) -> (Arc<SessionManager>, Arc<RuntimeBundle>) {
|
||||
let store: Arc<dyn crate::memory::store::MemoryStore> =
|
||||
Arc::new(InMemoryStore::new());
|
||||
async fn make_manager_and_bundle_for_all(
|
||||
n: usize,
|
||||
) -> (Arc<SessionManager>, Arc<RuntimeBundle>) {
|
||||
let store: Arc<dyn crate::memory::store::MemoryStore> = Arc::new(InMemoryStore::new());
|
||||
let responses: Vec<_> = (0..n)
|
||||
.map(|i| assistant_text(&format!("response {i}")))
|
||||
.collect();
|
||||
@@ -747,8 +750,7 @@ mod tests {
|
||||
/// 创建空 mock responses 的 manager 和 bundle —— 后续 dispatch 会触发
|
||||
/// "MockProvider: 预设响应已用完" 错误,可用于测试错误传播。
|
||||
async fn make_manager_and_bundle_empty_mock() -> (Arc<SessionManager>, Arc<RuntimeBundle>) {
|
||||
let store: Arc<dyn crate::memory::store::MemoryStore> =
|
||||
Arc::new(InMemoryStore::new());
|
||||
let store: Arc<dyn crate::memory::store::MemoryStore> = Arc::new(InMemoryStore::new());
|
||||
let provider = Arc::new(MockProvider::empty());
|
||||
let bundle = Arc::new(
|
||||
AgentBuilder::new()
|
||||
@@ -1048,8 +1050,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// 验证:收到 Error 事件
|
||||
let (err_child_id, err_message) =
|
||||
error_received.expect("Error event should be received");
|
||||
let (err_child_id, err_message) = error_received.expect("Error event should be received");
|
||||
assert_eq!(
|
||||
Some(err_child_id.as_str()),
|
||||
child_id_from_event.as_deref(),
|
||||
|
||||
@@ -129,8 +129,7 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn make_manager_and_bundle() -> (Arc<SessionManager>, Arc<RuntimeBundle>) {
|
||||
let store: Arc<dyn crate::memory::store::MemoryStore> =
|
||||
Arc::new(InMemoryStore::new());
|
||||
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"),
|
||||
@@ -175,10 +174,7 @@ mod tests {
|
||||
{
|
||||
let session = sm.get(&sid).await.unwrap();
|
||||
let mut guard = session.lock().await;
|
||||
guard
|
||||
.set_session_data("key1", "value1")
|
||||
.await
|
||||
.unwrap();
|
||||
guard.set_session_data("key1", "value1").await.unwrap();
|
||||
}
|
||||
sm.submit_turn(&sid, "hello").await.unwrap();
|
||||
|
||||
|
||||
+12
@@ -1,19 +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 =
|
||||
|
||||
+26
-2
@@ -1,12 +1,36 @@
|
||||
//! 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"
|
||||
))]
|
||||
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};
|
||||
|
||||
+109
-80
@@ -15,17 +15,18 @@ use serde_json::Value;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::llm::compact::{CompactConfig, CompactState, microcompact, should_compact};
|
||||
use crate::llm::cycle::retry::should_retry;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::hooks::{HookContext, HookEvent, HookExecutor};
|
||||
use crate::llm::provider::LlmProvider;
|
||||
use crate::llm::stream::StreamEvent;
|
||||
use crate::llm::types::ToolChoice;
|
||||
use crate::llm::types::message::{ContentBlock, Message};
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, PartialMessageResponse, StopReason};
|
||||
use crate::llm::types::tool::ToolDef;
|
||||
use crate::llm::types::ToolChoice;
|
||||
#[cfg(feature = "tools")]
|
||||
use crate::tools::ToolRegistry;
|
||||
|
||||
/// LLM 调用周期配置。
|
||||
@@ -451,10 +452,7 @@ impl LlmCycle {
|
||||
/// 内部请求方法(与 `submit` 共享重试逻辑,但不 push user message 和 Assistant 响应)。
|
||||
///
|
||||
/// 用于 `submit_with_tools()` 的多轮 tool 循环。
|
||||
async fn submit_request(
|
||||
&mut self,
|
||||
tools: &[ToolDef],
|
||||
) -> Result<MessageResponse, LlmError> {
|
||||
async fn submit_request(&mut self, tools: &[ToolDef]) -> Result<MessageResponse, LlmError> {
|
||||
let mut attempts = 0;
|
||||
|
||||
loop {
|
||||
@@ -528,6 +526,7 @@ impl LlmCycle {
|
||||
///
|
||||
/// 注意:OpenAI API 要求 tool 消息必须紧跟在对应的 Assistant(tool_calls)消息之后。
|
||||
/// 因此 push 工具结果前必须先 push Assistant 响应,否则 API 拒绝请求。
|
||||
#[cfg(feature = "tools")]
|
||||
pub async fn submit_with_tools(
|
||||
&mut self,
|
||||
prompt: String,
|
||||
@@ -631,6 +630,7 @@ impl LlmCycle {
|
||||
/// 直接调用模块函数 `run_tool_loop`。
|
||||
///
|
||||
/// ponytail: 返回的流是 `Item = StreamEvent`(非 `Result`),所有错误事件化为 `StreamEvent::Error`。
|
||||
#[cfg(feature = "tools")]
|
||||
pub async fn submit_with_tools_stream(
|
||||
&mut self,
|
||||
prompt: String,
|
||||
@@ -737,6 +737,7 @@ fn truncate_tool_result(s: &str, max_bytes: usize) -> String {
|
||||
///
|
||||
/// **所有错误事件化**:通过 `tx.send(Error{..})` 表达错误,最终 `return` 结束 task。
|
||||
/// 不返回 `Result`,因为错误已通过事件流传递。
|
||||
#[cfg(feature = "tools")]
|
||||
async fn run_tool_loop(
|
||||
mut messages: Vec<Message>,
|
||||
provider: Arc<dyn LlmProvider>,
|
||||
@@ -909,11 +910,10 @@ async fn run_tool_loop(
|
||||
Ok(v) => {
|
||||
// ponytail: 与 submit_with_tools 行为对齐 —— 用 truncate_tool_result
|
||||
// 截断结果以防止超大工具输出在 tool 循环中膨胀 messages 上下文窗口
|
||||
let serialized =
|
||||
serde_json::to_string(v).unwrap_or_else(|e| {
|
||||
tracing::warn!("工具结果序列化失败: {}", e);
|
||||
"{}".to_string()
|
||||
});
|
||||
let serialized = serde_json::to_string(v).unwrap_or_else(|e| {
|
||||
tracing::warn!("工具结果序列化失败: {}", e);
|
||||
"{}".to_string()
|
||||
});
|
||||
truncate_tool_result(&serialized, max_bytes)
|
||||
}
|
||||
Err(e) if e.is_recoverable() => format!("错误: {}", e),
|
||||
@@ -933,7 +933,7 @@ async fn run_tool_loop(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::provider::{ProviderCapabilities, ProviderFeatures};
|
||||
use crate::llm::{ProviderCapabilities, ProviderFeatures};
|
||||
use crate::tools::{BaseTool, ToolRegistry};
|
||||
use async_trait::async_trait;
|
||||
use futures_core::Stream;
|
||||
@@ -1200,8 +1200,7 @@ mod tests {
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_submit_with_tools_stream_pure_text() {
|
||||
let provider = Mock::new(vec![assistant_text_response("你好")]);
|
||||
let mut cycle =
|
||||
LlmCycle::new(Box::new(provider), CycleConfig::default());
|
||||
let mut cycle = LlmCycle::new(Box::new(provider), CycleConfig::default());
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(std::sync::Arc::new(AddTool)).unwrap();
|
||||
|
||||
@@ -1212,23 +1211,36 @@ mod tests {
|
||||
let events = drain(stream).await;
|
||||
|
||||
// 期望序列:MessageStart → ContentBlockStart(Text) → TextDelta → ContentBlockEnd → CostUpdate → MessageComplete
|
||||
assert!(matches!(events.first(), Some(StreamEvent::MessageStart { .. })));
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::TextDelta { text } if text == "你好")));
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::MessageComplete { .. })));
|
||||
assert!(matches!(
|
||||
events.first(),
|
||||
Some(StreamEvent::MessageStart { .. })
|
||||
));
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::TextDelta { text } if text == "你好"))
|
||||
);
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::MessageComplete { .. }))
|
||||
);
|
||||
// 纯文本流不应有 ToolExecutionStarted/Completed 事件
|
||||
assert!(!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::ToolExecutionStarted { .. })));
|
||||
assert!(!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::ToolExecutionCompleted { .. })));
|
||||
assert!(!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::Error { .. })));
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::ToolExecutionStarted { .. }))
|
||||
);
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::ToolExecutionCompleted { .. }))
|
||||
);
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::Error { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 9 测试 3.2 — 单轮工具调用
|
||||
@@ -1238,8 +1250,7 @@ mod tests {
|
||||
assistant_tool_call_response(vec![("call_1", "add", r#"{"a":1,"b":2}"#)]),
|
||||
assistant_text_response("答案是 3"),
|
||||
]);
|
||||
let mut cycle =
|
||||
LlmCycle::new(Box::new(provider), CycleConfig::default());
|
||||
let mut cycle = LlmCycle::new(Box::new(provider), CycleConfig::default());
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(std::sync::Arc::new(AddTool)).unwrap();
|
||||
|
||||
@@ -1316,8 +1327,7 @@ mod tests {
|
||||
assistant_tool_call_response(vec![("call_3", "add", r#"{"a":5,"b":6}"#)]),
|
||||
assistant_text_response("完成"),
|
||||
]);
|
||||
let mut cycle =
|
||||
LlmCycle::new(Box::new(provider), CycleConfig::default());
|
||||
let mut cycle = LlmCycle::new(Box::new(provider), CycleConfig::default());
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry.register(std::sync::Arc::new(AddTool)).unwrap();
|
||||
|
||||
@@ -1377,8 +1387,11 @@ mod tests {
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
error_events.iter().any(|m| m.contains("达到最大工具循环轮次")),
|
||||
"应包含最大轮次超限 Error,实际: {:?}", error_events
|
||||
error_events
|
||||
.iter()
|
||||
.any(|m| m.contains("达到最大工具循环轮次")),
|
||||
"应包含最大轮次超限 Error,实际: {:?}",
|
||||
error_events
|
||||
);
|
||||
|
||||
// 工具调用次数应 ≤ 2
|
||||
@@ -1394,7 +1407,7 @@ mod tests {
|
||||
/// 使用自定义 MockProvider 返回 chat_stream Err。
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_submit_with_tools_stream_chat_stream_err() {
|
||||
use crate::llm::provider::{ProviderCapabilities, ProviderFeatures};
|
||||
use crate::llm::{ProviderCapabilities, ProviderFeatures};
|
||||
|
||||
struct ErrProvider;
|
||||
#[async_trait]
|
||||
@@ -1405,10 +1418,8 @@ mod tests {
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
_r: MessageRequest,
|
||||
) -> Result<
|
||||
Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>,
|
||||
LlmError,
|
||||
> {
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
|
||||
{
|
||||
Err(LlmError::Other("网络错误".into()))
|
||||
}
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
@@ -1432,25 +1443,29 @@ mod tests {
|
||||
|
||||
// 第一个事件应是 Error(chat_stream Err 立即事件化)
|
||||
assert!(
|
||||
events.first().map(|e| matches!(e, StreamEvent::Error { .. }))
|
||||
events
|
||||
.first()
|
||||
.map(|e| matches!(e, StreamEvent::Error { .. }))
|
||||
== Some(true),
|
||||
"流中首个事件应是 Error,实际: {:?}", events.first()
|
||||
"流中首个事件应是 Error,实际: {:?}",
|
||||
events.first()
|
||||
);
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
StreamEvent::Error { message } => Some(message.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.any(|m| m.contains("网络错误"))
|
||||
);
|
||||
assert!(events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
StreamEvent::Error { message } => Some(message.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.any(|m| m.contains("网络错误")));
|
||||
}
|
||||
|
||||
/// Phase 9 测试 3.6 — 空 tool_registry
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_submit_with_tools_stream_empty_registry() {
|
||||
let provider = Mock::new(vec![assistant_text_response("纯文本回答")]);
|
||||
let mut cycle =
|
||||
LlmCycle::new(Box::new(provider), CycleConfig::default());
|
||||
let mut cycle = LlmCycle::new(Box::new(provider), CycleConfig::default());
|
||||
let registry = ToolRegistry::new();
|
||||
|
||||
let stream = cycle
|
||||
@@ -1460,18 +1475,26 @@ mod tests {
|
||||
let events = drain(stream).await;
|
||||
|
||||
// 流退化为纯文本流 —— 无 ToolExecution 事件,无 Error
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::TextDelta { text } if text == "纯文本回答")));
|
||||
assert!(!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::ToolExecutionStarted { .. })));
|
||||
assert!(!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::ToolExecutionCompleted { .. })));
|
||||
assert!(!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::Error { .. })));
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::TextDelta { text } if text == "纯文本回答"))
|
||||
);
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::ToolExecutionStarted { .. }))
|
||||
);
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::ToolExecutionCompleted { .. }))
|
||||
);
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::Error { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 9 测试 3.7 — 不可恢复工具错误
|
||||
@@ -1503,8 +1526,7 @@ mod tests {
|
||||
assistant_tool_call_response(vec![("call_x", "fail_unrecoverable", "{}")]),
|
||||
assistant_text_response("忽略"),
|
||||
]);
|
||||
let mut cycle =
|
||||
LlmCycle::new(Box::new(provider), CycleConfig::default());
|
||||
let mut cycle = LlmCycle::new(Box::new(provider), CycleConfig::default());
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry
|
||||
.register(std::sync::Arc::new(UnrecoverableTool))
|
||||
@@ -1562,8 +1584,7 @@ mod tests {
|
||||
assistant_tool_call_response(vec![("call_y", "fail_recoverable", "{}")]),
|
||||
assistant_text_response("已恢复"),
|
||||
]);
|
||||
let mut cycle =
|
||||
LlmCycle::new(Box::new(provider), CycleConfig::default());
|
||||
let mut cycle = LlmCycle::new(Box::new(provider), CycleConfig::default());
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry
|
||||
.register(std::sync::Arc::new(RecoverableTool))
|
||||
@@ -1576,9 +1597,11 @@ mod tests {
|
||||
let events = drain(stream).await;
|
||||
|
||||
// 可恢复错误:tool_result 回传 LLM,最终流正常结束
|
||||
assert!(!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::Error { .. })));
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, StreamEvent::Error { .. }))
|
||||
);
|
||||
// 最终 MessageComplete 应是 Stop(不是 ToolUse)
|
||||
let final_response = events
|
||||
.iter()
|
||||
@@ -1598,7 +1621,10 @@ mod tests {
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(completed, "可恢复错误的 ToolExecutionCompleted.is_error 应为 true");
|
||||
assert!(
|
||||
completed,
|
||||
"可恢复错误的 ToolExecutionCompleted.is_error 应为 true"
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 9 测试 3.9 — 工具超时
|
||||
@@ -1647,9 +1673,7 @@ mod tests {
|
||||
]);
|
||||
let mut cycle = LlmCycle::new(Box::new(provider), config);
|
||||
let mut registry = ToolRegistry::new();
|
||||
registry
|
||||
.register(std::sync::Arc::new(SlowTool))
|
||||
.unwrap();
|
||||
registry.register(std::sync::Arc::new(SlowTool)).unwrap();
|
||||
|
||||
let stream = cycle
|
||||
.submit_with_tools_stream("test".to_string(), std::sync::Arc::new(registry))
|
||||
@@ -1669,8 +1693,14 @@ mod tests {
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!tool_completed.is_empty(), "应有 ToolExecutionCompleted 事件");
|
||||
assert!(tool_completed[0].0, "超时后 ToolExecutionCompleted.is_error 应为 true");
|
||||
assert!(
|
||||
!tool_completed.is_empty(),
|
||||
"应有 ToolExecutionCompleted 事件"
|
||||
);
|
||||
assert!(
|
||||
tool_completed[0].0,
|
||||
"超时后 ToolExecutionCompleted.is_error 应为 true"
|
||||
);
|
||||
assert_eq!(tool_completed[0].1, "slow_tool");
|
||||
|
||||
// 2. 流中应有不可恢复错误终止事件(tool_timeout → McpTimeout → 不可恢复 → Error)
|
||||
@@ -1682,10 +1712,9 @@ mod tests {
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
error_events.iter().any(|m| m.contains("不可恢复错误")),
|
||||
"应有不可恢复错误事件终止流,实际事件: {:?}",
|
||||
error_events
|
||||
.iter()
|
||||
.any(|m| m.contains("不可恢复错误")),
|
||||
"应有不可恢复错误事件终止流,实际事件: {:?}", error_events
|
||||
);
|
||||
|
||||
// 3. 第一轮的 MessageComplete { stop_reason: ToolUse } 在 Error 之前已发出
|
||||
|
||||
+11
-6
@@ -151,17 +151,18 @@ mod tests {
|
||||
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);
|
||||
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 r1 = embedder.embed(&["hello world".to_string()]).await.unwrap();
|
||||
let r2 = embedder
|
||||
.embed(&["completely different".to_string()])
|
||||
.await
|
||||
@@ -178,6 +179,10 @@ mod tests {
|
||||
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);
|
||||
assert!(
|
||||
(norm - 1.0).abs() < 1e-5,
|
||||
"empty-string vector norm should be ~1.0, got {}",
|
||||
norm
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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`]。
|
||||
///
|
||||
|
||||
+10
-60
@@ -4,16 +4,17 @@ pub mod openai;
|
||||
pub mod openai_compat;
|
||||
pub mod registry;
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_core::Stream;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
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 中使用。
|
||||
///
|
||||
@@ -255,63 +256,12 @@ pub fn create_provider(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>;
|
||||
|
||||
/// 流式聊天请求 —— 返回新 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;
|
||||
}
|
||||
/// 详见 `docs/27-step3-phase26-ci-verification.md` §3.2 工作 0 + ADR-1。
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -18,7 +18,6 @@ use serde::{Deserialize, Serialize};
|
||||
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 +25,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` 兜底值。
|
||||
///
|
||||
|
||||
@@ -11,10 +11,10 @@ use futures_core::Stream;
|
||||
use reqwest::Client;
|
||||
|
||||
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};
|
||||
use crate::llm::{LlmProvider, ProviderCapabilities};
|
||||
|
||||
/// Ollama 本地 Provider —— OpenAI-compatible 协议的 newtype 包装。
|
||||
///
|
||||
|
||||
@@ -21,7 +21,6 @@ use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
use crate::llm::convert::{from_openai, to_openai};
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::types::message::{ContentBlock, ContentBlockType, Message};
|
||||
@@ -32,6 +31,7 @@ use crate::llm::types::response_v2::{
|
||||
};
|
||||
use crate::llm::types::shared::{FinishReason, ResponseFormat, ServiceTier, StopSequence};
|
||||
use crate::llm::types::tool::{OpenaiToolCall, OpenaiToolDefinition, ToolChoice};
|
||||
use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||
use serde::Deserialize;
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -15,12 +15,11 @@ use std::pin::Pin;
|
||||
use async_trait::async_trait;
|
||||
use futures_core::Stream;
|
||||
|
||||
use super::ProviderCapabilities;
|
||||
use super::openai::GenericOpenaiProvider;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::LlmProvider;
|
||||
use crate::llm::types::request_v2::MessageRequest;
|
||||
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
|
||||
use crate::llm::{LlmProvider, ProviderCapabilities};
|
||||
|
||||
// =============================================================================
|
||||
// DeepSeek
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::provider::{LlmProvider, ProviderConfig, ProviderType, create_provider};
|
||||
use crate::llm::provider::{ProviderConfig, ProviderType, create_provider};
|
||||
|
||||
/// Provider 注册表 —— 管理多个 LLM Provider 实例。
|
||||
///
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -232,10 +232,16 @@ mod tests {
|
||||
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].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 字段");
|
||||
assert!(
|
||||
!json.contains("strict"),
|
||||
"ToolDef 序列化不应包含 strict 字段"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +372,8 @@ impl PartialMessageResponse {
|
||||
}
|
||||
// 元事件:不参与内容块累积,不修改 partial 状态
|
||||
//(Phase 9 —— 工具执行透明化,由 run_tool_loop 在工具前后插入)
|
||||
StreamEvent::ToolExecutionStarted { .. } | StreamEvent::ToolExecutionCompleted { .. } => true,
|
||||
StreamEvent::ToolExecutionStarted { .. }
|
||||
| StreamEvent::ToolExecutionCompleted { .. } => true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,9 +63,7 @@ impl CostTracker {
|
||||
|
||||
impl From<Usage> for CostTracker {
|
||||
fn from(usage: Usage) -> Self {
|
||||
CostTracker {
|
||||
accumulated: usage,
|
||||
}
|
||||
CostTracker { accumulated: usage }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -13,10 +13,14 @@ pub mod vector_store;
|
||||
// 高频类型(大多数下游需要)
|
||||
pub use conversation::{ConversationMemory, ConversationMemoryConfig};
|
||||
pub use error::MemoryError;
|
||||
pub use graph::{GraphEntity, GraphRelation, InMemoryGraph, KnowledgeGraph, RelationDirection, ScoredEntity};
|
||||
pub use graph::{
|
||||
GraphEntity, GraphRelation, InMemoryGraph, KnowledgeGraph, RelationDirection, ScoredEntity,
|
||||
};
|
||||
pub use knowledge::KnowledgeStore;
|
||||
pub use retriever::MemoryRetriever;
|
||||
pub use store::{InMemoryStore, MemoryStore, SqliteStore};
|
||||
#[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};
|
||||
|
||||
+75
-79
@@ -35,7 +35,11 @@ pub struct GraphEntity {
|
||||
|
||||
impl GraphEntity {
|
||||
/// 创建一个最小实体(仅 id + name + type,其余为空)。
|
||||
pub fn new(id: impl Into<String>, name: impl Into<String>, entity_type: impl Into<String>) -> Self {
|
||||
pub fn new(
|
||||
id: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
entity_type: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
@@ -81,7 +85,10 @@ impl GraphRelation {
|
||||
|
||||
/// 复合键:`source_id:target_id:relation_type`,用于去重和查找。
|
||||
pub fn composite_key(&self) -> String {
|
||||
format!("{}:{}:{}", self.source_id, self.target_id, self.relation_type)
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
self.source_id, self.target_id, self.relation_type
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,9 +260,10 @@ impl KnowledgeGraph for InMemoryGraph {
|
||||
if entity.id.is_empty() {
|
||||
return Err(MemoryError::InvalidInput("entity id is empty".into()));
|
||||
}
|
||||
let mut inner = self.inner.lock().map_err(|e| {
|
||||
MemoryError::RetrievalError(format!("lock poisoned: {e}"))
|
||||
})?;
|
||||
let mut inner = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|e| MemoryError::RetrievalError(format!("lock poisoned: {e}")))?;
|
||||
// upsert:若已存在,先收集旧标签用于清理反向引用(避免同时借用 entities 和 tag_index)
|
||||
let old_tags: Vec<String> = inner
|
||||
.entities
|
||||
@@ -283,16 +291,18 @@ impl KnowledgeGraph for InMemoryGraph {
|
||||
}
|
||||
|
||||
async fn get_entity(&self, id: &str) -> Result<Option<GraphEntity>, MemoryError> {
|
||||
let inner = self.inner.lock().map_err(|e| {
|
||||
MemoryError::RetrievalError(format!("lock poisoned: {e}"))
|
||||
})?;
|
||||
let inner = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|e| MemoryError::RetrievalError(format!("lock poisoned: {e}")))?;
|
||||
Ok(inner.entities.get(id).cloned())
|
||||
}
|
||||
|
||||
async fn remove_entity(&self, id: &str) -> Result<(), MemoryError> {
|
||||
let mut inner = self.inner.lock().map_err(|e| {
|
||||
MemoryError::RetrievalError(format!("lock poisoned: {e}"))
|
||||
})?;
|
||||
let mut inner = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|e| MemoryError::RetrievalError(format!("lock poisoned: {e}")))?;
|
||||
// 移除实体并清理其标签反向引用
|
||||
if let Some(entity) = inner.entities.remove(id) {
|
||||
for tag in &entity.tags {
|
||||
@@ -312,9 +322,10 @@ impl KnowledgeGraph for InMemoryGraph {
|
||||
}
|
||||
|
||||
async fn add_relation(&self, relation: GraphRelation) -> Result<(), MemoryError> {
|
||||
let mut inner = self.inner.lock().map_err(|e| {
|
||||
MemoryError::RetrievalError(format!("lock poisoned: {e}"))
|
||||
})?;
|
||||
let mut inner = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|e| MemoryError::RetrievalError(format!("lock poisoned: {e}")))?;
|
||||
// 校验两端实体存在
|
||||
if !inner.entities.contains_key(&relation.source_id) {
|
||||
return Err(MemoryError::InvalidInput(format!(
|
||||
@@ -348,11 +359,14 @@ impl KnowledgeGraph for InMemoryGraph {
|
||||
target_id: &str,
|
||||
relation_type: &str,
|
||||
) -> Result<(), MemoryError> {
|
||||
let mut inner = self.inner.lock().map_err(|e| {
|
||||
MemoryError::RetrievalError(format!("lock poisoned: {e}"))
|
||||
})?;
|
||||
let mut inner = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|e| MemoryError::RetrievalError(format!("lock poisoned: {e}")))?;
|
||||
inner.relations.retain(|r| {
|
||||
!(r.source_id == source_id && r.target_id == target_id && r.relation_type == relation_type)
|
||||
!(r.source_id == source_id
|
||||
&& r.target_id == target_id
|
||||
&& r.relation_type == relation_type)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -364,9 +378,10 @@ impl KnowledgeGraph for InMemoryGraph {
|
||||
direction: RelationDirection,
|
||||
relation_types: Option<&[&str]>,
|
||||
) -> Result<Vec<ScoredEntity>, MemoryError> {
|
||||
let inner = self.inner.lock().map_err(|e| {
|
||||
MemoryError::RetrievalError(format!("lock poisoned: {e}"))
|
||||
})?;
|
||||
let inner = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|e| MemoryError::RetrievalError(format!("lock poisoned: {e}")))?;
|
||||
|
||||
// 1. 验证起点存在
|
||||
if !inner.entities.contains_key(entity_id) {
|
||||
@@ -470,9 +485,10 @@ impl KnowledgeGraph for InMemoryGraph {
|
||||
}
|
||||
|
||||
async fn find_by_keywords(&self, keywords: &[String]) -> Result<Vec<GraphEntity>, MemoryError> {
|
||||
let inner = self.inner.lock().map_err(|e| {
|
||||
MemoryError::RetrievalError(format!("lock poisoned: {e}"))
|
||||
})?;
|
||||
let inner = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|e| MemoryError::RetrievalError(format!("lock poisoned: {e}")))?;
|
||||
if keywords.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -495,9 +511,10 @@ impl KnowledgeGraph for InMemoryGraph {
|
||||
}
|
||||
|
||||
async fn find_tags(&self, prefix: &str) -> Result<Vec<String>, MemoryError> {
|
||||
let inner = self.inner.lock().map_err(|e| {
|
||||
MemoryError::RetrievalError(format!("lock poisoned: {e}"))
|
||||
})?;
|
||||
let inner = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|e| MemoryError::RetrievalError(format!("lock poisoned: {e}")))?;
|
||||
let prefix_l = prefix.to_lowercase();
|
||||
let mut tags: Vec<String> = inner
|
||||
.tag_index
|
||||
@@ -514,9 +531,10 @@ impl KnowledgeGraph for InMemoryGraph {
|
||||
entity_id: &str,
|
||||
tags: Vec<String>,
|
||||
) -> Result<usize, MemoryError> {
|
||||
let mut inner = self.inner.lock().map_err(|e| {
|
||||
MemoryError::RetrievalError(format!("lock poisoned: {e}"))
|
||||
})?;
|
||||
let mut inner = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|e| MemoryError::RetrievalError(format!("lock poisoned: {e}")))?;
|
||||
|
||||
// 先收集旧标签(避免同时借用 entities 和 tag_index)
|
||||
let old_tags: Vec<String> = inner
|
||||
@@ -554,13 +572,11 @@ impl KnowledgeGraph for InMemoryGraph {
|
||||
}
|
||||
|
||||
async fn entity_count_by_tag(&self, tag: &str) -> Result<usize, MemoryError> {
|
||||
let inner = self.inner.lock().map_err(|e| {
|
||||
MemoryError::RetrievalError(format!("lock poisoned: {e}"))
|
||||
})?;
|
||||
Ok(inner
|
||||
.tag_index
|
||||
.get(tag)
|
||||
.map_or(0, |ids| ids.len()))
|
||||
let inner = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|e| MemoryError::RetrievalError(format!("lock poisoned: {e}")))?;
|
||||
Ok(inner.tag_index.get(tag).map_or(0, |ids| ids.len()))
|
||||
}
|
||||
|
||||
fn tag_constraints(&self) -> TagConstraints {
|
||||
@@ -660,10 +676,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn add_relation_validates_entities() {
|
||||
let graph = InMemoryGraph::new();
|
||||
graph
|
||||
.add_entity(make_entity("a", "A", "x"))
|
||||
.await
|
||||
.unwrap();
|
||||
graph.add_entity(make_entity("a", "A", "x")).await.unwrap();
|
||||
// target 不存在
|
||||
let result = graph
|
||||
.add_relation(GraphRelation::new("a", "ghost", "r", 0.5))
|
||||
@@ -674,14 +687,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn add_relation_upsert_overrides_weight() {
|
||||
let graph = InMemoryGraph::new();
|
||||
graph
|
||||
.add_entity(make_entity("a", "A", "x"))
|
||||
.await
|
||||
.unwrap();
|
||||
graph
|
||||
.add_entity(make_entity("b", "B", "x"))
|
||||
.await
|
||||
.unwrap();
|
||||
graph.add_entity(make_entity("a", "A", "x")).await.unwrap();
|
||||
graph.add_entity(make_entity("b", "B", "x")).await.unwrap();
|
||||
graph
|
||||
.add_relation(GraphRelation::new("a", "b", "r", 0.5))
|
||||
.await
|
||||
@@ -701,14 +708,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn remove_relation() {
|
||||
let graph = InMemoryGraph::new();
|
||||
graph
|
||||
.add_entity(make_entity("a", "A", "x"))
|
||||
.await
|
||||
.unwrap();
|
||||
graph
|
||||
.add_entity(make_entity("b", "B", "x"))
|
||||
.await
|
||||
.unwrap();
|
||||
graph.add_entity(make_entity("a", "A", "x")).await.unwrap();
|
||||
graph.add_entity(make_entity("b", "B", "x")).await.unwrap();
|
||||
graph
|
||||
.add_relation(GraphRelation::new("a", "b", "r", 0.5))
|
||||
.await
|
||||
@@ -726,10 +727,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn get_related_empty_graph() {
|
||||
let graph = InMemoryGraph::new();
|
||||
graph
|
||||
.add_entity(make_entity("a", "A", "x"))
|
||||
.await
|
||||
.unwrap();
|
||||
graph.add_entity(make_entity("a", "A", "x")).await.unwrap();
|
||||
let related = graph
|
||||
.get_related("a", 3, RelationDirection::Outgoing, None)
|
||||
.await
|
||||
@@ -740,10 +738,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn get_related_depth_0() {
|
||||
let graph = InMemoryGraph::new();
|
||||
graph
|
||||
.add_entity(make_entity("a", "A", "x"))
|
||||
.await
|
||||
.unwrap();
|
||||
graph.add_entity(make_entity("a", "A", "x")).await.unwrap();
|
||||
let related = graph
|
||||
.get_related("a", 0, RelationDirection::Outgoing, None)
|
||||
.await
|
||||
@@ -853,10 +848,16 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn get_related_star_topology() {
|
||||
let graph = InMemoryGraph::new();
|
||||
graph.add_entity(make_entity("center", "C", "x")).await.unwrap();
|
||||
graph
|
||||
.add_entity(make_entity("center", "C", "x"))
|
||||
.await
|
||||
.unwrap();
|
||||
for i in 0..5 {
|
||||
let leaf = format!("leaf{i}");
|
||||
graph.add_entity(make_entity(&leaf, &leaf, "x")).await.unwrap();
|
||||
graph
|
||||
.add_entity(make_entity(&leaf, &leaf, "x"))
|
||||
.await
|
||||
.unwrap();
|
||||
graph
|
||||
.add_relation(GraphRelation::new("center", &leaf, "r", 0.8))
|
||||
.await
|
||||
@@ -1016,10 +1017,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn set_and_find_tags() {
|
||||
let graph = InMemoryGraph::new();
|
||||
graph
|
||||
.add_entity(make_entity("a", "A", "x"))
|
||||
.await
|
||||
.unwrap();
|
||||
graph.add_entity(make_entity("a", "A", "x")).await.unwrap();
|
||||
let n = graph
|
||||
.set_entity_tags("a", vec!["rust".into(), "ai".into()])
|
||||
.await
|
||||
@@ -1055,7 +1053,10 @@ mod tests {
|
||||
let graph = InMemoryGraph::with_constraints(constraints);
|
||||
graph.add_entity(make_entity("a", "A", "x")).await.unwrap();
|
||||
let n = graph
|
||||
.set_entity_tags("a", vec!["t1".into(), "t2".into(), "t3".into(), "t4".into()])
|
||||
.set_entity_tags(
|
||||
"a",
|
||||
vec!["t1".into(), "t2".into(), "t3".into(), "t4".into()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(n, 2, "should truncate to max_tags_per_entity");
|
||||
@@ -1075,10 +1076,7 @@ mod tests {
|
||||
.set_entity_tags("b", vec!["rust".into(), "ai".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
graph
|
||||
.set_entity_tags("c", vec!["ai".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
graph.set_entity_tags("c", vec!["ai".into()]).await.unwrap();
|
||||
assert_eq!(graph.entity_count_by_tag("rust").await.unwrap(), 2);
|
||||
assert_eq!(graph.entity_count_by_tag("ai").await.unwrap(), 2);
|
||||
}
|
||||
@@ -1086,9 +1084,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn set_entity_tags_not_found() {
|
||||
let graph = InMemoryGraph::new();
|
||||
let result = graph
|
||||
.set_entity_tags("ghost", vec!["x".into()])
|
||||
.await;
|
||||
let result = graph.set_entity_tags("ghost", vec!["x".into()]).await;
|
||||
assert!(matches!(result, Err(MemoryError::NotFound(_))));
|
||||
}
|
||||
|
||||
|
||||
+33
-12
@@ -270,7 +270,12 @@ impl MemoryRetriever {
|
||||
}
|
||||
// BFS 找相关实体
|
||||
let related: Vec<ScoredEntity> = graph
|
||||
.get_related(&start.id, depth, crate::memory::graph::RelationDirection::Both, None)
|
||||
.get_related(
|
||||
&start.id,
|
||||
depth,
|
||||
crate::memory::graph::RelationDirection::Both,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
for se in related {
|
||||
if seen.insert(se.entity.id.clone()) {
|
||||
@@ -361,7 +366,7 @@ fn default_stop_words() -> HashSet<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::memory::graph::{GraphEntity, InMemoryGraph, GraphRelation};
|
||||
use crate::memory::graph::{GraphEntity, GraphRelation, InMemoryGraph};
|
||||
use crate::memory::knowledge::KnowledgeStore;
|
||||
use crate::memory::{InMemoryStore, MemoryStore};
|
||||
use std::sync::Arc;
|
||||
@@ -502,7 +507,12 @@ mod tests {
|
||||
graph.add_entity(langgraph).await.unwrap();
|
||||
graph.add_entity(python).await.unwrap();
|
||||
graph
|
||||
.add_relation(GraphRelation::new("langchain", "langgraph", "includes", 0.8))
|
||||
.add_relation(GraphRelation::new(
|
||||
"langchain",
|
||||
"langgraph",
|
||||
"includes",
|
||||
0.8,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
graph
|
||||
@@ -523,7 +533,12 @@ mod tests {
|
||||
let result = retriever.retrieve("langchain").await.unwrap();
|
||||
assert_eq!(result.strategy, RetrievalStrategy::GraphOnly);
|
||||
// 应该全部是 GraphEntity 变体
|
||||
assert!(result.items.iter().all(|i| matches!(i, RetrievalItem::GraphEntity { .. })));
|
||||
assert!(
|
||||
result
|
||||
.items
|
||||
.iter()
|
||||
.all(|i| matches!(i, RetrievalItem::GraphEntity { .. }))
|
||||
);
|
||||
// langchain 是起始实体(score=1.0),langgraph 和 python 是 BFS 结果
|
||||
assert!(!result.items.is_empty(), "should find graph entities");
|
||||
}
|
||||
@@ -544,8 +559,8 @@ mod tests {
|
||||
// KnowledgeGraph 也有匹配
|
||||
let graph = make_graph_with_data().await;
|
||||
|
||||
let retriever = MemoryRetriever::new(ks, RetrieverConfig::default())
|
||||
.with_knowledge_graph(graph);
|
||||
let retriever =
|
||||
MemoryRetriever::new(ks, RetrieverConfig::default()).with_knowledge_graph(graph);
|
||||
let result = retriever.retrieve("langchain").await.unwrap();
|
||||
assert_eq!(result.strategy, RetrievalStrategy::Hybrid);
|
||||
// 应该同时包含 KnowledgePage 和 GraphEntity
|
||||
@@ -569,8 +584,8 @@ mod tests {
|
||||
.unwrap();
|
||||
// 空图
|
||||
let graph = Arc::new(InMemoryGraph::new());
|
||||
let retriever = MemoryRetriever::new(ks, RetrieverConfig::default())
|
||||
.with_knowledge_graph(graph);
|
||||
let retriever =
|
||||
MemoryRetriever::new(ks, RetrieverConfig::default()).with_knowledge_graph(graph);
|
||||
let result = retriever.retrieve("langchain").await.unwrap();
|
||||
assert_eq!(result.strategy, RetrievalStrategy::Hybrid);
|
||||
// 图空,只有 Store 结果
|
||||
@@ -587,15 +602,18 @@ mod tests {
|
||||
let ks = KnowledgeStore::new(store);
|
||||
// Store 空,图有数据
|
||||
let graph = make_graph_with_data().await;
|
||||
let retriever = MemoryRetriever::new(ks, RetrieverConfig::default())
|
||||
.with_knowledge_graph(graph);
|
||||
let retriever =
|
||||
MemoryRetriever::new(ks, RetrieverConfig::default()).with_knowledge_graph(graph);
|
||||
let result = retriever.retrieve("langchain").await.unwrap();
|
||||
assert_eq!(result.strategy, RetrievalStrategy::Hybrid);
|
||||
let has_entity = result
|
||||
.items
|
||||
.iter()
|
||||
.any(|i| matches!(i, RetrievalItem::GraphEntity { .. }));
|
||||
assert!(has_entity, "should have graph results even when store is empty");
|
||||
assert!(
|
||||
has_entity,
|
||||
"should have graph results even when store is empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -615,7 +633,10 @@ mod tests {
|
||||
.items
|
||||
.iter()
|
||||
.any(|i| matches!(i, RetrievalItem::GraphEntity { .. }));
|
||||
assert!(!has_entity, "KnowledgeOnly should not return graph entities");
|
||||
assert!(
|
||||
!has_entity,
|
||||
"KnowledgeOnly should not return graph entities"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 辅助函数测试 ──
|
||||
|
||||
@@ -6,9 +6,11 @@ use crate::memory::error::MemoryError;
|
||||
use crate::memory::types::{MemoryFilter, MemoryItem};
|
||||
|
||||
pub mod in_memory;
|
||||
#[cfg(feature = "memory-sqlite")]
|
||||
pub mod sqlite_store;
|
||||
|
||||
pub use in_memory::InMemoryStore;
|
||||
#[cfg(feature = "memory-sqlite")]
|
||||
pub use sqlite_store::SqliteStore;
|
||||
|
||||
/// 底层记忆存储抽象接口。
|
||||
|
||||
@@ -6,9 +6,9 @@ use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rusqlite::{params, params_from_iter, Connection, ErrorCode};
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use rusqlite::{Connection, ErrorCode, params, params_from_iter};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use tracing::{debug, error, instrument, warn};
|
||||
|
||||
use crate::memory::error::MemoryError;
|
||||
@@ -114,9 +114,7 @@ impl MemoryStore for SqliteStore {
|
||||
.map_err(|e| map_sqlite_error(e, "query get"))?;
|
||||
match rows.next() {
|
||||
None => Ok(None),
|
||||
Some(row) => row
|
||||
.map(Some)
|
||||
.map_err(|e| map_sqlite_error(e, "decode row")),
|
||||
Some(row) => row.map(Some).map_err(|e| map_sqlite_error(e, "decode row")),
|
||||
}
|
||||
})
|
||||
.await
|
||||
@@ -130,11 +128,8 @@ impl MemoryStore for SqliteStore {
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<(), MemoryError> {
|
||||
let conn = conn.lock().unwrap_or_else(|e| e.into_inner());
|
||||
conn.execute(
|
||||
"DELETE FROM memory_items WHERE id = ?1",
|
||||
params![id_owned],
|
||||
)
|
||||
.map_err(|e| map_sqlite_error(e, "delete"))?;
|
||||
conn.execute("DELETE FROM memory_items WHERE id = ?1", params![id_owned])
|
||||
.map_err(|e| map_sqlite_error(e, "delete"))?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
@@ -143,9 +138,8 @@ impl MemoryStore for SqliteStore {
|
||||
|
||||
#[instrument(skip(self, filter))]
|
||||
async fn list(&self, filter: &MemoryFilter) -> Result<Vec<MemoryItem>, MemoryError> {
|
||||
let mut sql = String::from(
|
||||
"SELECT id, content, metadata, created_at FROM memory_items WHERE 1=1",
|
||||
);
|
||||
let mut sql =
|
||||
String::from("SELECT id, content, metadata, created_at FROM memory_items WHERE 1=1");
|
||||
let mut param_values: Vec<String> = Vec::new();
|
||||
let mut ph_idx = 0usize;
|
||||
|
||||
@@ -309,9 +303,7 @@ fn map_sqlite_error(e: rusqlite::Error, ctx: &str) -> MemoryError {
|
||||
rusqlite::Error::InvalidQuery
|
||||
| rusqlite::Error::InvalidParameterName(_)
|
||||
| rusqlite::Error::InvalidColumnIndex(_)
|
||||
| rusqlite::Error::InvalidColumnName(_) => {
|
||||
MemoryError::InvalidInput(format!("{ctx}: {e}"))
|
||||
}
|
||||
| rusqlite::Error::InvalidColumnName(_) => MemoryError::InvalidInput(format!("{ctx}: {e}")),
|
||||
rusqlite::Error::FromSqlConversionFailure(_, _, _)
|
||||
| rusqlite::Error::ToSqlConversionFailure(_) => {
|
||||
MemoryError::Serialization(format!("{ctx}: {e}"))
|
||||
@@ -527,8 +519,7 @@ mod tests {
|
||||
// ponytail: 回归验证 SqliteStore 可作为 Arc<dyn MemoryStore> 与 InMemoryStore 互换
|
||||
// 所有现有消费者(Conversation / Knowledge / Retriever / SessionMemory)均通过 trait object 引用,
|
||||
// 此测试确保 trait 接口契约在 SqliteStore 上同样成立。
|
||||
let sqlite: Arc<dyn MemoryStore> =
|
||||
Arc::new(SqliteStore::open(":memory:").unwrap());
|
||||
let sqlite: Arc<dyn MemoryStore> = Arc::new(SqliteStore::open(":memory:").unwrap());
|
||||
let in_mem: Arc<dyn MemoryStore> = Arc::new(InMemoryStore::new());
|
||||
|
||||
let stores: Vec<Arc<dyn MemoryStore>> = vec![Arc::clone(&sqlite), Arc::clone(&in_mem)];
|
||||
@@ -556,12 +547,7 @@ mod tests {
|
||||
handles.push(tokio::spawn(async move {
|
||||
let id = format!("concurrent_{i}");
|
||||
// 设置每次 save 的 per-call timeout —— busy_timeout=5000ms 应足够
|
||||
match tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
s.save(make_item(&id)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
match tokio::time::timeout(Duration::from_secs(10), s.save(make_item(&id))).await {
|
||||
Ok(res) => res.unwrap(),
|
||||
Err(_) => panic!("save({id}) timed out under 100-way concurrency"),
|
||||
}
|
||||
|
||||
+3
-11
@@ -30,11 +30,7 @@ pub trait VectorRetriever: Send + Sync {
|
||||
///
|
||||
/// 返回 `Vec<(id, score)>`,按 score 降序排列,score ∈ [0.0, 1.0]
|
||||
/// (余弦相似度)。当 `k == 0`、索引为空或 query 为零向量时返回空 Vec。
|
||||
async fn search(
|
||||
&self,
|
||||
query: Vec<f32>,
|
||||
k: usize,
|
||||
) -> Result<Vec<(String, f32)>, MemoryError>;
|
||||
async fn search(&self, query: Vec<f32>, k: usize) -> Result<Vec<(String, f32)>, MemoryError>;
|
||||
}
|
||||
|
||||
/// 进程内向量检索器 —— 基于 HashMap + 全量余弦相似度扫描。
|
||||
@@ -79,11 +75,7 @@ impl VectorRetriever for InMemoryVectorRetriever {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn search(
|
||||
&self,
|
||||
query: Vec<f32>,
|
||||
k: usize,
|
||||
) -> Result<Vec<(String, f32)>, MemoryError> {
|
||||
async fn search(&self, query: Vec<f32>, k: usize) -> Result<Vec<(String, f32)>, MemoryError> {
|
||||
let vectors = self
|
||||
.vectors
|
||||
.lock()
|
||||
@@ -241,4 +233,4 @@ mod tests {
|
||||
h.await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-46
@@ -11,8 +11,8 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::document::{Document, RecursiveCharacterSplitter};
|
||||
@@ -43,11 +43,8 @@ pub trait VectorStore: Send + Sync {
|
||||
/// - 截取 `min(len)` 对处理(部分写入已发生)
|
||||
/// - 返回 `Err(MemoryError::InvalidInput)` 告知截断
|
||||
/// - 调用方可以 `let _ = store.add(...)` 忽略错误
|
||||
async fn add(
|
||||
&self,
|
||||
documents: &[Document],
|
||||
embeddings: &[Vec<f32>],
|
||||
) -> Result<(), MemoryError>;
|
||||
async fn add(&self, documents: &[Document], embeddings: &[Vec<f32>])
|
||||
-> Result<(), MemoryError>;
|
||||
|
||||
/// 检索与 `query` 向量最相似的 `k` 条记录。
|
||||
///
|
||||
@@ -59,11 +56,7 @@ pub trait VectorStore: Send + Sync {
|
||||
/// - 空索引 → 返回 `vec![]`
|
||||
/// - `k == 0` → 返回 `vec![]`
|
||||
/// - 零向量(norm ≈ 0)→ 返回 `vec![]`
|
||||
async fn search(
|
||||
&self,
|
||||
query: &[f32],
|
||||
k: usize,
|
||||
) -> Result<Vec<(Document, f32)>, MemoryError>;
|
||||
async fn search(&self, query: &[f32], k: usize) -> Result<Vec<(Document, f32)>, MemoryError>;
|
||||
|
||||
/// 批量删除文档(幂等)。
|
||||
///
|
||||
@@ -105,9 +98,7 @@ impl InMemoryVectorStore {
|
||||
}
|
||||
|
||||
/// 从预填充的 entries 构造(供 `PersistentVectorStore` 使用)。
|
||||
pub(crate) fn with_entries(
|
||||
entries: HashMap<String, (Document, Vec<f32>)>,
|
||||
) -> Self {
|
||||
pub(crate) fn with_entries(entries: HashMap<String, (Document, Vec<f32>)>) -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(entries),
|
||||
}
|
||||
@@ -142,7 +133,10 @@ impl VectorStore for InMemoryVectorStore {
|
||||
}
|
||||
|
||||
for i in 0..n {
|
||||
entries.insert(documents[i].id.clone(), (documents[i].clone(), embeddings[i].clone()));
|
||||
entries.insert(
|
||||
documents[i].id.clone(),
|
||||
(documents[i].clone(), embeddings[i].clone()),
|
||||
);
|
||||
}
|
||||
|
||||
if documents.len() != embeddings.len() {
|
||||
@@ -156,11 +150,7 @@ impl VectorStore for InMemoryVectorStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn search(
|
||||
&self,
|
||||
query: &[f32],
|
||||
k: usize,
|
||||
) -> Result<Vec<(Document, f32)>, MemoryError> {
|
||||
async fn search(&self, query: &[f32], k: usize) -> Result<Vec<(Document, f32)>, MemoryError> {
|
||||
if k == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -265,10 +255,7 @@ impl PersistentVectorStore {
|
||||
///
|
||||
/// `MemoryStore::list()` 由 `SqliteStore` 内部使用 `spawn_blocking` 卸载,
|
||||
/// 加载过程本身在 async context 中即可,无需额外 spawn_blocking。
|
||||
pub async fn new(
|
||||
store: Arc<dyn MemoryStore>,
|
||||
namespace: &str,
|
||||
) -> Result<Self, MemoryError> {
|
||||
pub async fn new(store: Arc<dyn MemoryStore>, namespace: &str) -> Result<Self, MemoryError> {
|
||||
let prefix = format!("vec:{namespace}:");
|
||||
let filter = MemoryFilter {
|
||||
prefix: Some(prefix.clone()),
|
||||
@@ -292,7 +279,10 @@ impl PersistentVectorStore {
|
||||
entries.insert(doc.id.clone(), (doc, entry.embedding));
|
||||
}
|
||||
|
||||
info!(entries = entries.len(), "PersistentVectorStore — 内存索引重建完成");
|
||||
info!(
|
||||
entries = entries.len(),
|
||||
"PersistentVectorStore — 内存索引重建完成"
|
||||
);
|
||||
Ok(Self {
|
||||
inner: InMemoryVectorStore::with_entries(entries),
|
||||
store,
|
||||
@@ -340,11 +330,7 @@ impl VectorStore for PersistentVectorStore {
|
||||
self.inner.add(documents, embeddings).await
|
||||
}
|
||||
|
||||
async fn search(
|
||||
&self,
|
||||
query: &[f32],
|
||||
k: usize,
|
||||
) -> Result<Vec<(Document, f32)>, MemoryError> {
|
||||
async fn search(&self, query: &[f32], k: usize) -> Result<Vec<(Document, f32)>, MemoryError> {
|
||||
tracing::trace!(k, "PersistentVectorStore::search");
|
||||
self.inner.search(query, k).await
|
||||
}
|
||||
@@ -575,10 +561,7 @@ mod tests {
|
||||
async fn remove_items() {
|
||||
let store = InMemoryVectorStore::new();
|
||||
let docs = vec![make_doc("a", "alpha"), make_doc("b", "beta")];
|
||||
let embeddings = vec![
|
||||
make_vec(&[1.0, 0.0, 0.0]),
|
||||
make_vec(&[0.0, 1.0, 0.0]),
|
||||
];
|
||||
let embeddings = vec![make_vec(&[1.0, 0.0, 0.0]), make_vec(&[0.0, 1.0, 0.0])];
|
||||
store.add(&docs, &embeddings).await.unwrap();
|
||||
|
||||
store.remove(&["a".to_string()]).await.unwrap();
|
||||
@@ -599,7 +582,9 @@ mod tests {
|
||||
let embeddings = vec![make_vec(&[1.0, 0.0, 0.0])];
|
||||
store.add(&docs, &embeddings).await.unwrap();
|
||||
|
||||
let result = store.remove(&["nonexistent".to_string(), "also_nonexistent".to_string()]).await;
|
||||
let result = store
|
||||
.remove(&["nonexistent".to_string(), "also_nonexistent".to_string()])
|
||||
.await;
|
||||
assert!(result.is_ok(), "批量删除不存在 id 不应报错");
|
||||
|
||||
let results = store.search(&[1.0, 0.0, 0.0], 5).await.unwrap();
|
||||
@@ -657,7 +642,9 @@ mod tests {
|
||||
backend: Arc<dyn MemoryStore>,
|
||||
namespace: &str,
|
||||
) -> PersistentVectorStore {
|
||||
PersistentVectorStore::new(backend, namespace).await.unwrap()
|
||||
PersistentVectorStore::new(backend, namespace)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -755,10 +742,7 @@ mod tests {
|
||||
let store = make_persistent(Arc::clone(&backend), "default").await;
|
||||
|
||||
// 正常写入前 2 条
|
||||
let docs_first = vec![
|
||||
make_doc("doc_0", "first"),
|
||||
make_doc("doc_1", "second"),
|
||||
];
|
||||
let docs_first = vec![make_doc("doc_0", "first"), make_doc("doc_1", "second")];
|
||||
let embeddings_first = vec![make_vec(&[1.0, 0.0, 0.0]), make_vec(&[0.0, 1.0, 0.0])];
|
||||
store.add(&docs_first, &embeddings_first).await.unwrap();
|
||||
|
||||
@@ -797,11 +781,7 @@ mod tests {
|
||||
let store: Arc<dyn VectorStore> = Arc::new(InMemoryVectorStore::new());
|
||||
let splitter = RecursiveCharacterSplitter::new(50, 5);
|
||||
|
||||
let pipeline = RagPipeline::new(
|
||||
Arc::clone(&embedder),
|
||||
Arc::clone(&store),
|
||||
Some(splitter),
|
||||
);
|
||||
let pipeline = RagPipeline::new(Arc::clone(&embedder), Arc::clone(&store), Some(splitter));
|
||||
|
||||
// 创建多段落文档
|
||||
let doc = Document::new(
|
||||
@@ -934,4 +914,4 @@ mod tests {
|
||||
elapsed.as_millis()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
pub mod base;
|
||||
pub mod error;
|
||||
#[cfg(feature = "tools-mcp")]
|
||||
pub mod mcp;
|
||||
pub mod permission;
|
||||
pub mod registry;
|
||||
|
||||
pub use base::{BaseTool, ToolContext, ToolRef};
|
||||
pub use error::ToolError;
|
||||
#[cfg(feature = "tools-mcp")]
|
||||
pub use mcp::{McpClient, McpTransport};
|
||||
pub use permission::{Permission, PermissionChecker, PermissionConfig};
|
||||
pub use registry::{ToolInvocation, ToolRegistry};
|
||||
|
||||
Reference in New Issue
Block a user