21 Commits
Author SHA1 Message Date
徐涛 c5afa4b31e docs(agents): 同步文档规范与 design 目录说明至全局版本
- 替换方案规范为 design/pdd/ + design/prd/ 编号体系
- 移除 docs/roadmap.md 进度同步规则(全局未要求)
- 新增 design/ 子目录职责、读写权限与兜底规则
2026-07-23 05:54:58 +08:00
徐涛 28ca43ccb2 chore(docs): 将设计文档从 docs 移至 design 目录
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
2026-07-23 05:45:53 +08:00
徐涛 528a17f5fa docs(roadmap): 更新 v0.4.0 规划状态,归档已完成 Phase 2026-07-21 22:44:01 +08:00
徐涛 d48286a942 docs(v0.4.0): 新增 v0.4.0 版本路线图规划文档
涵盖多 Agent 编排、Human-in-the-loop、语义压缩与自动校正等 Phase A-E 共 5 个增量的实施计划、依赖关系与里程碑定义
2026-07-21 22:43:55 +08:00
徐涛 eeae943727 fix(core): 升级 Cargo.toml 版本号到 v0.3.5
CI / test (chat,provider-openai) (push) Has been cancelled
CI / test (chat,provider-openai,provider-openai-response) (push) Has been cancelled
CI / test (chat,provider-openai,tools-mcp) (push) Has been cancelled
CI / test (full) (push) Has been cancelled
CI / test (light) (push) Has been cancelled
CI / test (multi,provider-openai) (push) Has been cancelled
CI / test (multi,provider-openai,tools-mcp) (push) Has been cancelled
CI / clippy (push) Has been cancelled
CI / fmt (push) Has been cancelled
CI / examples (push) Has been cancelled
2026-07-20 16:47:38 +08:00
徐涛 40e4b3d8fe fix(llm): 修复 OpenaiResponseProvider builtin_tools 注入逻辑缺陷
修复当 tools_defs 为空时 builtin_tools 完全不生效的问题:
- 修复 convert_request 分支错位,解耦 tools_defs 与 builtin_tools 处理
- 扩展 ResponseTool 枚举,添加 Builtin(Value) 变体 + 自定义 Serialize/Deserialize
- 改进错误处理:match + warn! 替代 unwrap_or_else 兜底
- 添加 6 个测试用例覆盖纯 builtin / 混用 / 回归 / wire format / roundtrip 场景
- 补充方案文档 docs/30-builtin-tools-injection-fix.md
2026-07-20 16:45:47 +08:00
徐涛 8ea01d373e fix(core): 升级 Cargo.toml 版本号到 v0.3.4
CI / test (multi,provider-openai) (push) Has been cancelled
CI / test (chat,provider-openai) (push) Has been cancelled
CI / test (chat,provider-openai,provider-openai-response) (push) Has been cancelled
CI / test (chat,provider-openai,tools-mcp) (push) Has been cancelled
CI / test (full) (push) Has been cancelled
CI / test (light) (push) Has been cancelled
CI / test (multi,provider-openai,tools-mcp) (push) Has been cancelled
CI / clippy (push) Has been cancelled
CI / fmt (push) Has been cancelled
CI / examples (push) Has been cancelled
版本号 PATCH 升级(0.3.3 → 0.3.4),向后兼容,包含三 Provider 自定义 HTTP 请求头支持(feat 77321db + 安全修复 76bbeed + doc 清理 5e475e1)。
2026-07-20 15:11:18 +08:00
徐涛 5e475e1303 docs(llm): 清理 build_request_builder 重复 doc comment
- anthropic.rs:删除 build_request_builder doc comment 中重复的 4 行(Round 1 安全性修复时追加内容未清理原段落)
- openai_response.rs:在 build_request_builder doc comment 补充「构造 HTTP POST 请求 builder(含认证头与额外请求头)」描述句,与另两 provider 对齐
2026-07-20 15:09:21 +08:00
徐涛 76bbeed596 fix(llm): 自定义头注入安全性修复 + 测试覆盖补全
三 Provider 自定义 HTTP 头机制的审查后修复:

- 安全性:build_request_builder 改用 HeaderName::from_bytes / HeaderValue::from_str
  安全转换;非法 header 名/值(如控制字符)静默跳过 + warn,避免 reqwest panic。

- doc comment:with_extra_headers() 的"注入"措辞与"替换"语义不符
  (self.extra_headers = headers),改为"设置 Provider 级别固定头,替换已有的"。

- 测试覆盖:补全方案验证标准缺失的 *_extra_headers_from_constructor 单元测试
  (三 provider 各 2 个,共 6 新增),用 RequestBuilder::build() 直检 headers。
2026-07-20 14:58:32 +08:00
徐涛 77321db8f6 feat(llm): 三 provider 统一支持自定义 HTTP 请求头
在 GenericOpenaiProvider / OpenaiResponseProvider / AnthropicProvider 中新增双层自定义 HTTP 头机制:

- Provider 级固定头:extra_headers 字段,构造时通过 with_extra_headers() 链式注入
- 请求级临时头:extra.custom_headers,通过 set_extra 透传,#[serde(skip)] 隔离 JSON body

AnthropicProvider 前置提取 build_request_builder 统一方法,使三 provider 的请求构造模式对齐。

头融合顺序(一致):认证头 → Provider 级头 → 请求级头,后注入覆盖前注入。

新增 14 个测试覆盖提取 / 序列化隔离 / 类型降级 / 透传 / 覆盖优先级 / 认证头可覆盖等维度。
2026-07-20 14:47:38 +08:00
徐涛 939dcf0f9a fix(core): 对齐 Cargo.toml 版本号到 v0.3.3
CI / test (chat,provider-openai) (push) Has been cancelled
CI / test (chat,provider-openai,provider-openai-response) (push) Has been cancelled
CI / test (chat,provider-openai,tools-mcp) (push) Has been cancelled
CI / test (full) (push) Has been cancelled
CI / test (light) (push) Has been cancelled
CI / test (multi,provider-openai,tools-mcp) (push) Has been cancelled
CI / clippy (push) Has been cancelled
CI / fmt (push) Has been cancelled
CI / examples (push) Has been cancelled
CI / test (multi,provider-openai) (push) Has been cancelled
v0.3.3 tag 已发布但 Cargo.toml version 仍停留在 0.3.2,
导致 cargo publish 与用户引用的版本不一致。补齐后重新
打 tag v0.3.3 到本次提交。

- Cargo.toml version: 0.3.2 → 0.3.3
- git tag v0.3.3 重新指向新 commit
2026-07-20 10:07:14 +08:00
徐涛 b895616dd0 feat(llm): 实现 OpenAI Response API Provider
CI / test (chat,provider-openai) (push) Has been cancelled
CI / test (chat,provider-openai,provider-openai-response) (push) Has been cancelled
CI / test (chat,provider-openai,tools-mcp) (push) Has been cancelled
CI / test (full) (push) Has been cancelled
CI / test (light) (push) Has been cancelled
CI / test (multi,provider-openai,tools-mcp) (push) Has been cancelled
CI / clippy (push) Has been cancelled
CI / fmt (push) Has been cancelled
CI / examples (push) Has been cancelled
CI / test (multi,provider-openai) (push) Has been cancelled
- 新增独立 OpenaiResponseProvider(POST /responses 协议),独立 feature provider-openai-response
- 覆盖文本对话/流式/Vision/Function Calling/多轮接续/结构化输出/内置工具逃生舱
- 内置工具(web_search/file_search)通过 extra 逃生舱透传
- 工厂注册 ProviderType::OpenaiResponse + src/llm 模块门控追加
- 新增 example response_api_demo + CI 矩阵新增组合 + README/roadmap 同步
- 测试覆盖:13 单元 + 15 wiremock(流式 + 非流式 + 错误路径)
- 文档:docs/28-phase28-openai-response-api-provider.md
2026-07-20 09:05:04 +08:00
徐涛 f6cf583cd7 chore(core): 发布 v0.3.2 版本
CI / test (chat,provider-openai) (push) Has been cancelled
CI / test (chat,provider-openai,tools-mcp) (push) Has been cancelled
CI / test (full) (push) Has been cancelled
CI / test (light) (push) Has been cancelled
CI / test (multi,provider-openai) (push) Has been cancelled
CI / test (multi,provider-openai,tools-mcp) (push) Has been cancelled
CI / clippy (push) Has been cancelled
CI / fmt (push) Has been cancelled
CI / examples (push) Has been cancelled
- Cargo.toml version 从 0.3.0 升至 0.3.2,与 v0.3.2 tag 对齐
2026-07-19 13:07:31 +08:00
徐涛 0cfd401579 docs(roadmap): 同步 v0.3.2 roadmap 与实际实施状态
- Step 3/4 标记 ,补充验证结果(427/416/363/369/401/407 passed)
- 功能清单 imply 列与 Cargo.toml 对齐(llm +futures-util,tools +tokio,memory +llm/tokio/time)
- Phase 24/25 状态从"部分交付"改为"已交付"(Phase 26 已验证)
- Cargo.toml [features] 草案与实际实现一致
- CI 测试矩阵草案替换为已实施的 ci.yml
- 依赖 optional 化对照表修正(tokio 启用者 +tools/memory,futures-util +llm,time +memory)
2026-07-19 09:20:29 +08:00
徐涛 5baa170508 docs: 更新 README feature 表 + 升级指南 + 示例注释 + roadmap 同步
- README 添加 feature 组合表 + 模块级 features 清单 + 升级指南
- 18 个 example 顶部添加 Required features 注释
- roadmap.md 和 roadmap-v0.3.2.md 同步 Phase 26-27 完成状态
- cargo fmt 全量格式化(修复预存格式问题,CI format job 可通过)
2026-07-19 08:18:04 +08:00
徐涛 bc4eac72e1 chore(ci): 创建 GitHub Actions CI 测试矩阵
- 6 个 feature 组合并行测试(full/light/chat/chat+mcp/multi/multi+mcp)
- clippy --all-features 零警告检查
- cargo fmt --check 格式检查(stable toolchain)
- examples 编译验证(cargo test --features full)
- RUSTFLAGS=-D warnings 强制零警告
- 每个 job 设置 timeout-minutes 兜底
2026-07-19 08:10:53 +08:00
徐涛 61e6d219dd chore(examples): 为 18 个 example 添加 required-features 声明
- 18 个 example 各自声明最小 feature 集合
- llm feature 补充 imply futures-util(修复 cycle.rs 隐式依赖)
- prompt_composer/custom_tool 需额外 llm(response_v2.rs 依赖 LlmError)
2026-07-19 08:09:57 +08:00
徐涛 932a06f512 refactor(core): 完成 v0.3.2 Cargo features 拆分基础设施
- 定义 16 个 features + 4 个快捷组合,default = ["full"] 保持向后兼容
- 12 个重型依赖 optional 化(tokio/reqwest/rusqlite 等)
- 全模块 #[cfg(feature)] 门控注入(llm/tools/memory/agent/engine)
- 将 LlmProvider trait 及关联类型移出 provider 模块归属 llm(ADR-1)
- 为 session.rs bundle() 方法添加 engine feature 门控
- 更新 12 个内部文件 + 4 个示例文件的 import 路径
- 向后兼容:provider.rs 保留 pub use 重导出老路径
2026-07-19 07:58:04 +08:00
徐涛 249fba8aaf docs(roadmap): 增补 v0.3.2 实施节奏 4-Step 计划及执行说明 2026-07-18 09:25:08 +08:00
徐涛 703151e363 docs(roadmap): 增补 v0.3.2 Cargo features 拆分路线图
- 16 个 feature(10 模块级 + 5 provider + 1 工具)+ 4 个快捷组合
- agent 不再 imply tools-mcp,MCP 作为可选依赖由用户显式启用
- tokio features 拆细为 rt/sync/time/macros/process/io-util
- 8 个 Phase 实施计划(Phase 20-27)+ 7 种 CI 矩阵组合
- default = ["full"] 保持向后兼容,非破坏性变更
2026-07-18 08:16:38 +08:00
徐涛 385560a1dd docs: 增补 README 介绍内容并对齐至 v0.3 实际实现
- 版本号 0.2 → 0.3
- 示例数量 10 → 18,追加 8 个示例条目
- 模块表新增 engine / document,扩展 llm / memory / agent 描述
- 架构图新增 Engine 层、Document 模块、Memory 子模块展开
- 依赖关系图新增 Engine / Document 节点,修正依赖规则
- 环境变量 PROVIDER 移除未实现的 openai-response,新增 ollama
- 修正示例行数描述与参考项目段措辞
2026-07-17 16:53:43 +08:00
119 changed files with 8183 additions and 751 deletions
+62
View File
@@ -0,0 +1,62 @@
name: CI
on: [push, pull_request]
env:
RUSTFLAGS: "-D warnings"
jobs:
test-matrix:
name: test (${{ matrix.features }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
features:
- "full"
- "light"
- "chat,provider-openai"
- "chat,provider-openai,tools-mcp"
- "multi,provider-openai"
- "multi,provider-openai,tools-mcp"
- "chat,provider-openai,provider-openai-response"
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo test --no-default-features --features "${{ matrix.features }}" --lib
clippy:
name: clippy
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo clippy --all-features --lib -- -D warnings
format:
name: fmt
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
- run: cargo fmt --check
examples:
name: examples
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo test --features "full"
+52 -17
View File
@@ -186,29 +186,64 @@ pub use vector_store::VectorStore;
## 文档规范
### 方案规范 (docs/)
### 文档编号规范(design/pdd/ + design/prd/
**编号规则**:创建新方案前必须先通过 shell 命令确认当前实际最大编号(Unix: `ls docs/` / Windows: `dir docs\`),禁止使用上下文中缓存的编号,如遇冲突自动递增
`design/pdd/`(方案文档)和 `design/prd/`(需求文档)使用相同的命名格式,但**各自独立编号**:
**方案文档结构**6 项):
1. **背景与目标** - 问题描述、预期目标
2. **需求分析** - 功能需求、非功能需求
3. **方案设计** - 架构设计、模块划分、接口定义
4. **实现计划** - 任务拆解、优先级、时间估算
5. **风险评估** - 潜在风险、缓解措施
6. **验收标准** - 可验证的完成条件
```
<序号>-<简短描述>.md
```
### 进度同步规范 (docs/roadmap.md)
规则:
- 序号使用数字,从 1 开始递增。**创建前必须通过 shell 命令确认目标目录当前实际最大编号再加 1:**
```bash
# 查 design/pdd/ 的最大编号
ls design/pdd/ 2>/dev/null | grep -E '^\d+-' | sort -t- -k1 -n | tail -1 | cut -d- -f1
# 查 design/prd/ 的最大编号
ls design/prd/ 2>/dev/null | grep -E '^\d+-' | sort -t- -k1 -n | tail -1 | cut -d- -f1
# 无输出则从 1 开始
```
禁止使用上下文中缓存的编号。
- 描述:中文,简短概括主题
- 两个目录各自独立编号——`design/pdd/` 已有 `3-` 时,`design/prd/` 的新文件仍从当前最大号 +1 开始,互不影响
完成一项实施后,必须检查 `docs/roadmap.md` 是否存在对应内容;若存在,必须同步标记为完成
方案文档(`design/pdd/`)应包含
- 背景与目标
- 需求推演概要(需求拆解、边界识别、关键假设的简要推演)
- 当前问题分析
- 架构决策记录(重大技术选型、架构变更的决策过程与理由)
- 设计方案(含架构图/流程图)
- 实施步骤
- 验证标准
- 回滚方案(如适用)
- **Step / Phase 状态行**:对应 Step 加 ✅ 标记;Phase 章节末尾「状态」行从 ⏳ 改为 ✅ Phase X 全部交付物已完成
- **里程碑表**:更新对应里程碑状态从 ⏳ 改为 ✅ + 完成日期
- **依赖关系图(Mermaid**:节点 `class``pending` / `core` 改为 `done`,必要时更新节点摘要
- **文末「已完成 / 进行中阶段」列表**:追加一行 `- ✅ Phase X — 一句话要点`
- **顶部「当前状态」**:补充新完成 Phase,更新「下一步」指向
示例:
- `design/pdd/1-ui-components重构方案.md`
- `design/prd/1-用户认证需求.md`
- `design/pdd/2-数据库迁移方案.md`
参考案例:2026-07-05 完成 Phase 7 SqliteStore 时同步更新 6 处(顶部状态 / Phase 章节 / 依赖图 / M3 / 下一步行动 / 已完成列表)。
---
### 设计目录(design/
项目根下的 `design/` 目录集中管理所有设计相关的文件,供人类和 agent 共同读写。
| 子目录 | 内容 | 谁写 | 谁读 |
|--------|------|------|------|
| `design/pdd/` | 方案设计文档(PDD)→ 架构方案、设计决策、转换方案 | proposal→writer pipeline | Think 参考、Build 实现、Vet 审查 |
| `design/prd/` | 需求文档(PRD)→ 功能需求、用户故事、验收标准 | 人写 | Think 分析、Proposal 写方案时参考 |
| `design/prototype/` | **OD 导出的原型 HTML** → 视觉稿、交互原型、页面 layout | OD 桌面版导出 | Think 分析结构、Build 对照实现 |
| `design/notes/` | 笔记记录 → 零散想法、会议纪要、调研速记 | 人写 | 各 agent 参考 |
| `design/roadmap/` | 路线图 → 里程碑规划、版本计划、优先级列表 | 人写 | Proposal 排期参考 |
| `design/DESIGN.md` | 设计系统(品牌规范)→ 色板、字体、间距、语气 | OD 导出 / 人维护 | Think 提取 token、Build 同步到 `src/` |
| `design/tokens.css` | 设计 Token CSS → 从 DESIGN.md 提取的 CSS 变量 | 人同步 / agent 同步 | 所有 Svelte 组件引用 |
**访问规则:**
- 读:所有 agent 默认可读(`read_file` 不需要额外权限)
- 写:writer agent 可通过 `"design/**": allow` 写入 `design/` 下任意子目录
- 注意:`prototype/` 由 OD 桌面版导出,agent 只读不写;`DESIGN.md` 和 `tokens.css` 建议手动维护或 agent 写入时确认后再改
**兜底规则:** 文档类型不在上表时(如教程、接口文档、临时记录),或目标目录不存在时 → **向用户提问确认路径**。不允许自行推断存放位置。
---
+140 -13
View File
@@ -1,29 +1,156 @@
[package]
name = "agcore"
version = "0.3.0"
version = "0.3.5"
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 模块依赖 llmconversation/vector_store 使用 compact/embedding)、tokioknowledge.rs 使用 Mutex)、timetypes.rs 使用 OffsetDateTime
memory = ["document", "llm", "tokio", "time"]
memory-sqlite = ["memory", "rusqlite", "time"]
agent = ["llm", "tools", "memory", "futures-util"]
engine = ["agent"]
# === Provider features ===
# Provider features — openai/anthropic/openai-response 额外依赖 bytes(流式解析)和 futures-utilStream 组合)
provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
# OpenAI Response APIPOST /responses)—— 与 Chat Completions 协议独立,独立 feature
provider-openai-response = ["llm", "reqwest", "bytes", "futures-util"]
# deepseek/qwen 使用 openai_compat 适配层,不需要 bytes 和 futures-util
provider-deepseek = ["llm", "reqwest"]
provider-qwen = ["llm", "reqwest"]
provider-ollama = ["llm", "reqwest"]
# === 工具 features ===
tracing-init = ["tracing-subscriber"]
# === 快捷组合 ===
full = [
"document", "llm-types", "prompt", "llm",
"tools", "tools-mcp",
"memory", "memory-sqlite",
"agent", "engine",
"provider-openai", "provider-anthropic", "provider-openai-response",
"provider-deepseek", "provider-qwen", "provider-ollama",
"tracing-init",
]
light = ["llm", "provider-openai", "tools", "tools-mcp", "memory", "agent", "engine", "prompt", "document"]
chat = ["agent", "provider-openai"]
multi = ["engine", "provider-openai"]
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "stream"] }
# 始终编译的轻量依赖(5 个,不参与门控)
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
async-trait = "0.1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tokio-stream = "0.1"
futures = "0.3"
futures-util = "0.3"
futures-core = "0.3"
bytes = "1"
async-stream = "0.3"
tokio-util = { version = "0.7", features = ["rt"] }
time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"] }
rusqlite = { version = "0.32", features = ["bundled"] }
# 12 个重型依赖(全部 optional
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process", "io-util"], optional = true }
reqwest = { version = "0.12", features = ["json", "stream"], optional = true }
rusqlite = { version = "0.32", features = ["bundled"], optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
tokio-stream = { version = "0.1", optional = true }
futures = { version = "0.3", optional = true }
futures-util = { version = "0.3", optional = true }
futures-core = { version = "0.3", optional = true }
bytes = { version = "1", optional = true }
async-stream = { version = "0.3", optional = true }
tokio-util = { version = "0.7", features = ["rt"], optional = true }
time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"], optional = true }
[dev-dependencies]
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
dotenvy = "0.15.7"
wiremock = "0.6"
temp-env = "0.3"
tempfile = "3"
# === Examples required-features ===
# 每个 example 声明最小 feature 集合,`cargo test --features "full"` 时全部编译;
# 其他组合下不兼容的 example 自动跳过。
[[example]]
name = "prompt_composer"
required-features = ["prompt", "llm"]
[[example]]
name = "custom_tool"
required-features = ["tools", "llm"]
[[example]]
name = "conversation_memory_demo"
required-features = ["memory"]
[[example]]
name = "knowledge_graph_demo"
required-features = ["memory"]
[[example]]
name = "knowledge_search_demo"
required-features = ["memory"]
[[example]]
name = "agent_session_demo"
required-features = ["agent"]
[[example]]
name = "task_agent_demo"
required-features = ["agent"]
[[example]]
name = "context_slot_demo"
required-features = ["agent"]
[[example]]
name = "quick_start"
required-features = ["agent"]
[[example]]
name = "simple_visit"
required-features = ["llm", "provider-openai", "tracing-init"]
[[example]]
name = "streaming_events_demo"
required-features = ["llm", "provider-openai"]
[[example]]
name = "agent_switch_demo"
required-features = ["engine"]
[[example]]
name = "bridge_keys_demo"
required-features = ["engine"]
[[example]]
name = "dispatch_stream_demo"
required-features = ["engine"]
[[example]]
name = "engine_demo"
required-features = ["engine"]
[[example]]
name = "sub_agent_dispatch_demo"
required-features = ["engine"]
[[example]]
name = "document_demo"
required-features = ["memory", "tracing-init"]
[[example]]
name = "end_to_end"
required-features = ["agent", "memory-sqlite", "provider-openai"]
[[example]]
name = "response_api_demo"
required-features = ["llm", "provider-openai-response"]
+135 -23
View File
@@ -26,7 +26,7 @@ AG Core 不是 Agent 产品,而是 Agent 的**底层依赖库**:上层应用
```toml
[dependencies]
agcore = "0.2"
agcore = "0.3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```
@@ -38,7 +38,7 @@ use std::sync::Arc;
use agcore::agent::{Agent, AgentBuilder, AgentSession};
use agcore::llm::hooks::HookExecutor;
use agcore::llm::mock::MockProvider;
use agcore::llm::provider::LlmProvider;
use agcore::llm::LlmProvider;
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
use agcore::llm::types::Usage;
@@ -110,11 +110,11 @@ let provider = create_provider(
).expect("创建 Provider 失败");
```
更多端到端示例见 [`examples/`](./examples/) 目录(共 10 个,全部可 `cargo run --example <name>`):
更多端到端示例见 [`examples/`](./examples/) 目录(全部可 `cargo run --example <name>`):
| 示例 | 说明 |
|------|------|
| `quick_start` | **30 行最小示例**MockProvider + EchoTool + submit_turn,新用户 5 分钟上手 |
| `quick_start` | **最短可运行示例**MockProvider + EchoTool + submit_turn,新用户 5 分钟上手 |
| `end_to_end` | **完整集成示例**3 工具 + 3 轮对话 + SqliteStore 持久化跨连接验证 |
| `agent_session_demo` | Agent + 会话 + SessionMemory 完整链路(MockProvider 离线) |
| `custom_tool` | 自定义工具注册、单次 / 并行调用、权限检查 |
@@ -124,16 +124,111 @@ let provider = create_provider(
| `knowledge_search_demo` | 知识页面关键词检索 |
| `streaming_events_demo` | LLM 流式响应事件消费(含错误路径) |
| `simple_visit` | 真实 LLM 调用(OpenAI / Anthropic,设置 `OPENAI_*` / `ANTHROPIC_*` 环境变量) |
| `document_demo` | 文档分割:RecursiveCharacterSplitter 将长文本切分为可嵌入片段 |
| `knowledge_graph_demo` | 知识图谱:实体-关系 CRUD、BFS 遍历、关键词/标签检索 |
| `context_slot_demo` | 多上下文分区:ContextSlot 分区管理、FocusedConfig 聚焦策略 |
| `sub_agent_dispatch_demo` | 子任务分发:SubTask 异步执行与结果汇聚 |
| `dispatch_stream_demo` | 分发流式输出:SubTaskStreamEvent 实时消费 |
| `engine_demo` | Agent 执行引擎:SessionManager 会话树 + Checkpointer 快照恢复 |
| `bridge_keys_demo` | 桥接键:Agent 间上下文键值透传 |
| `agent_switch_demo` | Agent 热切换:会话中动态切换 Agent 角色 |
| `response_api_demo` | OpenAI Response API`POST /responses`)真实调用 |
## Feature 组合
AG Core 通过 Cargo features 让下游按需选择模块,跳过不需要的编译单元和重型依赖。`default = ["full"]` 保持向后兼容——不指定 features 时行为与 v0.3.0 一致。
### 快捷组合
| 组合 | 场景 | 包含的 features |
|------|------|----------------|
| `full`(default | 全栈使用,兼容 v0.3.0 | 全部 16 个 feature |
| `light` | 生产常用,跳过 Anthropic/DeepSeek/Qwen/Ollama | llm + provider-openai + tools + tools-mcp + memory + agent + engine + prompt + document |
| `chat` | 纯对话(跳过 SQLite 和 MCP | agent + provider-openai |
| `multi` | 多 Agent 复合(chat + subagent + switch + checkpointer | engine + provider-openai |
### Cargo.toml 配置示例
```toml
# 默认全栈(兼容 v0.3.0
[dependencies]
agcore = "0.3"
# 纯对话场景:跳过 SQLite 和 MCP,编译更快
[dependencies]
agcore = { version = "0.3", default-features = false, features = ["chat", "provider-openai"] }
# 生产常用:OpenAI + 工具 + 记忆 + Agent
[dependencies]
agcore = { version = "0.3", default-features = false, features = ["light"] }
# 多 Agent 复合 + MCP 工具
[dependencies]
agcore = { version = "0.3", default-features = false, features = ["multi", "provider-openai", "tools-mcp"] }
```
### 模块级 features
如需更细粒度控制,可单独启用模块级 features:
| Feature | 覆盖内容 | imply |
|---------|---------|-------|
| `document` | Document + RecursiveCharacterSplitter | — |
| `llm-types` | Message / ToolDef / Usage 等 IR 类型 | — |
| `prompt` | PromptTemplate + PromptComposer | `llm-types` |
| `llm` | Provider trait + LlmCycle + hooks + compact + embedding + mock | `llm-types` |
| `tools` | BaseTool + ToolRegistry | `llm-types` |
| `tools-mcp` | McpClientStdio/StreamableHttp | `tools` |
| `memory` | MemoryStore + Conversation + VectorStore + KnowledgeGraph + Retriever | `document` + `llm` |
| `memory-sqlite` | SqliteStore | `memory` |
| `agent` | Agent + Builder + Session + ContextSlot + Summary | `llm` + `tools` + `memory` |
| `engine` | SessionManager + Checkpointer + SubAgent + Switch | `agent` |
| `provider-openai` | OpenAI Provider 实现 | `llm` |
| `provider-anthropic` | Anthropic Provider 实现 | `llm` |
| `provider-openai-response` | OpenAI Response API`POST /responses`Provider 实现 | `llm` |
| `provider-deepseek` | DeepSeek Provider 实现 | `llm` |
| `provider-qwen` | Qwen Provider 实现 | `llm` |
| `provider-ollama` | Ollama Provider 实现 | `llm` |
| `tracing-init` | `init_tracing()` 函数 | — |
## 升级指南(v0.3.0 → v0.3.2
### LlmProvider trait 路径变更
v0.3.2 起,`LlmProvider` trait 及其关联类型 `ProviderCapabilities` / `ProviderFeatures` 从 `provider` 模块移至 `llm` 模块根级别,归属 `#[cfg(feature = "llm")]` 而非 `any(provider-*)`。纯 Mock 场景不再需要引入任何 provider feature。
| 旧路径(v0.3.0 | 新路径(v0.3.2 |
|------------------|------------------|
| `agcore::llm::provider::LlmProvider` | `agcore::llm::LlmProvider` |
| `agcore::llm::provider::ProviderCapabilities` | `agcore::llm::ProviderCapabilities` |
| `agcore::llm::provider::ProviderFeatures` | `agcore::llm::ProviderFeatures` |
**向后兼容**`provider` 模块中保留了 `pub use` 重导出,老路径仍可编译。但推荐迁移至新路径,未来版本可能移除重导出。
`ProviderConfig` / `ProviderType` / `create_provider()` 等 provider 创建逻辑仍在 `agcore::llm::provider` 下,无需迁移。
### 迁移步骤
```bash
# 1. 全局替换 use 路径
sed -i 's/agcore::llm::provider::LlmProvider/agcore::llm::LlmProvider/g' src/**/*.rs
sed -i 's/agcore::llm::provider::{LlmProvider/agcore::llm::{LlmProvider/g' src/**/*.rs
# 2. 验证编译
cargo build --features "full"
```
## 核心模块
| 模块 | 一句话说明 |
|------|----------|
| `agcore::llm` | LLM 调用周期(`LlmProvider` trait + `LlmCycle` 重试/用量 + 流式事件 + auto-compaction + Hook + 公开 `MockProvider` |
| `agcore::llm` | LLM 调用周期(`LlmProvider` trait + `LlmCycle` 重试/用量 + 流式工具循环 + auto-compaction + Hook + `Embedding` trait + 公开 `MockProvider` |
| `agcore::prompt` | 提示词工程(`PromptTemplate` 变量插值 + `PromptTemplateRegistry` + `PromptComposer` 多角色消息构造 + `validate_messages` |
| `agcore::tools` | 工具系统(`BaseTool` trait + `ToolRegistry` 注册/调用 + `PermissionChecker` 黑白名单 + MCP stdio 客户端) |
| `agcore::memory` | 记忆系统(`MemoryStore` trait + `InMemoryStore` 默认实现 + `ConversationMemory` 滑动窗口 + `KnowledgeStore` + `MemoryRetriever` |
| `agcore::agent` | Agent 运行时(`Agent` trait 角色定义 + `AgentBuilder` + `RuntimeBundle` 依赖注入 + `AgentSession` 会话 + `SessionMemory` + `Plan`/`Step` 任务编排) |
| `agcore::memory` | 记忆系统(`MemoryStore` trait + `InMemoryStore` / `SqliteStore` + `ConversationMemory` 滑动窗口 + `KnowledgeStore` + `KnowledgeGraph` 图谱 + `VectorStore` 向量 + `RagPipeline` + `MemoryRetriever` 混合检索 |
| `agcore::agent` | Agent 运行时(`Agent` trait + `AgentBuilder` + `RuntimeBundle` + `AgentSession` + 多上下文分区 + 摘要自动生成 + 快照恢复 + `Plan`/`Step` 任务编排) |
| `agcore::engine` | Agent 执行引擎(`SessionManager` 会话树 + `Checkpointer` time-travel 快照 + 子任务分发 + Agent 热切换) |
| `agcore::document` | 文档分割(`Document` 类型 + `RecursiveCharacterSplitter` 递归字符级分割) |
## 架构关系图
@@ -143,9 +238,14 @@ let provider = create_provider(
└───────────────────────────┬─────────────────────────────────┘
│ 使用
┌───────────────────────────▼─────────────────────────────────┐
│ Agent Runtime (agcore::agent)
│ Agent Engine (agcore::engine)
│ SessionManager / Checkpointer / SubTask 分发 / Agent 切换 │
└───────────────────────────┬─────────────────────────────────┘
│ 编排
┌───────────────────────────▼─────────────────────────────────┐
│ Agent Runtime (agcore::agent) │
│ Agent / AgentBuilder / RuntimeBundle / AgentSession / │
SessionMemory / Plan / Step
ContextSlot / SummaryConfig / Plan / Step │
└─────┬───────────────┬───────────────┬───────────────┬───────┘
│ │ │ │
┌─────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
@@ -154,10 +254,15 @@ let provider = create_provider(
│ llm │ │ prompt │ │ tools │ │ memory │
└─────┬─────┘ └─────────────┘ └─────┬───────┘ └──────┬──────┘
│ │ │
──────────────┬────────────────┘ │
┌─────────────────┐
│ Mock Provider │◄──────────────────────┘
│ ┌──────────┐ │ ┌───────────┴──────
│ Document │ Graph / Vector
│ agcore:: │ │ │ RagPipeline
│ │ document │ │ │ Retriever │
│ └──────────┘ │ └──────────────────┘
└──────────────┬────────────────┘
┌─────────────────┐
│ Mock Provider │
│ 公开 API │ 离线测试 / 示例
└─────────────────┘
```
@@ -166,28 +271,35 @@ let provider = create_provider(
```mermaid
graph BT
LLM["<b>llm</b><br/>Provider / Cycle /<br/>Hooks / Stream /<br/>Compact / Mock"]:::core
LLM["<b>llm</b><br/>Provider / Cycle /<br/>Hooks / Stream /<br/>Compact / Embedding /<br/>Mock"]:::core
Prompt["<b>prompt</b><br/>Template / Composer"]:::core
Tool["<b>tools</b><br/>BaseTool / Registry /<br/>Permission / MCP"]:::core
Memory["<b>memory</b><br/>Store / Conversation /<br/>Knowledge / Retriever"]:::core
Agent["<b>agent</b><br/>Agent / Builder /<br/>Session / Plan"]:::core
Tool["<b>tools</b><br/>BaseTool / Registry /<br/>MCP"]:::core
Memory["<b>memory</b><br/>Store / Conversation /<br/>Knowledge / Graph /<br/>VectorStore / RagPipeline /<br/>Retriever"]:::core
Agent["<b>agent</b><br/>Agent / Builder /<br/>Session / ContextSlot /<br/>Summary / Plan"]:::core
Engine["<b>engine</b><br/>SessionManager /<br/>Checkpointer /<br/>SubTask / Switch"]:::phase
Document["<b>document</b><br/>Splitter"]:::phase
Prompt --> LLM
Tool --> LLM
Memory --> LLM
Memory --> Document
Document --> LLM
Agent --> LLM
Agent --> Tool
Agent --> Memory
Engine --> Agent
classDef core fill:#60a5fa,stroke:#2563eb,color:#fff
classDef phase fill:#a78bfa,stroke:#7c3aed,color:#fff
```
**依赖规则**
- `llm` 是叶子,被其他四个模块使用
- `prompt` / `tools` / `memory` 互相不依赖,可独立使用
- `agent` 编译期依赖 `llm` / `tools` / `memory`,与 `prompt` 无直接编译依赖(system prompt 以 `&str` 形式传入)
- 上层应用只应依赖 `agent` + 必要的子模块,不应跨层直接 `use`
- `llm` 被 `agent` / `engine` 使用,同时 `llm::cycle` 依赖 `tools::ToolRegistry`(用于工具调用循环)
- `prompt` / `tools` 互不依赖,可独立使用;`memory` 依赖 `document`(向量存储的分割器)和 `llm`embedding trait
- `agent` 编译期依赖 `llm` / `tools` / `memory`,与 `prompt` / `document` 无直接编译依赖(system prompt 以 `&str` 形式传入,文档分割由应用层处理
- `engine` 编译期依赖 `agent`,提供会话树管理和快照恢复能力
- 上层应用通常依赖 `engine`(完整能力)或 `agent`(轻量场景),不应跨层直接 `use`
## 环境变量
@@ -201,7 +313,7 @@ graph BT
| `ANTHROPIC_API_KEY` | 用 Anthropic 时 | — | Anthropic Claude API key |
| `ANTHROPIC_BASE_URL` | 是 | `https://api.anthropic.com` | Anthropic 兼容端点 base URL |
| `ANTHROPIC_MODEL` | 是 | `claude-3-5-sonnet-latest` | Claude 模型名 |
| `PROVIDER` | 否 | `openai` | Provider 类型:`openai` / `openai-response` / `anthropic` / `deepseek` / `qwen` |
| `PROVIDER` | 否 | `openai` | Provider 类型:`openai` / `anthropic` / `deepseek` / `qwen` / `ollama` |
| `RUST_LOG` | 否 | `agcore=info` | tracing 日志级别(其他 crate 可加 `=debug` |
示例(运行 `examples/simple_visit.rs` 真实调用 OpenAI):
@@ -226,7 +338,7 @@ AG Core 在 Phase 4 设计阶段调研了 4 个 2026 年公开的 AI Agent 项
| [OpenHuman](https://github.com/tinyhumansai/openhuman) | 桌面助手 | Rust + Tauri | 记忆树与 Token 压缩 |
| [OpenHarness](https://github.com/HKUDS/OpenHarness) | Agent Harness 框架 | Python | 显式依赖注入容器 + 三级权限 |
AG Core 不"抄代码",只参考架构模式。当前实现已经采纳
AG Core 借鉴了以下架构模式(不直接复用代码)
- **OpenHarness 风格** —— 显式 `RuntimeBundle` 依赖注入容器
- **Hermes 风格** —— `Agent` trait(角色)与 `AgentSession`(会话)解耦
View File
@@ -0,0 +1,694 @@
# AG Core v0.3.2 Step 1Phase 20)— Cargo Features 基础设施改造实施方案
## 1. 背景与目标
**背景**agcore 是一个 Rust 编写的智能体核心工具箱,目前约 23,718 行、66 个源文件。v0.3.0 发布后,所有模块在编译时全量捆绑,下游用户无法按需选择模块,即使只使用 LLM 对话也需要编译 sqlite / MCP / agent 引擎等全部依赖。
**目标**:通过 Cargo features 拆分让下游按需选择模块。Step 1 是基础设施变更 —— `Cargo.toml` features 定义 + 依赖 optional 化 + 必要的子模块 cfg 门控,编译通过后打 checkpoint。
**预期效果**
- `default = ["full"]` → v0.3.0 用户零迁移成本
- 最小组合(`document`)零重型外部依赖(仅依赖始终编译的轻量依赖:serde/serde_json/thiserror/async-trait/tracing
- 纯对话组合(`chat + provider-openai`)仅需 ~10 个依赖,不含 sqlite / MCP / engine
## 2. 需求分析
### 2.1 约束条件
| # | 约束 | 说明 |
|---|------|------|
| 1 | `default = ["full"]` | 保持向后兼容,v0.3.0 用户零迁移成本 |
| 2 | `document` feature 零重型外部依赖(仅依赖始终编译的轻量依赖:serde/serde_json/thiserror/async-trait/tracing | 纯 std + 始终编译的轻量依赖(serde/serde_json/thiserror/async-trait/tracing |
| 3 | tokio 从 `["full"]` 拆细 | 已验证全库无 net/fs/signal 使用,拆为 `["rt", "sync", "time", "macros", "process", "io-util"]` |
| 4 | 重型依赖全部 optional | tokio、reqwest、rusqlite、tracing-subscriber、tokio-stream、futures、futures-util、futures-core、bytes、async-stream、tokio-util、time |
| 5 | 始终编译的轻量依赖 | serde、serde_json、thiserror、async-trait、tracing |
### 2.2 关键决策
| # | 决策 | 理由 |
|---|------|------|
| 1 | `tools` feature 必须 `imply tokio` | `src/tools/registry.rs` 使用 `tokio::time::timeout` |
| 2 | `pub mod llm` 门控条件为 `any(feature = "llm-types", feature = "llm")` | `prompt → llm-types` 路径需要 llm 模块编译,但只需 types 子模块 |
| 3 | 测试 dev-dependencies 加 `tokio = { version = "1", features = ["rt", "macros"] }` | 现有 `#[tokio::test]` 需要 tokio runtime |
| 4 | 快捷组合名保持原名(chat/multi/light | 文档中说明各组合包含的 feature 约束 |
| 5 | `init_tracing()` 函数整体用 `#[cfg(feature = "tracing-init")]` 包裹 | 避免 `use tracing_subscriber` 出现在未启用 feature 时编译失败 |
## 3. 方案设计
### 3.1 Features 定义(完整 Cargo.toml `[features]` 草案)
```toml
[features]
default = ["full"]
# === 模块级 features ===
document = []
llm-types = []
prompt = ["llm-types"]
llm = ["llm-types", "tokio", "async-stream", "futures-core", "tokio-stream"]
tools = ["llm-types", "futures", "tokio-util", "tokio"]
tools-mcp = ["tools", "reqwest"]
# memory 模块依赖 llmconversation/vector_store 使用 compact/embedding)、tokioknowledge.rs 使用 Mutex)、timetypes.rs 使用 OffsetDateTime
memory = ["document", "llm", "tokio", "time"]
memory-sqlite = ["memory", "rusqlite", "time"]
agent = ["llm", "tools", "memory", "futures-util"]
engine = ["agent"]
# === Provider features ===
# Provider features — openai/anthropic 额外依赖 bytes(流式解析)和 futures-utilStream 组合)
provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
# deepseek/qwen 使用 openai_compat 适配层,不需要 bytes 和 futures-util
provider-deepseek = ["llm", "reqwest"]
provider-qwen = ["llm", "reqwest"]
provider-ollama = ["llm", "reqwest"]
# === 工具 features ===
tracing-init = ["tracing-subscriber"]
# === 快捷组合 ===
full = [
"document", "llm-types", "prompt", "llm",
"tools", "tools-mcp",
"memory", "memory-sqlite",
"agent", "engine",
"provider-openai", "provider-anthropic", "provider-deepseek",
"provider-qwen", "provider-ollama",
"tracing-init",
]
light = ["llm", "provider-openai", "tools", "tools-mcp", "memory", "agent", "engine", "prompt", "document"]
chat = ["agent", "provider-openai"]
multi = ["engine", "provider-openai"]
```
**features 依赖图(简略)**
```
document (零外部依赖)
└── memory (+llm, +tokio, +time) ─── memory-sqlite (+rusqlite, +time)
llm-types (零依赖)
├── prompt
└── llm (+tokio, +async-stream, +futures-core, +tokio-stream)
├── tools (+futures, +tokio-util) ─── tools-mcp (+reqwest)
├── provider-openai / provider-anthropic (+reqwest, +bytes, +futures-util)
├── provider-deepseek / provider-qwen / provider-ollama (+reqwest)
└── agent (+tools, +memory, +futures-util) ─── engine
```
### 3.2 依赖 optional 化方案
**始终编译(5 个,不参与门控)**
```toml
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
async-trait = "0.1"
tracing = "0.1"
```
**12 个依赖加 `optional = true`**
| 依赖 | 原声明 | 新声明 |
|------|--------|--------|
| tokio | `{ version = "1", features = ["full"] }` | `{ version = "1", features = ["rt", "sync", "time", "macros", "process", "io-util"], optional = true }` |
| reqwest | `{ version = "0.12", features = ["json", "stream"] }` | `{ version = "0.12", features = ["json", "stream"], optional = true }` |
| rusqlite | `{ version = "0.32", features = ["bundled"] }` | `{ version = "0.32", features = ["bundled"], optional = true }` |
| tracing-subscriber | `{ version = "0.3", features = ["env-filter"] }` | `{ version = "0.3", features = ["env-filter"], optional = true }` |
| tokio-stream | `{ version = "0.1" }` | `{ version = "0.1", optional = true }` |
| futures | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
| futures-util | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
| futures-core | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
| bytes | `{ version = "1" }` | `{ version = "1", optional = true }` |
| async-stream | `{ version = "0.3" }` | `{ version = "0.3", optional = true }` |
| tokio-util | `{ version = "0.7", features = ["rt", "sync"] }` | `{ version = "0.7", features = ["rt", "sync"], optional = true }` |
| time | `{ version = "0.3", features = ["serde", "parsing", "formatting", "macros"] }` | `{ version = "0.3", features = ["serde", "parsing", "formatting", "macros"], optional = true }` |
**dev-dependencies 新增**
```toml
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros"] }
```
### 3.3 源文件改动清单
共涉及 **8 个文件**(预估 ~100 行改动):`Cargo.toml``src/lib.rs``src/llm.rs``src/llm/cycle.rs``src/tools.rs``src/memory.rs``src/memory/store.rs``src/agent/session.rs`
---
#### 文件 1`Cargo.toml`
**改动 1.1** — 新增 `[features]` 表(约 45 行,插入在 `[package]` 之后、`[dependencies]` 之前)
```diff
+ [features]
+ default = ["full"]
+
+ # === 模块级 features ===
+ document = []
+ llm-types = []
+ prompt = ["llm-types"]
+ llm = ["llm-types", "tokio", "async-stream", "futures-core", "tokio-stream"]
+ tools = ["llm-types", "futures", "tokio-util", "tokio"]
+ tools-mcp = ["tools", "reqwest"]
+ # memory 模块依赖 llmconversation/vector_store 使用 compact/embedding)、tokioknowledge.rs 使用 Mutex)、timetypes.rs 使用 OffsetDateTime
+ memory = ["document", "llm", "tokio", "time"]
+ memory-sqlite = ["memory", "rusqlite", "time"]
+ agent = ["llm", "tools", "memory", "futures-util"]
+ engine = ["agent"]
+
+ # === Provider features ===
+ # Provider features — openai/anthropic 额外依赖 bytes(流式解析)和 futures-utilStream 组合)
+ provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
+ provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
+ # deepseek/qwen 使用 openai_compat 适配层,不需要 bytes 和 futures-util
+ provider-deepseek = ["llm", "reqwest"]
+ provider-qwen = ["llm", "reqwest"]
+ provider-ollama = ["llm", "reqwest"]
+
+ # === 工具 features ===
+ tracing-init = ["tracing-subscriber"]
+
+ # === 快捷组合 ===
+ full = [
+ "document", "llm-types", "prompt", "llm",
+ "tools", "tools-mcp",
+ "memory", "memory-sqlite",
+ "agent", "engine",
+ "provider-openai", "provider-anthropic", "provider-deepseek",
+ "provider-qwen", "provider-ollama",
+ "tracing-init",
+ ]
+ light = ["llm", "provider-openai", "tools", "tools-mcp", "memory", "agent", "engine", "prompt", "document"]
+ chat = ["agent", "provider-openai"]
+ multi = ["engine", "provider-openai"]
```
**改动 1.2** — tokio 依赖声明修改
```diff
- tokio = { version = "1", features = ["full"] }
+ tokio = { version = "1", features = ["rt", "sync", "time", "macros", "process", "io-util"], optional = true }
```
**改动 1.3** — 11 个重型依赖逐行加 `optional = true`
```diff
- reqwest = { version = "0.12", features = ["json", "stream"] }
+ reqwest = { version = "0.12", features = ["json", "stream"], optional = true }
- rusqlite = { version = "0.32", features = ["bundled"] }
+ rusqlite = { version = "0.32", features = ["bundled"], optional = true }
- tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+ tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
- tokio-stream = "0.1"
+ tokio-stream = { version = "0.1", optional = true }
- futures = "0.3"
+ futures = { version = "0.3", optional = true }
- futures-util = "0.3"
+ futures-util = { version = "0.3", optional = true }
- futures-core = "0.3"
+ futures-core = { version = "0.3", optional = true }
- bytes = "1"
+ bytes = { version = "1", optional = true }
- async-stream = "0.3"
+ async-stream = { version = "0.3", optional = true }
- tokio-util = { version = "0.7", features = ["rt"] }
+ tokio-util = { version = "0.7", features = ["rt", "sync"], optional = true }
- time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"] }
+ time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"], optional = true }
```
**改动 1.4**`[dev-dependencies]` 新增 tokio
```diff
+ [dev-dependencies]
+ tokio = { version = "1", features = ["rt", "macros"] }
```
**说明**:如果原 `Cargo.toml` 已有 `[dev-dependencies]` 则追加该行;若无则新增整个 section。
---
#### 文件 2`src/lib.rs`(当前约 26 行 → 改动后约 40 行)
**当前内容(参考)**
```rust
//! agcore —— 智能体(Agent)核心工具箱。
pub mod llm;
pub mod document;
pub mod prompt;
pub mod tools;
pub mod memory;
pub mod agent;
pub mod engine;
pub use document::Document;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
static INIT: std::sync::Once = std::sync::Once::new();
pub fn init_tracing() {
INIT.call_once(|| {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("agcore=info"));
tracing_subscriber::registry()
.with(fmt::layer())
.with(filter)
.init();
});
}
```
**改动后内容**
```diff
//! agcore —— 智能体(Agent)核心工具箱。
- pub mod llm;
+ #[cfg(any(feature = "llm-types", feature = "llm"))]
+ pub mod llm;
- pub mod document;
+ #[cfg(feature = "document")]
+ pub mod document;
- pub mod prompt;
+ #[cfg(feature = "prompt")]
+ pub mod prompt;
- pub mod tools;
+ #[cfg(feature = "tools")]
+ pub mod tools;
- pub mod memory;
+ #[cfg(feature = "memory")]
+ pub mod memory;
- pub mod agent;
+ #[cfg(feature = "agent")]
+ pub mod agent;
- pub mod engine;
+ #[cfg(feature = "engine")]
+ pub mod engine;
- pub use document::Document;
+ #[cfg(feature = "document")]
+ pub use document::Document;
- use tracing_subscriber::{EnvFilter, fmt, prelude::*};
- static INIT: std::sync::Once = std::sync::Once::new();
- pub fn init_tracing() {
- INIT.call_once(|| {
- let filter = EnvFilter::try_from_default_env()
- .unwrap_or_else(|_| EnvFilter::new("agcore=info"));
- tracing_subscriber::registry()
- .with(fmt::layer())
- .with(filter)
- .init();
- });
- }
+ #[cfg(feature = "tracing-init")]
+ use tracing_subscriber::{EnvFilter, fmt, prelude::*};
+
+ #[cfg(feature = "tracing-init")]
+ static INIT: std::sync::Once = std::sync::Once::new();
+
+ #[cfg(feature = "tracing-init")]
+ pub fn init_tracing() {
+ INIT.call_once(|| {
+ let filter = EnvFilter::try_from_default_env()
+ .unwrap_or_else(|_| EnvFilter::new("agcore=info"));
+ tracing_subscriber::registry()
+ .with(fmt::layer())
+ .with(filter)
+ .init();
+ });
+ }
```
---
#### 文件 3`src/llm.rs`(当前约 12 行 → 改动后约 24 行)
**改动说明**:为每个子模块声明加 feature 门控。`types` 子模块在 `llm-types``llm` 任一 feature 启用时编译(`llm` imply `llm-types`,但 `prompt` 也 depend on `llm-types`);其余子模块(compact/convert/cycle 等)仅在 `llm` feature 启用时编译。
```diff
//! LLM 调用周期 —— 大模型基础调用周期控制。
- pub mod types;
+ #[cfg(feature = "llm-types")]
+ pub mod types;
- pub mod compact;
+ #[cfg(feature = "llm")]
+ pub mod compact;
- pub mod convert;
+ #[cfg(feature = "llm")]
+ pub mod convert;
- pub mod cycle;
+ #[cfg(feature = "llm")]
+ pub mod cycle;
- pub mod embedding;
+ #[cfg(feature = "llm")]
+ pub mod embedding;
- pub mod error;
+ #[cfg(feature = "llm")]
+ pub mod error;
- pub mod hooks;
+ #[cfg(feature = "llm")]
+ pub mod hooks;
- pub mod mock;
+ #[cfg(feature = "llm")]
+ pub mod mock;
- pub mod provider;
+ // provider 模块依赖 reqwest(通过 reqwest::Client),仅在任一 provider feature 启用时编译
+ #[cfg(any(feature = "provider-openai", feature = "provider-anthropic", feature = "provider-deepseek", feature = "provider-qwen", feature = "provider-ollama"))]
+ pub mod provider;
- pub mod stream;
+ #[cfg(feature = "llm")]
+ pub mod stream;
```
---
#### 文件 3b`src/llm/cycle.rs`(新增文件,约 35 行)
**改动说明**`cycle.rs` 中使用 `crate::tools::ToolRegistry`(第 29 行),依赖 `tools` feature。工具相关字段和方法需加 `#[cfg(feature = "tools")]` 门控。
> ⚠️ 这是 Phase 22.7 原计划的门控变更,因为编译阻塞提前到 Step 1 执行。
```diff
//! Cycle —— 多轮对话与工具调用编排。
use async_trait::async_trait;
use futures::StreamExt; // 来自 llm→tokio imply 链
+ #[cfg(feature = "tools")]
use crate::tools::ToolRegistry;
// ... struct / enum 定义 ...
// ===== CycleConfig — 工具相关字段加 cfg 门控 =====
pub struct CycleConfig {
pub max_retries: usize,
pub max_history: usize,
+ #[cfg(feature = "tools")]
pub max_tool_turns: usize,
+ #[cfg(feature = "tools")]
pub tool_timeout_secs: u64,
// ... 其他字段 ...
}
// ===== Cycle — 方法加 cfg 门控 =====
impl Cycle {
/// 仅在有 tools feature 时才有工具调用相关方法
+ #[cfg(feature = "tools")]
pub async fn submit_with_tools(&self, ...) -> Result<...> {
// ...
}
+ /// submit_with_tools_stream 方法同样需要 tools 门控,
+ /// 因参数包含 Arc<ToolRegistry> 而与 submit_with_tools 同理。
+ #[cfg(feature = "tools")]
+ pub async fn submit_with_tools_stream(
+ &self, ... // 方法签名中包含 Arc<ToolRegistry> 参数
+ ) -> Result<...> {
+ // ...
+ }
+ #[cfg(feature = "tools")]
async fn run_tool_loop(&self, ...) -> Result<...> {
// ...
}
}
+ // ===== 顶层函数 — 同样依赖 ToolRegistry =====
+ /// run_tool_loop 函数(顶层函数,非 LlmCycle 方法)同样依赖 Arc<ToolRegistry>
+ /// 参数包含 Arc<ToolRegistry>,需 #[cfg(feature = "tools")]。
+ #[cfg(feature = "tools")]
+ pub async fn run_tool_loop(
+ // ... 函数签名中包含 Arc<ToolRegistry> 参数
+ ) -> Result<...> {
+ // ...
+ }
**说明**`Cycle` 本身的 struct 定义、`submit()` 基础方法、`ResponseStream` 等不依赖 tools 的部分保持无门控,仅在 `llm` feature 下编译即可。
---
#### 文件 4`src/tools.rs`(当前约 13 行 → 改动后约 15 行)
**改动说明**`mcp` 子模块及对应的 `pub use` 仅在 `tools-mcp` feature 启用时编译。其余子模块(base/error/permission/registry)始终在 `tools` feature 下编译。
```diff
//! 工具系统 —— 工具抽象、注册、调用、权限控制与 MCP 集成。
pub mod base;
pub mod error;
- pub mod mcp;
+ #[cfg(feature = "tools-mcp")]
+ pub mod mcp;
pub mod permission;
pub mod registry;
pub use base::{BaseTool, ToolContext, ToolRef};
pub use error::ToolError;
- pub use mcp::{McpClient, McpTransport};
+ #[cfg(feature = "tools-mcp")]
+ pub use mcp::{McpClient, McpTransport};
pub use permission::{Permission, PermissionChecker, PermissionConfig};
pub use registry::{ToolInvocation, ToolRegistry};
```
---
#### 文件 5`src/memory/store.rs`(当前约 62 行 → 改动后约 64 行)
**改动说明**`sqlite_store` 子模块及其 `pub use` 仅在 `memory-sqlite` feature 启用时编译。
```diff
//! MemoryStore 抽象接口与默认实现。
use async_trait::async_trait;
use crate::memory::error::MemoryError;
use crate::memory::types::{MemoryFilter, MemoryItem};
pub mod in_memory;
- pub mod sqlite_store;
+ #[cfg(feature = "memory-sqlite")]
+ pub mod sqlite_store;
pub use in_memory::InMemoryStore;
- pub use sqlite_store::SqliteStore;
+ #[cfg(feature = "memory-sqlite")]
+ pub use sqlite_store::SqliteStore;
```
**说明**`MemoryStore` trait、`EvictionConfig``EvictionPolicy` 等定义保持不变,不需要 cfg 门控。
---
#### 文件 6`src/memory.rs`(当前约 32 行 → 改动后约 34 行)
**改动说明**`SqliteStore` 的重新导出仅在 `memory-sqlite` feature 启用时编译。其余子模块声明和 `pub use` 保持不变(`memory` feature 门控由 `src/lib.rs` 负责)。
```diff
//! 记忆系统 —— 对话消息管理、知识页面存储与关键词检索。
// 所有子模块声明保持不变:
// pub mod conversation;
// pub mod error;
// pub mod graph;
// pub mod knowledge;
// pub mod retriever;
// pub mod store;
// ...
// 高频类型
pub use conversation::{ConversationMemory, ConversationMemoryConfig};
pub use error::MemoryError;
pub use graph::{GraphEntity, GraphRelation, InMemoryGraph, KnowledgeGraph, RelationDirection, ScoredEntity};
pub use knowledge::KnowledgeStore;
pub use retriever::MemoryRetriever;
pub use store::{InMemoryStore, MemoryStore};
+ #[cfg(feature = "memory-sqlite")]
+ pub use store::SqliteStore;
// 其余 pub use 保持不变...
```
---
#### 文件 7`src/agent/session.rs`(新增文件,约 30 行)
**改动说明**`session.rs` 中引用了 `crate::engine::*`SessionMemoryEntry、SessionSnapshot、EngineError),而 `agent` feature 不含 `engine``engine = ["agent"]` 是反向依赖)。需要对 engine 相关导入和方法加门控。
> ⚠️ 阻塞 B3`src/agent/session.rs:28-29` 无条件引用 `crate::engine::*`,在 `agent` feature 下编译时因缺少 engine 而失败。
```diff
//! Session —— Agent 会话管理。
use async_trait::async_trait;
use crate::llm::types::LLMRequest;
use crate::memory::MemoryStore;
+ #[cfg(feature = "engine")]
use crate::engine::snapshot::{SessionMemoryEntry, SessionSnapshot};
+ #[cfg(feature = "engine")]
use crate::engine::EngineError;
// ===== AgentSession — pending_memory_restore 字段 =====
+ /// AgentSession 结构体中的 pending_memory_restore 字段类型来自 engine 模块,
+ /// 需要条件编译。
pub struct AgentSession {
+ // ... 其他字段 ...
+
+ #[cfg(feature = "engine")]
+ pending_memory_restore: Option<HashMap<String, SessionMemoryEntry>>,
+ // ... 其他字段 ...
+ }
impl Session {
/// to_snapshot / from_snapshot / restore_memory 仅在 engine feature 下可用
+ #[cfg(feature = "engine")]
pub fn to_snapshot(&self) -> SessionSnapshot {
// ...
}
+ #[cfg(feature = "engine")]
pub fn from_snapshot(snap: SessionSnapshot) -> Result<Self, EngineError> {
// ...
}
+ #[cfg(feature = "engine")]
async fn restore_memory(&mut self, entries: Vec<SessionMemoryEntry>) -> Result<(), MemoryError> {
// ...
}
}
```
**说明**`Session` 结构体本身以及不依赖 engine 的方法(如 `new()``add_message()``get_history()`)保持无门控,仅在 `agent` feature 下编译即可。
---
## 4. 实施步骤
**3 个 commit** 粒度执行,每个 commit 后编译验证。
### Commit 1Cargo.toml features 定义 + 依赖 optional 化
**涉及文件**:仅 `Cargo.toml`
**操作清单**
1.`[package]` 之后、`[dependencies]` 之前插入 `[features]` 表(16 个 features + 4 个快捷组合,约 45 行)
2. tokio features 从 `["full"]` 改为 `["rt", "sync", "time", "macros", "process", "io-util"]` 并加 `optional = true`
3. reqwest / rusqlite / tracing-subscriber / tokio-stream / futures / futures-util / futures-core / bytes / async-stream / tokio-util / time 共 11 个依赖加 `optional = true`
4.`[dependencies]` 之后新增 `[dev-dependencies]``tokio = { version = "1", features = ["rt", "macros"] }`
**验证**
```bash
cargo build --no-default-features # 不依赖任何 optional crate,应通过
cargo build -F document # 零外部依赖,应通过
```
---
### Commit 2cfg 门控(pub mod + pub use + init_tracing
**涉及文件**`src/lib.rs``src/llm.rs``src/llm/cycle.rs``src/tools.rs``src/memory/store.rs``src/memory.rs``src/agent/session.rs`
**操作清单**
按文件逐一执行:
1. `src/lib.rs` — 7 个 `pub mod``#[cfg(feature = "...")]``Document` pub use 加 cfg、`init_tracing` 整体用 `#[cfg(feature = "tracing-init")]` 包裹
2. `src/llm.rs` — 10 个子模块按 `llm-types` / `llm` / provider 分类门控
3. `src/llm/cycle.rs``ToolRegistry` 导入加 `#[cfg(feature = "tools")]`,工具字段和方法加相同门控
4. `src/tools.rs``pub mod mcp``pub use mcp::*``#[cfg(feature = "tools-mcp")]`
5. `src/memory/store.rs``pub mod sqlite_store``pub use sqlite_store::SqliteStore``#[cfg(feature = "memory-sqlite")]`
6. `src/memory.rs``pub use store::SqliteStore``#[cfg(feature = "memory-sqlite")]`
7. `src/agent/session.rs` — engine 相关导入加 `#[cfg(feature = "engine")]`to_snapshot/from_snapshot/restore_memory 加相同门控
**验证**
```bash
cargo build -F "full" # 全量回归
cargo build -F "prompt" # 验证 llm::types imply 路径
cargo build -F "tools" # 验证 tokio imply 路径
cargo build -F "memory" # 验证记忆模块不含 sqlite
cargo build -F "chat,provider-openai" # 纯对话组合
cargo build -F "chat,provider-openai,tools-mcp" # 带 MCP 对话
```
---
### Commit 3Checkpoint 全量验证
**操作清单**
1. 完整的验证矩阵执行(见第 5 节)
2. `cargo test -F "full"` 确认 427 passed
**验证**
```bash
cargo test -F "full"
cargo build -F "light"
cargo build -F "multi"
```
## 5. 验证标准
### 编译验证矩阵
| 命令 | 验证目标 | 预期结果 |
|------|---------|---------|
| `cargo build --no-default-features` | 空 crate | 编译通过(无模块) |
| `cargo build -F "full"` | 全量回归 | 编译通过,与 v0.3.0 语义一致 |
| `cargo test -F "full"` | 测试回归 | `427 passed` |
| `cargo build -F "document"` | 文档模块独立 | 编译通过,零外部依赖 |
| `cargo build -F "prompt"` | 提示词独立 | 编译通过,`llm::types` imply 路径正确 |
| `cargo build -F "tools"` | 工具独立 | 编译通过,tokio imply 路径正确 |
| `cargo build -F "memory"` | 记忆模块独立编译 | ✅ 不含 sqlite(依赖 B1/B2 修复) |
| `cargo build -F "memory-sqlite"` | 含 SQLite 的记忆模块 | ✅ 含 rusqlite |
| `cargo build -F "agent"` | Agent 独立编译 | ✅ 含 llm+tools+memory,不含 engine(依赖 B1/B3 修复) |
| `cargo build -F "engine"` | Engine 独立编译 | ✅ imply agent → llm+tools+memory |
| `cargo build -F "chat,provider-openai"` | 纯对话组合 | 编译通过,不含 MCP、sqlite |
| `cargo build -F "chat,provider-openai,tools-mcp"` | 带 MCP 对话 | 编译通过,含 reqwest 无 sqlite |
| `cargo build -F "light"` | 生产常用组合 | 编译通过 |
| `cargo build -F "multi"` | 多 provider 组合 | 编译通过 |
### 验证操作指令
每次编译验证后执行(验证编译产物不含意外符号):
```bash
# 确认空 crate 确实没有模块符号
cargo build --no-default-features 2>&1 && echo "OK"
# 确认 document 零外部依赖(无 reqwest/rusqlite 等符号)
cargo build -F "document" 2>&1 && echo "OK"
# 全量构建 + 测试
cargo build -F "full" 2>&1 && cargo test -F "full" 2>&1 | tail -5
```
### 验证通过条件
- 所有 14 条编译验证命令返回 exit code 0
- `cargo test -F "full"` 输出 `427 passed`(与 v0.3.0 基线一致,不要求测试数精确匹配,但必须全部通过且数量合理)
-`unused import` / `unused variable` / `dead code` warning(由 `#[cfg]` 引起的新 warning 需逐一修复)
## 6. 风险评估
| 风险 | 等级 | 缓解措施 |
|------|------|---------|
| **tokio features 拆细遗漏**:某些代码路径用到 net/fs/signal | 低 | 已通过 SA(静态分析)验证全库无相关使用 |
| **`#[tokio::test]` 编译失败**:测试代码无 tokio runtime | 低 | `[dev-dependencies]` 添加 `tokio = { version = "1", features = ["rt", "macros"] }` |
| **下游 transitive tokio features 缩小**:依赖 agcore 的 crate 之前通过 agcore 间接获得 `full` tokio,现在范围缩小 | 中 | Phase 27 README 发布说明中明确告知迁移方案;下游如需完整 tokio 需自行添加 |
| **imply 链未闭合**:某个 feature 依赖了未 imply 的 feature | 低 | 7 种特征组合全部逐条构建验证;features 定义中有交叉引用的全部显式列出 |
| **unused cfg warning**:某些 `#[cfg]` 标记导致编译 warning | 低 | 每个 commit 后检查编译器输出,发现后立即修复 |
| **测试依赖循环**dev-deps 与普通 deps 版本冲突 | 低 | dev-deps 的 tokio 版本与主依赖保持一致 (`version = "1"`,由 cargo 自动选择兼容版本) |
| **memory 跨模块 imply 链** | **高** | memory 模块依赖 llmconversation/vector_store)、tokioknowledge)、timetypes),imply 链必须完整传递 | `memory` feature 定义已包含 `llm``tokio``time`;验证矩阵覆盖 memory 独立编译 |
| **跨模块引用未门控** | **高** | provider.rs 依赖 reqwest、cycle.rs 依赖 ToolRegistry、session.rs 依赖 engineStep 1 必须添加 cfg 门控 | provider 模块 cfg 改为 provider-xxx 条件;cycle.rs 加 tools 门控;session.rs 加 engine 门控 |
@@ -0,0 +1,424 @@
# AG Core v0.3.2 Step 3Phase 2627)— 验证固化 + 文档更新实施方案
## 1. 背景与目标
**背景**agcore v0.3.2 Step 1Phase 2025)已交付 —— Cargo features 拆分基础设施改造全部完成,所有模块 `#[cfg]` 门控注入完毕,依赖全部 optional 化,`default = ["full"]` 保持向后兼容。当前项目处于已改造完成但未经 CI 固化、无文档指引的状态。
**当前状态快照**
- v0.3.0 → v0.3.2 Step 168 个源文件,23,765 行
- 16 个 features10 模块级 + 5 provider + 1 工具)+ 4 个快捷组合
- `cargo test --features "full"`427 passed
- 7 种 feature 组合的 `cargo test --lib` 已全部通过(full / light / chat / chat+mcp / multi / multi+mcp / clippy),无需修复 cfg 遗漏
-`cargo test`(不带 `--lib`)会因 18 个 example 缺少 `required-features` 而失败
- 项目无 CI/CD 配置
**目标**:通过 Step 3 将 features 体系验证固化到 CI 中,消除编译死代码警告,完成文档指引,使 v0.3.2 达到可发布状态。
**预期效果**
- 每次提交自动验证 7 种 feature 组合的编译 + 测试(零 warning)
- 18 个 example 各自标注准确的 `required-features`,外树用户可一键运行
- `cargo clippy --all-features -- -D warnings` 零告警
- README 含完整 feature 表 + `Cargo.toml` 配置示例 + 升级指南,新用户 5 分钟内可选定组合
- roadmap 同步更新
## 2. 需求分析
### 2.1 功能需求
| # | 需求 | 说明 | 对应工作 |
|---|------|------|---------|
| F1 | LlmProvider trait 不应依赖具体 provider feature | trait 自身不依赖 reqwest 或任何 provider 实现,纯 Mock 场景也应可用 | 工作 0 |
| F2 | 每个 example 通过 `cargo run --example xxx` 正确编译 | 18 个 example 各有精确的最小 features 声明 | 工作 1 |
| F3 | CI 自动验证 7 种特征组合的编译与测试 | push / PR 触发 | 工作 2 |
| F4 | 所有 feature 组合下 0 个编译器警告 | 消除 dead_code 等警告 | 工作 3 |
| F5 | README 提供完整的 feature 选择指引 | 表格 + 场景推荐 + Cargo.toml 示例 | 工作 5Phase 27 |
| F6 | example 文件顶部标注所需 features | 用户可一键复制运行命令 | 工作 5(Phase 27 |
| F7 | roadmap 状态同步 | 总入口 + v0.3.2 子文档 | 工作 5Phase 27 |
### 2.2 非功能需求
| # | 需求 | 指标 | 对应工作 |
|---|------|------|---------|
| N1 | 向后兼容 | `default = ["full"]` 行为与 v0.3.0 一致,427 tests passed | 全部 |
| N2 | CI 时效 | 全矩阵 ≤ 10 分钟 | 工作 2 |
| N3 | 最少侵入 | 不改动功能逻辑,仅 cfg / 配置 / 文档变更 | 全部 |
### 2.3 推演概要
**需求拆解**:从当前编译验证结果出发,发现三类待解决问题:
1. **架构归属问题**——`LlmProvider` trait 定义在 provider 模块门控下,语义上应归属 `llm` 基础设施。同时其返回类型 `ProviderCapabilities` / `ProviderFeatures` 也必须一并移出
2. **example 可编译性问题**——18 个 example 无 `required-features`,多组合下 `cargo test` 失败
3. **代码质量问题**——`session.rs``bundle()` 方法在 `chat` 组合下 dead_code
4. **工程缺失**——无 CI、README 无 features 说明、roadmap 未同步
**边界识别**
- 工作 0 仅移动 trait 及关联类型定义,不改变公开 API 签名
- 工作 1 的 required-features 是最小集合,不添加冗余 feature
- 工作 2 的 CI 仅验证编译 + 单元测试,不包含集成测试
- 工作 5 的文档更新不涉及新的功能描述
**非目标声明**
- 不新增 feature 组合(保持现有的 4 个快捷组合不变)
- 不重构 `ProviderConfig` / `ProviderType` / `create_provider()` 等 provider 模块创建逻辑(仅移出 trait 和元数据结构体)
- 不集成集成测试(CI 仅验证 `--lib` 单元测试 + example 编译验证)
- 不改动 `Cargo.toml``[dependencies]` 声明
- 不改变 `default = ["full"]` 的默认行为
### 2.4 需求映射矩阵
| 功能需求 | 非功能需求 | 对应工作 | 验收项 |
|---------|-----------|---------|-------|
| F1 + N1 + N3 | — | 工作 0 | A1, A2, A9 |
| F2 | — | 工作 1 | A10 |
| F3 | N2 | 工作 2 | A5, A11 |
| F4 | — | 工作 3 | A4 |
| F5 | N1 | 工作 5 | A6 |
| F6 | — | 工作 5 | A7 |
| F7 | — | 工作 5 | A8 |
| — | N3 | 全部 | A1 |
| — | R2 缓解 | 全部 | A10 |
## 3. 方案设计
### 3.1 总体架构调整
```
工作 0 — 架构修正(LlmProvider trait 及关联类型归属调整)
当前:
src/llm/provider.rs #[cfg(any(feature = "provider-openai", ...))]
├─ pub trait LlmProvider { ... }
├─ pub struct ProviderCapabilities { ... }
├─ pub struct ProviderFeatures { ... }
└─ pub fn capabilities(&self) -> ProviderCapabilities;
目标:
src/llm/provider_trait.rs #[cfg(feature = "llm")]
├─ pub trait LlmProvider { ... }
├─ pub struct ProviderCapabilities { ... }
├─ pub struct ProviderFeatures { ... }
└─ pub fn capabilities(&self) -> ProviderCapabilities;
src/llm/provider.rs #[cfg(any(feature = "provider-openai", ...))]
└─ 各 provider 实现 + ProviderConfig / ProviderType / create_provider()
src/llm.rs
└─ pub use provider_trait::{LlmProvider, ProviderCapabilities, ProviderFeatures};
影响文件(6 个源文件 + 1 个新建):
- src/llm.rs — 添加 mod provider_trait 声明 + pub use 重导出
- src/llm/provider_trait.rs — 新文件,trait + 关联类型定义移入
- src/llm/provider.rs — 移出 trait + 关联类型
- src/llm/provider/openai.rs — use super:: → use crate::llm::
- src/llm/provider/anthropic.rs — 同上
- src/llm/provider/ollama.rs — 同上
- src/llm/provider/openai_compat.rs — 同上(两个 import 合并)
```
### 3.2 各子项设计方案
#### 工作 0 — LlmProvider trait 归属修正
**设计方案**
1.`src/llm/` 下新建 `provider_trait.rs`,门控为 `#[cfg(feature = "llm")]`
2.`src/llm/provider.rs` 中提取以下定义到新文件:
- `pub trait LlmProvider`(含关联方法 `chat` / `chat_stream` / `capabilities`
- `pub struct ProviderCapabilities`(含字段 `features: ProviderFeatures`
- `pub struct ProviderFeatures`(含 8 个功能开关字段)
3. `src/llm.rs` 中声明 `mod provider_trait;`,并 `pub use provider_trait::{LlmProvider, ProviderCapabilities, ProviderFeatures};`
4. `src/llm/provider.rs` 移除上述定义,保留 `ProviderConfig` / `ProviderType` / `create_provider()` 等运行时代码
5. 更新所有 import 路径(详见下方清单)
**Import 路径调整清单**
现有写法 → 目标写法
| # | 文件 | 现有 import | 目标 import |
|---|------|------------|------------|
| 1 | `agent/builder.rs:16` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
| 2 | `agent/builder.rs:135`test | `use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
| 3 | `agent/session.rs:35` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
| 4 | `agent/runtime.rs:21` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
| 5 | `llm/mock.rs:46` | `use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
| 6 | `llm/cycle.rs:22` | `use crate::llm::provider::LlmProvider;` | `use crate::llm::LlmProvider;` |
| 7 | `llm/cycle.rs:940,1401`test | `use crate::llm::provider::{ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{ProviderCapabilities, ProviderFeatures};` |
| 8 | `llm/provider/openai.rs:24` | `use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
| 9 | `llm/provider/anthropic.rs:21` | `use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};` | `use crate::llm::{LlmProvider, ProviderCapabilities, ProviderFeatures};` |
| 10 | `llm/provider/ollama.rs:14` | `use super::{LlmProvider, ProviderCapabilities};` | `use crate::llm::{LlmProvider, ProviderCapabilities};` |
| 11 | `llm/provider/openai_compat.rs:18,21` | `use super::ProviderCapabilities;` + `use crate::llm::provider::LlmProvider;` | `use crate::llm::{LlmProvider, ProviderCapabilities};`(合并为一行) |
| 12 | `llm/provider/registry.rs:6` | `use crate::llm::provider::{LlmProvider, ProviderConfig, ProviderType, create_provider};` | `use crate::llm::LlmProvider;` + `use crate::llm::provider::{ProviderConfig, ProviderType, create_provider};`(拆分) |
**示例文件 import 调整**
| # | 文件 | 现有 import | 目标 import |
|---|------|------------|------------|
| 13 | `examples/end_to_end.rs:21` | `use agcore::llm::provider::{create_provider, LlmProvider, ProviderConfig, ProviderType};` | `use agcore::llm::LlmProvider;` + `use agcore::llm::provider::{create_provider, ProviderConfig, ProviderType};` |
| 14 | `examples/context_slot_demo.rs:18` | `use agcore::llm::provider::LlmProvider;` | `use agcore::llm::LlmProvider;` |
| 15 | `examples/streaming_events_demo.rs:19` | `use agcore::llm::provider::LlmProvider;` | `use agcore::llm::LlmProvider;` |
| 16 | `examples/quick_start.rs:9` | `use agcore::llm::provider::LlmProvider;` | `use agcore::llm::LlmProvider;` |
**Breaking Change 声明**
工作 0 是**非兼容性变更**,现有用户可能通过以下路径引用 `LlmProvider`
| 旧路径(v0.3.0v0.3.2 Step 1 | 新路径(v0.3.2 Step 3 后) |
|--------------------------------|---------------------------|
| `agcore::llm::provider::LlmProvider` | `agcore::llm::LlmProvider` |
| `agcore::llm::provider::ProviderCapabilities` | `agcore::llm::ProviderCapabilities` |
| `agcore::llm::provider::ProviderFeatures` | `agcore::llm::ProviderFeatures` |
**向后兼容方案(可选)**:在 `src/llm/provider.rs` 中添加 `#[cfg(feature = "llm")]` 门控的类型别名,让老路径仍然可用:
```rust
#[cfg(feature = "llm")]
pub use super::provider_trait::LlmProvider;
#[cfg(feature = "llm")]
pub use super::provider_trait::ProviderCapabilities;
#[cfg(feature = "llm")]
pub use super::provider_trait::ProviderFeatures;
```
**推荐**:用户应迁移到新路径 `agcore::llm::LlmProvider``provider` 模块仅保留 `ProviderConfig` / `ProviderType` / `create_provider()` 等创建逻辑。
**验证**
- `cargo test --features "full"` 仍 427 passed
- `cargo test --no-default-features --features "llm,llm-types" --lib` 编译通过(无需任何 provider feature
- 所有 12 个内部文件 + 4 个示例文件的 import 路径正确
#### 工作 1 — examples required-features 标注
**设计方案**:在 `Cargo.toml` 中为每个 example 添加 `[[example]]` + `required-features`,精确到最小 features 集合。
```
上下文 slot 示例(context_slot_demo):
工作 0 后仅需 ["agent"](修正前需 ["agent", "provider-openai"]
因为 agent 的测试无需真实 providermock 即可
推理不变的 examplesimple_visit):
真正调用 LLM,需要 ["llm", "provider-openai", "tracing-init"]
```
完整映射关系见实施计划 §4.2。
#### 工作 2 — CI 配置
**设计方案**GitHub Actions 矩阵策略,7 个并行测试 job + 1 clippy + 1 format + 1 example 验证。
| Job | Command | 作用域 |
|-----|---------|--------|
| full | `RUSTFLAGS="-D warnings" cargo test --features "full" --lib` | 全量回归,零警告 |
| light | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "light" --lib` | 生产常用,零警告 |
| chat | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "chat,provider-openai" --lib` | 纯对话,零警告 |
| chat+mcp | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "chat,provider-openai,tools-mcp" --lib` | 对话 + 工具,零警告 |
| multi | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "multi,provider-openai" --lib` | 多 Agent,零警告 |
| multi+mcp | `RUSTFLAGS="-D warnings" cargo test --no-default-features --features "multi,provider-openai,tools-mcp" --lib` | 多 Agent + 工具,零警告 |
| clippy | `cargo clippy --all-features --lib -- -D warnings` | lint 检查 |
| format | `cargo fmt --check`stable toolchain | 格式检查 |
| examples | `cargo test --features "full"`(不加 `--lib`,编译并运行所有 example | example 编译验证 |
**关键决策**
- 矩阵中统一使用 `--lib` 而非 `--all-targets`。理由:examples 的编译由 `required-features` 独立管理,若混入矩阵会因 feature 组合不匹配导致 example 编译失败,干扰模块测试结果验证。
- 使用 `RUSTFLAGS="-D warnings"` 将警告升级为编译错误,确保 `F40 编译器警告)`被矩阵中所有 6 个测试 job 强制执行。
- format job 使用 stable toolchain`cargo fmt --check` 不需要 nightly)。
- 独立 `examples` job 使用 `cargo test --features "full"`(不加 `--lib`),验证所有 example 在完整 features 下编译并运行通过。
#### 工作 3 — bundle() 死代码警告修复
**设计方案**:在 `src/agent/session.rs:154``pub(crate) fn bundle()` 方法上添加 `#[cfg(feature = "engine")]` 条件编译。
背景:`bundle()` 仅被 `engine/session_manager.rs:219` 调用,当启用 `chat` 组合(agent 但非 engine)时产生 dead_code 警告。
**验证**`cargo test --no-default-features --features "chat,provider-openai" --lib` 0 warnings。
#### 工作 4(可选)— 编译时间基线
记录但不沉淀到代码或 CI 中,仅供性能参考:
```bash
time cargo build --features "full"
time cargo build --no-default-features --features "light"
```
注:此工作在 v0.3.2 发布前为手动执行,不纳入 CI 或验收标准。若后续版本需要编译时间回归检测,可将其提升为正式工作项。
#### 工作 5 — 文档更新
三处并行更新:
**README.md 新增 features 表格**
- 4 个快捷组合 + 推荐使用场景 + `Cargo.toml` 配置示例
- 下游用户可快速选择并复制配置
**升级指南章节(README.md 新增)**
- 针对工作 0 的 Breaking Change 提供迁移说明
- 列出旧路径 → 新路径的对照表
- 提供向后兼容的重导出方案说明
- 示例:`agcore::llm::provider::LlmProvider``agcore::llm::LlmProvider`
- 提醒用户更新 `use` 声明
**example 文件顶部注释**
- 每个 example 第一行格式:`// Required features: cargo run --example xxx --features "..."`
- 对应工作 1 的 `required-features` 声明
**roadmap 状态同步**
- `docs/roadmap.md`:补充 Phase 26-27 完成状态 + v0.3.2 链接
- `docs/roadmap-v0.3.2.md`Phase 26/27 状态从 ⏳ 改为 ✅ + 完成日期
### 3.3 ADR 记录
#### ADR-1LlmProvider trait 及关联类型归属 llm 模块
| 字段 | 内容 |
|------|------|
| 问题 | `LlmProvider` trait 定义在 provider 模块门控 `any(provider-openai, provider-anthropic, ...)` 下,纯 Mock 场景被迫引入至少一个 provider feature。其返回类型 `ProviderCapabilities` / `ProviderFeatures` 同样被困在 provider 门控中 |
| 决策 | 将 trait 定义 + `ProviderCapabilities` / `ProviderFeatures` 一并提取到 `src/llm/provider_trait.rs`,归属 `#[cfg(feature = "llm")]` |
| 备选方案 | 保持不动,在 mock provider 上添加 cfg 绕过 — 否决,因为 provider 模块整体门控错误 |
| 理由 | trait 本身是个接口定义,不依赖 reqwest 或任何 provider 实现细节;`ProviderCapabilities` / `ProviderFeatures` 是 trait 方法的返回类型,必须与 trait 同门控 |
| 影响 | 修改 6 个源文件 + 1 个新建文件 + 4 个示例文件(详见 §3.2 import 调整清单) |
| 状态 | 已采纳 |
#### ADR-2CI 使用 nightly toolchain
| 字段 | 内容 |
|------|------|
| 问题 | 项目已使用 edition 2024,是否降级到 2021 以使用 stable Rust |
| 决策 | 测试和 clippy 使用 nightlyedition 2024 目前要求 nightly);format 使用 stable |
| 备选方案 | 降级 edition 到 2021 — 否决,已迁移至 edition 2024 且编译通过 |
| 理由 | edtion 2024 是主动选择的方向,降级是倒退且涉及大量语法变更;`cargo fmt --check` 无需 nightly |
| 影响 | CI 依赖 `actions-rust-lang/setup-rust-toolchain@v1`format job 指定 `toolchain: stable` |
| 状态 | 已采纳 |
#### ADR-3CI 矩阵使用 `--lib` 而非 `--all-targets`
| 字段 | 内容 |
|------|------|
| 问题 | `cargo test --all-targets` 会编译所有 example,与矩阵中自选的 feature 组合可能冲突 |
| 决策 | 矩阵测试使用 `--lib`examples 由独立 job`cargo test --features "full"` 不加 `--lib`)验证 |
| 备选方案 | 在矩阵中也传入 `--all-targets` — 否决,example 编译失败会干扰模块测试验证 |
| 理由 | 分离关注点:矩阵验证模块级编译 + 零警告,独立 job 验证 example 编译 |
| 状态 | 已采纳 |
## 4. 实施计划
### 4.1 任务拆解与优先级
| 优先级 | 工作 | 编号 | 规模 | 依赖 |
|--------|------|------|------|------|
| P0 | LlmProvider trait 归属修正 | 工作 0 | ~30 行(含关联类型移动 + import 调整) | 无 |
| P0 | bundle() 门控修复 | 工作 3 | 1 行 | 无 |
| P0 | examples required-features | 工作 1 | ~50 行 | 工作 0context_slot_demo / quick_start 最小 features 从 agent+provider-openai 降为 agent |
| P0 | CI 配置 | 工作 2 | ~80 行 | 无 |
| P1 | 文档更新 | 工作 5 | ~150 行 | 全部 |
| P2 | 编译时间基线 | 工作 4 | 手动 | 全部 |
### 4.2 各 example 的 required-features 清单
| example 文件名 | required-features | 运行环境备注 |
|---------------|-------------------|------------|
| `prompt_composer` | `["prompt"]` | — |
| `custom_tool` | `["tools"]` | — |
| `conversation_memory_demo` | `["memory"]` | — |
| `knowledge_graph_demo` | `["memory"]` | — |
| `knowledge_search_demo` | `["memory"]` | — |
| `agent_session_demo` | `["agent"]` | — |
| `task_agent_demo` | `["agent"]` | — |
| `context_slot_demo` | `["agent"]` | 工作 0 后无需 provider |
| `quick_start` | `["agent"]` | 工作 0 后无需 provider |
| `simple_visit` | `["llm", "provider-openai", "tracing-init"]` | 需要 API key |
| `streaming_events_demo` | `["llm", "provider-openai"]` | 需要 API key |
| `agent_switch_demo` | `["engine"]` | — |
| `bridge_keys_demo` | `["engine"]` | — |
| `dispatch_stream_demo` | `["engine"]` | — |
| `engine_demo` | `["engine"]` | — |
| `sub_agent_dispatch_demo` | `["engine"]` | — |
| `document_demo` | `["memory", "tracing-init"]` | 需 sqlite 依赖(memory-sqlite feature 可选) |
| `end_to_end` | `["agent", "memory-sqlite", "provider-openai"]` | 需要 API key + sqlite 依赖 |
**验证策略**:每个 example 除 `--lib` 验证外,还需单独运行以下命令确认 required-features 精确性:
```bash
cargo test --no-default-features --features "<features>" --example <name>
```
### 4.3 Commit 策略
每个工作独立 commit,按依赖顺序排列:
| 顺序 | Scope | Type | 描述 | 依赖 |
|------|-------|------|------|------|
| 1 | `core` | `refactor` | 将 LlmProvider trait 及关联类型移出 provider 模块归属 llm | 无 |
| 2 | `agent` | `fix` | 为 session.rs bundle() 方法添加 engine feature 门控 | 无 |
| 3 | `examples` | `chore` | 为 18 个 example 添加 required-features 声明 | 工作 1context_slot 等受益于工作 0 的轻量 features |
| 4 | `ci` | `chore` | 创建 CI 测试矩阵配置 | 无 |
| 5 | `docs` | `docs` | 更新 README feature 表 + 升级指南 + 示例注释 + roadmap 状态 | 全部 |
### 4.4 参考实现:CI 配置
```yaml
name: CI
on: [push, pull_request]
env:
RUSTFLAGS: "-D warnings"
jobs:
test-matrix:
strategy:
matrix:
features:
- "full"
- "light"
- "chat,provider-openai"
- "chat,provider-openai,tools-mcp"
- "multi,provider-openai"
- "multi,provider-openai,tools-mcp"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo test --no-default-features --features "${{ matrix.features }}" --lib
clippy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo clippy --all-features --lib -- -D warnings
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
- run: cargo fmt --check
examples:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo test --features "full"
```
## 5. 风险评估
| 风险 | 概率 | 影响 | 缓解措施 | 对应验收项 |
|------|------|------|---------|-----------|
| 工作 0 重构后公开 API 被意外改变 | 低 | 高 | 重构前后分别跑 `cargo test --features "full"` 确认测试数一致(427),且 `cargo doc` 无差异 | A1 |
| required-features 标注不准确导致 example 运行时缺少 trait 实现 | 低 | 中 | 每个 example 在 `--lib` 验证后,再单独跑 `cargo test --example xxx --no-default-features --features "对应features"` 确认 | A10 |
| CI 首次在 GitHub runner 上因环境差异(OS、Toolchain 版本)失败 | 中 | 低 | 非阻塞问题,修复后重新推送即可;本地已在 macOS 验证 7 种组合 | A5 |
| edition 2024 在 GitHub runner 的特定 nightly 版本上不稳定 | 低 | 中 | 可在 `Cargo.toml` 中加 `rust-version = "1.85"` 下限约束 | A5 |
| bundle() 门控修复后 engine 组合下方法不可见 | 极低 | 中 | `cargo test --features "engine,provider-openai"` 编译通过即可验证 | A4 |
## 6. 验收标准
| # | 验收项 | 验证方式 | 对应工作 |
|---|--------|---------|---------|
| A1 | `cargo test --features "full"` 仍 427 passed | `cargo test -F full -q` | 工作 0 |
| A2 | `cargo test --no-default-features --features "llm,llm-types" --lib` 编译通过 | 无需任何 provider feature 即完成编译 | 工作 0 |
| A3 | 7 种组合下 `cargo test --no-default-features --features "组合" --lib -q` 全部通过 | 逐一验证 | 工作 1example features 正确不会干扰 --lib |
| A4 | 6 个矩阵组合(full / light / chat / chat+mcp / multi / multi+mcp)下 `RUSTFLAGS="-D warnings" cargo test --lib` 0 warnings | 所有组合均无编译器警告 | 工作 3 |
| A5 | `.github/workflows/ci.yml` 文件存在,结构包含 9 个 job6 测试 + 1 clippy + 1 format + 1 examples | 文件检查 | 工作 2 |
| A6 | README.md 包含 features 表格 + 使用场景 + Cargo.toml 配置示例 + 升级指南 | review 通过 | 工作 5 |
| A7 | 所有 18 个 example 文件首行含 `// Required features: cargo run --example xxx --features "..."` 注释 | review 通过 | 工作 5 |
| A8 | `docs/roadmap.md``docs/roadmap-v0.3.2.md` 中 Phase 26/27 状态标记为 ✅ | review 通过 | 工作 5 |
| A9 | 工作 0 后 `cargo doc --no-deps --features "llm,llm-types"` 可生成 `LlmProvider` / `ProviderCapabilities` / `ProviderFeatures` 的 API 文档(无需任何 provider feature | review 通过 | 工作 0 |
| A10 | 每个 example 单独验证:`cargo test --no-default-features --features "<对应features>" --example <name>` 编译通过 | 逐一验证 18 个 example | 工作 1 |
| A11 | 全矩阵 CI6 测试 + clippy + format + examples)从 checkout 到完成 ≤ 10 分钟 | 实测计时 | 工作 2 |
@@ -0,0 +1,790 @@
# Phase 28-30 — OpenAI Response API Provider 实施方案
> **版本**v1 | **作者**Writer Agent | **日期**2026-07-20
>
> **阅读前提**:本文档假设读者已熟悉现有的 Provider 实现模式(`AnthropicProvider` 独立实现方式)、IR 类型系统(`MessageRequest` / `MessageResponse` / `ContentBlock` / `StreamEvent` / `LlmProvider trait`)以及 Cargo features 门控机制。
>
> **前置条件**v0.3.2Phase 20-27)已发布,Cargo features 拆分完成,CI 矩阵 6 种组合全部通过。
---
## 1. 背景与目标
### 1.1 背景
OpenAI 于 2025 年下半年发布了 **Response API**`POST /responses`),作为 Chat Completions API`POST /chat/completions`)的下一代接口。Response API 不仅提供了更简洁的请求/响应结构,还将 `web_search``file_search``computer_use` 等内置工具提升为一等公民,并引入了 `previous_response_id` 多轮续写等新机制。
agcore 当前通过 `GenericOpenaiProvider` 实现了 OpenAI Chat Completions 协议。`ProviderType::OpenaiResponse` 枚举项已在 `src/llm/provider.rs` 中定义,但工厂函数返回 `Err("Phase 1 暂不实现;请使用 OpenaiChat")`
### 1.2 目标
- 实现独立的 `OpenaiResponseProvider`(不套用 `GenericOpenaiProvider`,参考 `AnthropicProvider` 模式)
- 覆盖 Response API 的核心能力:文本对话、流式输出、Vision 输入、工具调用(function calling
- 新增独立 feature `provider-openai-response`,加入 `full` 快捷组合
- 内置工具(`web_search` / `file_search` / `computer_use`)通过 `MessageRequest.extra` 逃生舱传递
- 多轮接续第一版走全量消息历史模式
### 1.3 范围
| 维度 | 包含 | 不包含 |
|------|------|--------|
| 协议端点 | `POST /responses` | `/responses/{id}/input_items` 等管理端点 |
| 输入模式 | 全量消息历史 + `previous_response_id` | 增量续写优化 |
| 内置工具 | 通过 `extra` 逃生舱透传 | 原生 ToolDef 结构改动 |
| 流式 | SSE 语义事件 → `StreamEvent` | — |
| 结构化输出 | `text.format` | 暂不专项封装 |
---
## 2. 需求分析
### 2.1 功能需求
| # | 需求 | 优先级 | 说明 |
|---|------|--------|------|
| F1 | 文本对话(非流式 + 流式) | P0 | 最基础的对话能力 |
| F2 | Vision 图片输入 | P0 | `UserImage``input_image` |
| F3 | Function Calling 工具调用 | P0 | `ToolDef``{type: "function", ...}` |
| F4 | 多轮接续 | P1 | 全量消息历史模式 |
| F5 | System 消息处理 | P0 | 多个 System 消息拼接到 `instructions` |
| F6 | 流式 SSE 事件映射 | P0 | 按 Response API SSE 事件序列映射 |
| F7 | 内置工具逃生舱 | P2 | `extra` 字段透传 `web_search` / `file_search` |
| F8 | 结构化输出逃生舱 | P2 | `extra` 字段透传 `text.format` |
### 2.2 非功能需求
| # | 需求 | 指标 |
|---|------|------|
| N1 | 编译隔离 | 新增 feature 不增加 `light` / `chat` 组合的依赖 |
| N2 | 测试覆盖 | wiremock 覆盖非流式 + 流式 + 错误路径 |
| N3 | 错误映射 | 复用 `GenericOpenaiProvider` 的错误映射逻辑 |
| N4 | Clippy 合规 | `cargo clippy --all-features --lib -- -D warnings` 通过 |
### 2.3 与 Chat Completions 的差异回顾
| 维度 | Chat Completions | Response API |
|------|-----------------|--------------|
| 端点 | `POST /chat/completions` | `POST /responses` |
| 输入 | `messages: [{role, content}]` | `input: string \| items[]` + 顶层 `instructions` |
| 输出 | `choices[n].message` | `output: []` 异构 items 数组 |
| 内置工具 | 无(仅 function calling | `web_search` / `file_search` / `computer_use` 一等公民 |
| 多轮接续 | 调用方拼接 messages | `previous_response_id` 参数 或 全量回传 |
| 流式 | SSE chunk `choices[n].delta` | SSE 语义事件:`response.text.delta` / `response.output_item.added` 等 |
| 结构化输出 | `response_format` | `text.format` |
| 认证 | `Authorization: Bearer` | 相同 |
| 错误结构 | 相同(401/429/500 | 相同 |
---
## 3. 方案设计
### 3.1 设计决策
| # | 决策 | 选项 | 选择 | 理由 |
|---|------|------|------|------|
| D1 | 实现方式 | 独立 Provider vs 套用 GenericOpenaiProvider | **独立 Provider** | Response API 请求/响应结构与 Chat Completions 差异过大,序列化/反序列化无共用价值 |
| D2 | Feature 粒度 | 合并到 `provider-openai` vs 独立 | **独立 feature** | 与 `AnthropicProvider` 对齐,避免 `full` 组合膨胀 |
| D3 | 加入快捷组合 | 加入 `full` 但不加入 `light` | **`full` 包含** | Response API 属于高级能力,`light` 保持轻量 |
| D4 | 多轮方案 | 全量历史 vs 增量 | **全量历史(模式 A** | 功能正确,无需改动 `LlmCycle` |
| D5 | 内置工具支持 | 改 ToolDef vs extra 逃生舱 | **extra 逃生舱** | 不改已有 IR 类型,最小侵入 |
### 3.2 Feature 定义
```toml
provider-openai-response = ["llm", "reqwest", "bytes", "futures-util"]
```
`provider-openai` / `provider-anthropic` 的依赖集合一致——`llm` 已包含 `tokio` / `async-stream` / `futures-core` / `futures-util` / `tokio-stream`,此处补充 `reqwest`HTTP 客户端)和 `bytes`(流式 buffer 操作)。
`full` 快捷组合追加 `"provider-openai-response"`
### 3.3 新增文件
所有实现集中在单一文件:
```
src/llm/provider/openai_response.rs ← 全部实现(Wire 类型 + Provider 结构体 + 请求转换 + 响应转换 + 流式处理 + 测试)
```
不在 `provider/` 下创建子目录。模块声明在 `src/llm.rs`,在现有 Provider features cfg 条件中追加 `feature = "provider-openai-response"`
```rust
#[cfg(any(
feature = "provider-openai",
feature = "provider-anthropic",
feature = "provider-deepseek",
feature = "provider-qwen",
feature = "provider-ollama",
feature = "provider-openai-response",
))]
pub mod provider;
```
### 3.4 架构概览
```
┌──────────────────────────────────────────────┐
│ OpenaiResponseProvider │
│ ┌──────────────────────────────────────────┐ │
│ │ convert_request() │ │
│ │ MessageRequest → OpenaiResponseRequest │ │
│ └──────────────────┬───────────────────────┘ │
│ │ │
│ ┌──────────────────▼───────────────────────┐ │
│ │ HTTP POST /responses │ │
│ │ (reqwest Client) │ │
│ └──────────────────┬───────────────────────┘ │
│ │ │
│ ┌──────────────────▼───────────────────────┐ │
│ │ convert_response() │ │
│ │ OpenaiResponseBody → MessageResponse │ │
│ └──────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────┐ │
│ │ ResponseSseEventStream │ │
│ │ SSE bytes → StreamEvent 流 │ │
│ └──────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
```
### 3.5 Wire 类型设计
#### 请求体类型
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct OpenaiResponseRequest {
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
pub input: Vec<ResponseInputItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<ResponseTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_response_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub truncation: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<Value>,
}
```
#### Input Item 枚举
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub(crate) enum ResponseInputItem {
Message {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
item_type: Option<String>, // 可选,固定为 "message"assistant 回传时使用)
role: String,
content: Vec<ResponseInputContent>,
},
FunctionCall {
#[serde(rename = "type")]
item_type: String, // 固定为 "function_call"
call_id: String,
name: String,
arguments: String,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<String>,
},
FunctionCallOutput {
#[serde(rename = "type")]
item_type: String, // 固定为 "function_call_output"
call_id: String,
output: String,
},
}
> ** `ResponseInputItem` `ResponseOutputItem` **
>
> - **`ResponseInputItem`**`#[serde(untagged)]`****`convert_request` `item_type` untagged fallback
> - **`ResponseOutputItem`** untagged`item_type: String` ****`convert_response` API `item_type` §3.7 `ContentBlock::Extension` fallback item serde
/// 消息内容块(嵌套在 Message 变体的 content 数组中)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum ResponseInputContent {
InputText {
text: String,
},
InputImage {
image_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
},
}
```
> **补充说明**Response API 的 `input` 字段还支持简化格式——`input: "Hello"`(单字符串)或 `input: ["Hello", "Hi"]`(字符串数组),但这些格式只能表达纯文本消息。为支持多模态内容(文本 + 图片)和工具调用,本实现使用完整的消息对象数组格式。
#### Tool 类型
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum ResponseTool {
Function {
name: String,
description: String,
parameters: Value,
},
}
```
#### 响应体类型
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct OpenaiResponseBody {
pub id: String,
pub model: String,
pub output: Vec<ResponseOutputItem>,
pub usage: Usage,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ResponseOutputItem {
pub id: String,
#[serde(rename = "type")]
pub item_type: String,
pub status: Option<String>,
pub role: Option<String>,
pub content: Option<Vec<ResponseContentPart>>,
pub call_id: Option<String>,
pub name: Option<String>,
pub arguments: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ResponseContentPart {
#[serde(rename = "type")]
pub part_type: String,
pub text: Option<String>,
}
```
#### SSE 事件类型
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum ResponseSseEvent {
#[serde(rename = "response.created")]
ResponseCreated { response: ResponseSseMeta },
#[serde(rename = "response.completed")]
ResponseCompleted { response: ResponseSseMeta },
#[serde(rename = "response.failed")]
ResponseFailed { error: Option<serde_json::Value> },
#[serde(rename = "response.output_item.added")]
ResponseOutputItemAdded { item: ResponseOutputItem },
#[serde(rename = "response.output_item.done")]
ResponseOutputItemDone { item: ResponseOutputItem },
#[serde(rename = "response.output_text.delta")]
ResponseOutputTextDelta { delta: String, item_id: String },
#[serde(rename = "response.output_text.done")]
ResponseOutputTextDone { text: String, item_id: String },
#[serde(rename = "response.refusal.delta")]
ResponseRefusalDelta { delta: String, item_id: String },
#[serde(rename = "response.refusal.done")]
ResponseRefusalDone { refusal: String, item_id: String },
#[serde(rename = "response.function_call_arguments.delta")]
ResponseFunctionCallArgumentsDelta { delta: String, item_id: String },
#[serde(rename = "response.function_call_arguments.done")]
ResponseFunctionCallArgumentsDone { arguments: String, item_id: String },
#[serde(rename = "error")]
Error { code: String, message: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ResponseSseMeta {
pub id: String,
pub model: String,
pub status: String,
}
```
### 3.6 请求转换(convert_request
#### 消息类型映射
| 输入场景 | Message 类型 | → Response API input item |
|---------|-------------|--------------------------|
| 文本 User | `Message::User { content: [Text] }` | `{role: "user", content: [{type: "input_text", text}]}` |
| Vision | `Message::UserImage { data, mime_type, detail }` | `{role: "user", content: [{type: "input_image", image_url: "data:{mime};base64,{data}", detail}]}` |
| User 多模态 | `Message::User { content: [Text, Image, ...] }` | `{role: "user", content: [{type: "input_text", text}, {type: "input_image", image_url, detail}]}` |
| Assistant 文本 | `Message::Assistant { content: [Text] }` | User 侧:`{role: "assistant", content: [{type: "output_text", text}]}`(无 `type` 字段);回传时: `{type: "message", role: "assistant", content: [{type: "output_text", text}]}`(有 `type: "message"` |
| Assistant 工具调用 | `Message::Assistant { content: [ToolUse] }` | `FunctionCall { call_id, name, arguments }` |
| Assistant 文本+工具 | `Message::Assistant { content: [Text, ToolUse, ...] }` | 一个 `Message(assistant)` + 一个或多个 `FunctionCall` 项 |
| 工具结果 | `Message::ToolResult { tool_call_id, content, is_error }` | `FunctionCallOutput { call_id, output: content }` |
| System | `Message::System { content }` | 拼接到顶层 `instructions` 字段(非 input |
#### 字段映射
| MessageRequest 字段 | → Response API 字段 |
|---------------------|---------------------|
| `model` | `model` |
| `max_tokens` | `max_output_tokens` |
| `temperature` | `temperature` |
| `top_p` | `top_p` |
| `stop_sequences` | `stop` |
| `stream` | `stream` |
| `tools` (ToolDef) | `tools` = `[{type: "function", name, description, parameters}]` |
| `tool_choice` | `tool_choice` |
#### extra 字段映射
| `MessageRequest.extra` key | → Response API 字段 |
|---------------------------|---------------------|
| `previous_response_id` | `previous_response_id` |
| `store` | `store` |
| `metadata` | `metadata` |
| `truncation` | `truncation` |
| `reasoning.effort` | `reasoning: {effort: ...}` |
| 内置工具(`web_search` / `file_search` 等) | 追加到 `tools` 数组 |
> **备注**:当前仅支持 `reasoning.effort` 子字段(值为 `low`/`medium`/`high`),其他子字段(如 `reasoning.summary`)将在后续版本支持。
### 3.7 响应转换(convert_response
| Response API output item | → MessageResponse 中的表示 |
|-------------------------|------------------------------|
| `{type: "message", role: "assistant", content: [{type: "output_text", text}]}` | `Message::Assistant { content: [ContentBlock::Text { text }] }` |
| `{type: "function_call", name, arguments, call_id}` | `ContentBlock::ToolUse { id: call_id, name, input: arguments }` |
| `{type: "web_search_call", ...}` | `ContentBlock::Extension { kind: "web_search_call", data: ... }` |
| `{type: "reasoning", ...}` | `ContentBlock::Extension { kind: "reasoning", data: ... }` |
| `{type: "file_search_call", ...}` | `ContentBlock::Extension { kind: "file_search_call", data: ... }` |
**status → StopReason 映射**
- `completed``StopReason::Stop`
- `incomplete``StopReason::Length`
- `failed``StopReason::Other`
`response.output` 为空数组时,返回 `LlmError::Request { status: 200, body: "empty output" }`,表示响应格式异常。
对于未知的 `item_type`(非 `message`/`function_call`/`web_search_call`/`file_search_call`/`reasoning`),转换为 `ContentBlock::Extension { kind: item_type, data: serde_json::to_value(item)? }` 以保持前向兼容。
### 3.8 流式 SSE 事件映射
| Response API SSE event | → StreamEvent |
|------------------------|---------------|
| `response.created` | `MessageStart { id, model }` |
| `response.output_item.added` (type: message) | `ContentBlockStart { index, block_type: Text }` |
| `response.output_text.delta` | `TextDelta { text }` |
| `response.output_text.done` | `ContentBlockEnd { index }` |
| `response.refusal.delta` | `RefusalDelta { text }` |
| `response.refusal.done` | `ContentBlockEnd { index }` |
| `response.function_call_arguments.delta` | `ToolCallArgumentsDelta { index, arguments }` |
| `response.function_call_arguments.done` | `ToolCallEnd { index }` |
| `response.completed` | `MessageComplete { full_response }` |
| `response.failed` | `Error { message }` |
### 3.9 流式 SSE 状态机
`ResponseSseEventStream` 维护以下状态:
```
字段:
- byte_stream: reqwest 的 bytes_stream
- buffer: Vec<u8>SSE 行缓冲)
- partial: PartialMessageResponse(累积响应状态)
- block_index: u32(输出 block 序号计数器)
- saw_terminal: bool(是否已见到 response.completed / response.failed
流程:
line 级解析 → event: + data: 配对
→ 反序列化 ResponseSseEvent
→ try_into_stream_event() 映射为 StreamEvent
→ StreamEvent::apply_to(&mut partial)
→ yield StreamEvent
response.completed → partial.finalize() → yield MessageComplete
response.failed → yield Error
```
### 3.10 错误映射
复用 `GenericOpenaiProvider``handle_error_response()` 逻辑:
| HTTP 状态码 | → LlmError |
|------------|------------|
| 401 | `LlmError::Authentication(body)` |
| 429 | `LlmError::RateLimit { retry_after }` |
| 5xx | `LlmError::Request { status, body }` |
| 400 + `context_length_exceeded` | `LlmError::ContextLength` |
### 3.11 Provider 结构体
```rust
pub(crate) struct OpenaiResponseProvider {
http_client: Client,
base_url: String,
api_key: String,
model: String,
timeout_secs: u64,
}
```
#### 工厂方法
```rust
impl OpenaiResponseProvider {
pub(crate) fn from_parts(
base_url: String,
api_key: String,
model: String,
http_client: Client,
timeout_secs: u64,
) -> Self {
Self { http_client, base_url, api_key, model, timeout_secs }
}
}
```
### 3.12 LlmProvider trait 实现
```rust
#[async_trait]
impl LlmProvider for OpenaiResponseProvider {
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
self.chat_blocking(request).await
}
async fn chat_stream(
&self,
request: MessageRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
self.chat_stream_inner(request).await
}
fn capabilities(&self) -> ProviderCapabilities { ... }
}
```
### 3.13 Capabilities
```rust
ProviderCapabilities {
provider_name: "openai-response",
supported_models: Some(vec![model]),
features: ProviderFeatures {
streaming: true,
thinking: true, // o-series reasoning
vision: true, // image input
audio_input: false,
tool_use: true,
parallel_tool_calls: true,
system_prompt_in_messages: false,
max_context_window: 200_000,
},
}
```
### 3.14 工厂函数注册
```rust
ProviderType::OpenaiResponse => {
let client = build_client_with_timeout(config.timeout_secs)?;
Ok(Box::new(openai_response::OpenaiResponseProvider::from_parts(
config.base_url,
config.api_key,
config.model,
client,
config.timeout_secs,
)))
}
```
### 3.15 多轮接续方案
第一版走**全量消息历史模式(模式 A)**:
1. `convert_request()``MessageRequest.messages` 全部转换为 `input` items
2. System 消息拼接到 `instructions`
3. User / Assistant / ToolResult 消息转换为对应的 input items
4. 如果 `extra` 中有 `previous_response_id`,也传入请求体
此模式与 `LlmCycle::submit_with_tools()` 完全兼容——`LlmCycle` 在每次提交时都会填充完整的历史 messages,`OpenaiResponseProvider` 只是把这些 messages 全部序列化为 Response API 格式。无需改动 `LlmCycle`
---
## 4. 实施计划
实施拆分为 3 个 Phase9 个 Step。
### Phase 28Feature gate + Wire 类型 + Provider 骨架(~140 行)
#### Step 28.1Cargo.toml feature 定义
**文件操作**:修改 `Cargo.toml`
```toml
# 在 [features] 的 Provider features 区域追加
provider-openai-response = ["llm", "reqwest", "bytes", "futures-util"]
# 在 full 快捷组合中追加
full = [
"...",
"provider-openai-response",
]
```
**验证**`cargo build --features "provider-openai-response"` 编译通过
#### Step 28.2Wire 类型定义
**文件操作**:新建 `src/llm/provider/openai_response.rs`
定义 §3.5 中的所有 Wire 类型:
- `OpenaiResponseRequest`
- `ResponseInputItem`untagged 枚举:Message / FunctionCall / FunctionCallOutput
- `ResponseInputContent`tagged 枚举:InputText / InputImage
- `ResponseTool`
- `OpenaiResponseBody`
- `ResponseOutputItem`
- `ResponseContentPart`
- `ResponseSseEvent`(完整时序事件枚举)
- `ResponseSseMeta`
**无逻辑代码**,只有 `#[derive(Debug, Clone, Serialize, Deserialize)]` 的结构体和枚举。
**验证**`cargo build --features "provider-openai-response"` 编译通过
#### Step 28.3Provider 结构体 + from_parts
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
- `OpenaiResponseProvider` 结构体
- `from_parts()` 工厂方法
- 基础 HTTP 工具函数(`build_request_builder``handle_error_response``map_reqwest_error`
**验证**`cargo build --features "provider-openai-response"` 编译通过
#### Step 28.4Factory 注册 + 模块门控
**文件操作**
1. 修改 `src/llm.rs` — 在 cfg 条件中追加 `feature = "provider-openai-response"`
2. 修改 `src/llm/provider.rs` — 注册 factory
`src/llm.rs` 中修改现有 Provider features cfg 条件:
```rust
#[cfg(any(
feature = "provider-openai",
feature = "provider-anthropic",
feature = "provider-deepseek",
feature = "provider-qwen",
feature = "provider-ollama",
feature = "provider-openai-response",
))]
pub mod provider;
```
以及在 `src/llm/provider.rs``create_provider()` match 中替换当前 `Err` 为真实构造。
**验证**
- `cargo build --features "provider-openai-response"` 编译通过
- `cargo build --features "full"` 编译通过
---
### Phase 29:核心 Provider 实现(~680 行)
#### Step 29.1convert_request~200 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
实现 `OpenaiResponseProvider::convert_request(&self, request: MessageRequest) -> Result<OpenaiResponseRequest, LlmError>`
处理逻辑:
1. 遍历 `request.messages`,按 §3.6 消息类型映射表转换
2. Assistant 消息回传时设置 `item_type: Some("message".to_string())`,使序列化结果为 `{type: "message", role: "assistant", content: [...]}`User 消息保持 `item_type: None`,序列化为 `{role: "user", content: [...]}`(无 `type` 字段)
3. `request.tools``tools` 数组(`ToolDef``ResponseTool::Function`
4. `request.extra` → 解析 `previous_response_id` / `store` / `metadata` / `truncation` / `reasoning`
5. 标准字段映射(model / max_tokens / temperature / top_p / stop / stream
#### Step 29.2convert_response~100 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
实现 `OpenaiResponseProvider::convert_response(&self, response: OpenaiResponseBody) -> Result<MessageResponse, LlmError>`
处理逻辑:
1. 遍历 `response.output`,找到第一个 `type: "message"` 的 item,提取 text
2. 其他 items`function_call``ContentBlock::ToolUse`,内置工具 → `ContentBlock::Extension`
3. `response.status``StopReason`
4. `response.usage``Usage`
#### Step 29.3:非流式 chat()~80 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
实现 `OpenaiResponseProvider::chat_blocking()`
- `convert_request()` → serde 序列化 → HTTP POST `{base_url}/responses`
- Auth header: `Authorization: Bearer {api_key}`
- 错误处理映射
- 解析响应体 → `convert_response()`
#### Step 29.4SSE 事件类型 + 状态机(~230 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
实现 `ResponseSseEventStream` 结构体及其 `Stream` trait
- 字段:`byte_stream`, `buffer`, `partial: PartialMessageResponse`, `block_index: u32`, `saw_terminal: bool`
- 行级 SSE 解析:`event:` + `data:` 配对
- 事件 → `StreamEvent` 映射
- `PartialMessageResponse::apply_to()` 累积
- 流结束时 `finalize()``MessageComplete`
#### Step 29.5:流式 chat_stream()~50 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
实现 `OpenaiResponseProvider::chat_stream_inner()`
- `convert_request()` 设置 `stream: true`
- HTTP POST → bytes_stream → 包装为 `ResponseSseEventStream`
#### Step 29.6LlmProvider impl~50 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs`
实现 `LlmProvider for OpenaiResponseProvider`
- `chat()``chat_blocking()`
- `chat_stream()``chat_stream_inner()`
- `capabilities()` → 返回 `ProviderCapabilities`
#### Step 29.7:单元测试(~70 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs``#[cfg(test)] mod tests {}`
| 测试 | 场景 |
|------|------|
| `convert_request_text_only` | 纯文本输入转换 |
| `convert_request_vision` | Vision 输入转换 |
| `convert_request_tool_call` | 工具调用输入转换 |
| `convert_response_message` | 响应 message item 转换 |
| `convert_response_tool_use` | 响应 function_call item 转换 |
---
### Phase 30:测试 + CI + 文档(~520 行)
#### Step 30.1wiremock 非流式测试(~200 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs` 内联测试
| 测试 | 场景 | 验证 |
|------|------|------|
| `response_api_basic_text` | 纯文本响应 | `response.text()` 正确 |
| `response_api_tool_call` | 工具调用 | `stop_reason == ToolUse` |
| `response_api_multi_turn` | 两轮对话 | 第二轮携带历史 |
| `response_api_vision` | 图片输入 | 正确构造 `input_image` |
| `response_api_unauthorized` | 401 错误 | `LlmError::Authentication` |
| `response_api_rate_limit` | 429 错误 | `LlmError::RateLimit` |
| `response_api_server_error` | 500 错误 | `LlmError::Request` |
#### Step 30.2wiremock 流式测试(~200 行)
**文件操作**:追加到 `src/llm/provider/openai_response.rs` 内联测试
| 测试 | 场景 | 验证 |
|------|------|------|
| `response_api_stream_text` | 流式文本 | 完整 SSE 事件序列 |
| `response_api_stream_tool` | 流式工具调用 | `FunctionCallArgumentsDelta` 序列 |
| `response_api_stream_error` | 流中途失败 | `StreamEvent::Error` |
| `response_api_stream_multi_turn` | 流式多轮接续 | 第二轮携带历史消息时的完整 SSE 事件序列 |
#### Step 30.3CI 矩阵(~10 行)
**文件操作**:修改 `.github/workflows/ci.yml`
新增测试组合:
```yaml
- "chat,provider-openai,provider-openai-response"
```
#### Step 30.4:文档更新(~50 行)
**文件操作**:修改 `README.md` + `docs/roadmap.md`
- README feature 表新增 `provider-openai-response`
- `docs/roadmap.md``docs/roadmap-unsorted.md` 新增 v0.3.3 或下版本条目
#### Step 30.5Example~60 行)
**文件操作**:新建 `examples/response_api_demo.rs`
```toml
[[example]]
name = "response_api_demo"
required-features = ["llm", "provider-openai-response"]
```
基础对话示例,展示 Response API 的基本用法:
```
cargo run --example response_api_demo --features "full"
```
---
### 实施汇总
| Phase | 内容 | 代码行数估算 | 验证入口 |
|-------|------|------------|---------|
| 28 | Feature gate + Wire 类型 + Provider 骨架 | ~140 | `cargo build --features "provider-openai-response"` |
| 29 | 核心 Provider 实现(转换/HTTP/流式) | ~680 | 5 个单元测试 |
| 30 | 测试 + CI + 文档 | ~520 | 10 个 wiremock 测试 + CI 新组合 |
| **合计** | | **~1,340** | 全量 `cargo test --features "full"` |
---
## 5. 风险评估
| ID | 风险 | 影响 | 概率 | 缓解措施 |
|----|------|------|------|---------|
| R1 | Response API 协议快速迭代 | Wire 类型可能需更新 | 中 | Wire 类型集中在单个文件内,更新成本低 |
| R2 | `ContentBlock::Extension` 承载内置工具结果 | 下游消费方需适配 | 低 | 这是既有的逃生舱机制,已有消费模式 |
| R3 | 全量历史模式 token 开销 | 多轮时 input tokens 增长 | 低 | 功能正确,后续版本可优化为 `previous_response_id` 增量模式 |
| R4 | `instructions` 拼接多个 system 消息 | 语义可能与单 system 消息不同 | 低 | 已确认按 OpenAI 推荐方式全量拼接(`\n` 分隔),行为等价 |
| R5 | 与 `GenericOpenaiProvider` 的错误映射逻辑重复 | 维护两份相似逻辑 | 低 | 提取复用函数时需注意不影响现有 provider |
---
## 6. 验证标准
### 6.1 编译验证
| # | 检查项 | 命令 |
|---|--------|------|
| C1 | 独立 feature 编译 | `cargo build --features "provider-openai-response"` |
| C2 | full 组合编译 | `cargo build --features "full"` |
| C3 | light 组合不受影响 | `cargo build --features "light"`(不包含新 feature |
| C4 | Clippy 合规 | `cargo clippy --all-features --lib -- -D warnings` |
### 6.2 测试验证
| # | 检查项 | 通过条件 |
|---|--------|---------|
| T1 | 单元测试 | `cargo test --features "full"` 全部通过(+15 新增测试) |
| T2 | 非流式 wiremock | 7 个测试覆盖文本/工具/多轮/Vision/401/429/500 |
| T3 | 流式 wiremock | 3 个测试覆盖文本流/工具流/错误流 |
| T4 | 现有测试无回归 | 使用 `--features "full"` 时已有 427 测试全部通过 |
### 6.3 CI 验证
| # | 检查项 | 通过条件 |
|---|--------|---------|
| I1 | 新增 CI 组合 | 包含新 feature 的组合编译通过 |
| I2 | clippy + format | `cargo clippy` + `cargo fmt --check` 通过 |
### 6.4 Example 验证
| # | 检查项 | 通过条件 |
|---|--------|---------|
| E1 | Example 编译 | `cargo build --example response_api_demo --features "full"` 通过 |
| E2 | Example 运行 | `cargo run --example response_api_demo --features "full"` 可执行(需 API key |
@@ -0,0 +1,422 @@
# OpenAI Response Provider 自定义请求头支持
## 背景
OpenAI Responses API 的部分实现(如火山引擎豆包)需要携带特殊的 HTTP 请求头(如 `ark-beta-doubao-app: true`)来启用平台特定功能。当前 `OpenaiResponseProvider``build_request_builder()` 中只设置了 `Authorization` 头,没有途径注入自定义请求头。
原方案只覆盖 OpenAI Response Provider。经讨论后扩展为**三 Provider 统一**方案:OpenAI Chat`GenericOpenaiProvider`)、OpenAI Response`OpenaiResponseProvider`)、Anthropic`AnthropicProvider`)。
核心动机:
- OpenAI Responses API 的部分实现需要携带特殊 HTTP 请求头来启用平台特定功能
- 三种基础协议中,自定义头注入能力不一致
- 统一 API 让调用方用 `set_extra("custom_headers", ...)` 即可,与底层协议无关
## 需求
### 功能需求
双层自定义头机制:
- **Provider 级固定头**`extra_headers: Vec<(String, String)>`,构造时注入,所有请求自动携带。用于该 provider 所有请求都需要的固定标识头(如平台接入标记)
- **请求级临时头**`extra.custom_headers: HashMap<String, String>`,通过 `set_extra` 注入。用于特定请求需要覆盖或追加的头
### 约束
- 不可引入任何平台特定逻辑(火山、豆包等字符串不得出现)
- 自定义头仅运行时生效,不进入 JSON 序列化的请求体
- 兼容已有的 extra 逃生舱机制(builtin_tools、text_format 等)
- agcore 是支持库,不提供运行时敏感头过滤保护(如 Authorization/Cookie),但文档中应说明风险
- 不修改 `LlmProvider` trait、`ProviderType` 枚举
- `create_provider()` 工厂函数只传 `Vec::new()` 作为 extra_headers 默认值,不暴露配置能力;调用方如需 Provider 级固定头,直接构造 provider 后链式调用 `.with_extra_headers()`
### 用户故事
1. 作为集成者,我想对任意 provider 的请求注入自定义 HTTP 头,以启用平台特有功能(请求级)
2. 作为集成者,我想在 provider 构造时注入固定头,让所有请求自动携带,避免每次重复指定(Provider 级)
3. 作为维护者,我想三种基础协议使用统一的 API,调用方无需关心底层 provider 类型
## 方案设计
### 统一设计原则
```
调用方视角(统一 API):
request.set_extra("custom_headers", json!({"X-Foo": "bar"}));
// 不管底层是 OpenAI Chat / OpenAI Response / Anthropic,都能工作
构造方视角(Provider 级):
OpenaiResponseProvider::from_parts(..., extra_headers).with_extra_headers(...);
GenericOpenaiProvider::from_parts(..., extra_headers); // 已有
AnthropicProvider::from_parts(..., extra_headers);
头融合顺序(三 provider 一致):
认证头 (Authorization / x-api-key) → Provider 级 extra_headers → 请求级 custom_headers
↑ 后者覆盖前者
```
### 改动一:GenericOpenaiProvideropenai.rs
**`OpenaiChatRequest` 新增字段**
`extra_body`(第 147 行)之后:
```rust
/// 请求级别自定义 HTTP 头。运行时注入,不进入 JSON 请求体。
/// ⚠️ 与 struct 已有的 `extra_headers: Option<Value>`OpenAI API 自身的 wire 格式字段)
/// 不同——后者是 OpenAI API 参数,本字段是 reqwest 层的 HTTP 头注入。
#[serde(skip)]
pub custom_headers: HashMap<String, String>,
```
`#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。
**`convert_request()` 从 extra 提取**
`parallel_tool_calls`(第 559 行)之后:
```rust
let custom_headers: HashMap<String, String> = request
.get_extra_opt("custom_headers")
.unwrap_or_default();
```
**`build_request_builder()` 签名改具体类型 + 注入逻辑**
第 454 行,签名从 `&impl Serialize` 改为 `&OpenaiChatRequest`(两处调用点传入的均为该类型,安全):
```rust
fn build_request_builder(
&self,
url: &str,
body: &OpenaiChatRequest, // 从 &impl Serialize 改为具体类型
) -> Result<reqwest::RequestBuilder, LlmError> {
let mut builder = self
.http_client
.post(url)
.header("Authorization", format!("Bearer {}", self.api_key));
// 头融合顺序见上方「统一设计原则」。
// Provider 级固定头先注入,请求级临时头后注入(后者覆盖前者)。
Ok(builder.json(body))
}
```
两处调用点(`chat_blocking` 第 628 行、`chat_stream_inner` 第 669 行)传入的都是 `&OpenaiChatRequest`,零影响。
**`with_extra_headers()` builder 方法**
```rust
/// 注入 Provider 级别固定头。返回 self 以支持链式调用。
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
self.extra_headers = headers;
self
}
```
### 改动二:OpenaiResponseProvideropenai_response.rs
**① struct 新增 `extra_headers` 字段**
第 287 行,`pub struct OpenaiResponseProvider` 增加:
```rust
pub struct OpenaiResponseProvider {
// ... 已有字段 ...
extra_headers: Vec<(String, String)>,
}
```
**`from_parts()` 新增参数**
第 299 行:
```rust
pub(crate) fn from_parts(
base_url: String,
api_key: String,
model: String,
http_client: Client,
timeout_secs: u64,
extra_headers: Vec<(String, String)>, // 新增
) -> Self { ... }
```
**`with_extra_headers()` builder 方法**
```rust
/// 注入 Provider 级别固定头。返回 self 以支持链式调用。
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
self.extra_headers = headers;
self
}
```
**`OpenaiResponseRequest` 新增字段**
第 73 行,`reasoning` 之后:
```rust
/// 请求级别自定义 HTTP 头。序列化时跳过,仅运行时由 build_request_builder 消费。
/// stream 模式的修改不影响该字段——header 由 convert_request 在请求构造时注入。
#[serde(skip)]
pub custom_headers: HashMap<String, String>,
```
`#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。
**`convert_request()` 从 extra 提取**
第 404 行,`reasoning` 之后:
```rust
let custom_headers: HashMap<String, String> = extra
.get("custom_headers")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
```
> **注意**OpenaiResponseProvider 的 `convert_request` 在顶部 destructure 了 `request`,因此使用 `extra.get()` 而非 `request.get_extra_opt()`。两者语义一致,均反序列化为 `HashMap<String, String>`,失败时静默降级为空 HashMap。
**`build_request_builder()` 签名 + 注入逻辑**
第 319 行,签名从 `&impl Serialize` 改为 `&OpenaiResponseRequest`(两处调用点传入的均为该类型,安全):
```rust
/// 构造 HTTP POST 请求 builder(含认证头与额外请求头)。
///
/// 头融合顺序:Authorization → Provider 级 extra_headers → 请求级 custom_headers
/// 后者覆盖前者。
fn build_request_builder(
&self,
body: &OpenaiResponseRequest, // 从 &impl Serialize 改为具体类型
) -> Result<reqwest::RequestBuilder, LlmError> {
let mut builder = self
.http_client
.post(self.endpoint_url())
.header("Authorization", format!("Bearer {}", self.api_key));
for (k, v) in &self.extra_headers {
builder = builder.header(k.as_str(), v.as_str());
}
for (key, value) in &body.custom_headers {
builder = builder.header(key.as_str(), value.as_str());
}
Ok(builder.json(body))
}
```
两处调用点(`chat_blocking` 第 708 行、`chat_stream_inner` 第 741 行)传入的都是 `&OpenaiResponseRequest`,零影响。
### 改动三:AnthropicProvideranthropic.rs
AnthropicProvider 是唯一没有统一 `build_request_builder` 方法的 provider,需要**前置重构**。
**① struct 新增 `extra_headers` 字段**
第 36 行:
```rust
pub struct AnthropicProvider {
// ... 已有字段 ...
extra_headers: Vec<(String, String)>,
}
```
**`from_parts()` 新增参数**
第 128 行:
```rust
pub(crate) fn from_parts(
base_url: String,
api_key: String,
model: String,
http_client: Client,
timeout_secs: u64,
extra_headers: Vec<(String, String)>, // 新增
) -> Self { ... }
```
**`with_extra_headers()` builder 方法**
```rust
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
self.extra_headers = headers;
self
}
```
**`AnthropicRequestBody` 新增字段**
第 450 行,`stream` 之后:
```rust
struct AnthropicRequestBody {
model: String,
max_tokens: u32,
// ... 已有字段 ...
/// 请求级别自定义 HTTP 头。运行时注入,不进入 JSON 请求体。
#[serde(skip)]
custom_headers: HashMap<String, String>,
}
```
`#[serde(skip)]` 确保该字段不会出现在序列化后的 JSON body 中。
**`build_request_body()` 从 extra 提取**
```rust
let custom_headers: HashMap<String, String> = request
.get_extra_opt("custom_headers")
.unwrap_or_default();
```
**⑥ 提取 `build_request_builder()` 统一方法(前置重构)**
```rust
/// 构造 HTTP POST 请求 builder(含认证头 + 自定义头)。
/// 认证头(x-api-key / anthropic-version)已由 Client 的 default_headers 提供。
fn build_request_builder(
&self,
body: &AnthropicRequestBody,
) -> Result<reqwest::RequestBuilder, LlmError> {
let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
let mut builder = self.http_client.post(&url).json(body);
for (k, v) in &self.extra_headers {
builder = builder.header(k.as_str(), v.as_str());
}
for (key, value) in &body.custom_headers {
builder = builder.header(key.as_str(), value.as_str());
}
Ok(builder)
}
```
**⑦ 改造 `chat_blocking()``chat_stream_inner()`**
改造前(`chat_blocking`,第 263-269 行):
```rust
let response = self
.http_client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| self.map_reqwest_error(e))?;
```
改造后:
```rust
let response = self
.build_request_builder(&body)?
.send()
.await
.map_err(|e| self.map_reqwest_error(e))?;
```
`chat_stream_inner`(第 298-304 行)同理。
### 改动四:create_provider()provider.rs
依据约束「`create_provider()` 工厂函数不暴露配置能力」,三处分支适配 `from_parts` 的新签名时全部传 `Vec::new()`
```rust
// OpenaiResponse(第 199-207 行)
openai_response::OpenaiResponseProvider::from_parts(
config.base_url, config.api_key, config.model,
client, config.timeout_secs,
Vec::new(), // extra_headers 默认空
)
// Anthropic(第 215-221 行)
anthropic::AnthropicProvider::from_parts(
config.base_url, config.api_key, config.model,
client, config.timeout_secs,
Vec::new(), // extra_headers 默认空
)
// OpenAI Chat(第 185-194 行)— 已有 Vec::new(),无需改动
```
### 调用方式
**请求级临时头**(统一 API,三 provider 通用):
```rust
request.set_extra("custom_headers", serde_json::json!({
"ark-beta-doubao-app": "true"
}));
```
**Provider 级固定头**(构造时注入):
```rust
let provider = OpenaiResponseProvider::from_parts(...)
.with_extra_headers(vec![
("ark-beta-doubao-app".into(), "true".into()),
]);
```
## 风险评估
### 风险点与缓解措施
| 风险 | 等级 | 缓解措施 |
|------|------|---------|
| 用户通过 `custom_headers` 覆盖 `Authorization` 等认证头 | 中 | 文档说明:自定义头按遍历顺序注入,同 key 后注入覆盖前注入。agcore 作为支持库不做运行时拦截 |
| `serde_json::from_value` 类型错误静默降级为空 HashMap | 低 | 与已有 extra 字段(builtin_tools、text_format)一致的模式,保持行为统一。类型错误时请求正常发出,只是不携带自定义头 |
| HashMap 迭代顺序不确定影响测试确定性 | 低 | HTTP 协议不要求 header 顺序,wiremock 按名匹配。无需特殊处理 |
| AnthropicProvider 前置重构引入回归 | 低 | 提取 `build_request_builder` 是纯重构,现有测试覆盖其请求构造行为。重构后运行现有测试套件即可验证 |
| `build_request_builder` 签名从泛型改为具体类型 | 低 | 已确认两处调用点(chat_blocking / chat_stream_inner)传入的均为具体类型,零影响 |
| AnthropicProvider 的 `default_headers`x-api-key / anthropic-version)与 `extra_headers` 同名头合并行为取决于 reqwest 实现 | 低 | 明确约定 Provider 级固定头不应意图覆盖认证头;`build_request_builder` 的 doc comment 中标注认证头来源 |
### 设计取舍记录
| 决策 | 选择 | 理由 |
|------|------|------|
| Provider 级 vs 请求级 | 双层都支持 | 满足固定头和临时头两种场景 |
| `create_provider` 是否暴露 extra_headers | 不暴露,只传 `Vec::new()` | 保持工厂函数签名简洁,固定头通过 builder 方法注入 |
| 敏感头保护 | 不做运行时拦截,文档说明 | agcore 是支持库,不替调用方做保护 |
| `OpenaiChatRequest.custom_headers` 命名 | 用 `custom_headers` 而非 `extra_headers` | 避免与已有的 `extra_headers: Option<Value>`OpenAI API wire 字段)混淆 |
## 验证标准
### 单元测试(每 provider 4 个)
| 测试 | 验证点 |
|------|--------|
| `*_custom_headers_from_extra` | `convert_request` / `build_request_body` 能从 extra 提取 `custom_headers` |
| `*_custom_headers_skipped_in_json` | `#[serde(skip)]` 确保 custom_headers 不进入序列化 JSON body |
| `*_custom_headers_invalid_type_fallback` | 传入错误类型(如字符串而非对象)时静默降级为空 HashMap |
| `*_extra_headers_from_constructor` | 验证 `from_parts` / `new_with_name_and_headers` 传入的 `extra_headers``build_request_builder` 中被正确注入到 HTTP 请求头 |
### 集成测试(每 provider 4 个,wiremock
| 测试 | 验证点 |
|------|--------|
| `*_custom_headers_are_sent` | mock 匹配器验证 HTTP 请求确实携带自定义头 |
| `*_provider_level_headers_are_sent` | 验证 Provider 级固定头(通过 `with_extra_headers` 注入)确实出现在 HTTP 请求中 |
| `*_custom_headers_override_provider_headers` | 当 Provider 级和请求级设置了相同 key 但不同值时,最终 HTTP 请求携带的是请求级的值 |
| `*_custom_headers_can_override_auth_header` | 注入含 `Authorization` 同 key 的 `custom_headers`,验证最终认证头值被覆盖(使行为可见、可预测,与文档风险说明一致) |
### 回归验证
1. 运行 `cargo test --features full` 确保所有现有测试通过
2. `cargo clippy --features full` 无新警告
3. `cargo fmt --check` 格式一致
## 不涉及的改动
- 不新增 Feature gate
- 不修改 `LlmProvider` trait
- 不修改 `ProviderType` 枚举
- 不新增任何平台相关代码
@@ -0,0 +1,268 @@
# Builtin Tools 注入修复方案
## 背景与目标
### 问题描述
agcore 的 OpenaiResponseProvider 在通过 extra 逃生舱注入内置工具(`web_search` / `file_search`)时存在两层缺陷,导致 builtin_tools 完全不生效:
1. **分支逻辑错位**`convert_request`,第 615-640 行):builtin_tools 注入代码被嵌套在 `tools_defs` 非空的 `else` 分支内。当调用方只提供 builtin_tools 而不提供 tools_defs 时,分支走 `if tools_defs.is_empty() { None }`,注入代码完全不执行。
2. **枚举不完整**`ResponseTool`,第 136-146 行):枚举只有 `Function` 一种变体,非 `function` 类型的 builtin 工具(如 `type: "web_search"`)反序列化失败,退化为 `name: ""` 的空函数定义,API 层面被拒绝。
### 目标
- 修复 builtin_tools 注入逻辑,使纯内置工具、混用场景均正常工作
- 不破坏现有 tools_defs 功能
- 添加回归测试
## 需求分析
### 功能需求
| # | 需求 | 优先级 |
|---|------|--------|
| F1 | `tools_defs` 为空、`builtin_tools` 非空时,正确注入内置工具 | P0 |
| F2 | `tools_defs``builtin_tools` 同时非空时,合并注入 | P0 |
| F3 | 两端均为空时,tools 字段为 None(回归保底) | P0 |
| F4 | 无效的 builtin_tools 值不导致崩溃,跳过并告警 | P1 |
### 非功能需求
- 不做底层架构改造(extra 逃生舱机制不变)
- 不改 `openai.rs`Chat Completions API 不支持内置工具)
## 方案设计
### 总体架构
修复分三步,对应三层独立但不相互依赖的改动:
```
┌─────────────────────────────────────────────────┐
│ convert_request() │
│ │
│ [改动二] 重构分支逻辑 │
│ ┌─────────────────────────────────────────┐ │
│ │ tools_defs ──→ 生成 Vec<ResponseTool> │ │
│ │ builtin_tools ──→ 追加到同一 Vec │ │
│ │ 两者都空 ──→ None; 否则 ──→ Some(items) │ │
│ └─────────────────────────────────────────┘ │
│ │
│ [改动三] 错误处理 │
│ unwrap_or_else ──→ match + warn! │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ ResponseTool 枚举 │
│ │
│ [改动一] 添加 Builtin(Value) 变体 │
│ 自定义 Serialize/Deserialize 避免信息丢失 │
└─────────────────────────────────────────────────┘
```
### 改动一:扩展 `ResponseTool` 枚举
**位置**:第 136-146 行
**现状**
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum ResponseTool {
#[serde(rename = "function")]
Function {
name: String,
description: String,
parameters: Value,
},
}
```
**改后**(需要自定义 Serialize/Deserialize):
```rust
/// NOTE: 仅在请求序列化路径使用(convert_request → build_request_builder → HTTP body)。
/// 响应反序列化走 ResponseOutputItem,不经过此类型。
/// 自定义 Deserialize 服务于 convert_request 内 extra 字段反序列化。
#[derive(Debug, Clone)]
pub(crate) enum ResponseTool {
Function {
name: String,
description: String,
parameters: Value,
},
/// 非 function 类型的工具(如 web_search / file_search / code_interpreter)。
/// 直接透传原始 JSON Value,不做结构化解析,避免信息丢失。
Builtin(Value),
}
impl Serialize for ResponseTool {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
ResponseTool::Function { name, description, parameters } => {
let mut map = serde_json::Map::new();
map.insert("type".into(), Value::String("function".into()));
map.insert("name".into(), Value::String(name.clone()));
map.insert("description".into(), Value::String(description.clone()));
map.insert("parameters".into(), parameters.clone());
map.serialize(serializer)
}
ResponseTool::Builtin(value) => value.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for ResponseTool {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = Value::deserialize(deserializer)?;
match value.get("type").and_then(|t| t.as_str()) {
Some("function") => {
let name = value.get("name").and_then(|n| n.as_str()).unwrap_or_default().to_string();
let description = value.get("description").and_then(|d| d.as_str()).unwrap_or_default().to_string();
let parameters = value.get("parameters").cloned().unwrap_or(Value::Null);
Ok(ResponseTool::Function { name, description, parameters })
}
_ => Ok(ResponseTool::Builtin(value)),
}
}
}
```
关键点:
- 自定义 `Serialize``Builtin` 变体直接输出原始 Value(不包裹额外标记)
- 自定义 `Deserialize`:非 `"function"` 类型自动走 `Builtin(Value)` 分支
- 保留原始 JSON 结构,避免信息丢失(如 `search_context_size``user_location` 等字段)
### 改动二:修复 `convert_request` 分支逻辑
**位置**:第 615-640 行
**现状**(伪代码):
```
if tools_defs.is_empty() {
None // ← builtin_tools 被完全跳过
} else {
从 tools_defs 生成 Vec<ResponseTool>
if let Some(builtin_tools) {
for v in extra {
items.push(from_value(v)) // ← 只有进了 else 才执行
}
}
Some(items)
}
```
**改后**(伪代码):
```
let mut items: Vec<ResponseTool> = Vec::new();
// 1. 始终处理 tools_defs
items.extend(tools_defs.into_iter().map(|t| ResponseTool::Function { ... }));
// 2. 始终处理 builtin_tools(与 tools_defs 解耦)
if let Some(builtin_tools) = builtin_tools {
for v in extra {
// 见改动三
items.push(serde_json::from_value(v).unwrap_or_else(|_| { ... }));
}
}
// 3. 两者都空 → None;否则 → Some
if items.is_empty() { None } else { Some(items) }
```
### 改动三:改进错误处理
**位置**:第 630-636 行(`unwrap_or_else` 部分)
**现状**
```rust
items.push(serde_json::from_value(v).unwrap_or_else(|_| {
ResponseTool::Function {
name: String::new(),
description: String::new(),
parameters: Value::Null,
}
}));
```
**改后**
```rust
match serde_json::from_value(v.clone()) {
Ok(tool) => items.push(tool),
Err(e) => {
let raw = serde_json::to_string(&v).unwrap_or_default();
warn!(tool = %raw, error = %e, "skipped invalid builtin_tool");
}
}
```
`warn!` 输出被反序列化的 Value 摘要,便于生产排障时定位问题(无需复现调用方输入)。
`unwrap_or_else` 的错误值(空函数定义)在 OpenAI API 层会被拒绝,无实际价值。替换为 `match` + `warn!` 可明确跳过并记录原因。
### 改动四:添加测试
在文件末尾 `#[cfg(test)]` 区域或 `tests/` 目录新增 6 个测试用例:
| # | 用例名 | 场景 | 验证点 |
|---|--------|------|--------|
| T1 | `test_builtin_only` | 仅提供 builtin_tools | tools 为 Some,含正确 type |
| T2 | `test_mixed_tools` | 同时提供 tools_defs + builtin_tools | 合并后 items 顺序/数量正确 |
| T3 | `test_no_tools` | 两端均为空 | tools 为 None |
| T4 | `test_invalid_builtin` | builtin_tools 含无效 JSON | 不崩溃,有效项保留 |
| T5 | `test_function_wire_format` | ResponseTool::Function 序列化 | JSON 结构与改动前一致(AC7) |
| T6 | `test_builtin_roundtrip` | ResponseTool::Builtin 反序列化+序列化 | 原始 JSON 结构保留 |
## 实现计划
### 步骤
| 步骤 | 改动 | 文件 | 估算 |
|------|------|------|------|
| 1 | 扩展 `ResponseTool` 枚举,添加 `Builtin(Value)` + 自定义 Serialize/Deserialize | `openai_response.rs:136-146` | 40 行 |
| 2 | 重构 `convert_request` 分支逻辑,解耦 tools_defs 与 builtin_tools | `openai_response.rs:615-640` | 15 行 |
| 3 | 替换 `unwrap_or_else``match` + `warn!` | `openai_response.rs:630-636` | 5 行 |
| 4 | 添加 6 个测试用例 | `openai_response.rs` 末尾 | 70 行 |
| 5 | `cargo test` 验证全部通过 | - | - |
### 优先级
**P0(核心修复)**:步骤 1 + 2,修复分支逻辑和枚举不完整问题。
**P1(健壮性)**:步骤 3,改进错误处理。
**P1(质量保障)**:步骤 4 + 5,测试覆盖。
### 依赖关系
无外部依赖。全部改动限定在 `openai_response.rs` 一个文件内。
## 风险评估
| 风险 | 概率 | 影响 | 缓解措施 |
|------|------|------|----------|
| 自定义 Serialize/Deserialize 实现遗漏边界情况 | 低 | 中 | 测试覆盖所有分支:Function / Builtin / 无效值 |
| 现有 `Function` 序列化格式变化 | 低 | 高 | 自定义 Serialize 保持与原 derive 行为一致,测试覆盖 wire 格式 |
| `warn!` 日志在生产环境未配置 logger 导致 panic | 低 | 中 | 使用 `tracing::warn!`(已导入),项目已初始化 tracing logger |
### 回滚方案
单文件改动,回滚只需 `git checkout -- src/llm/provider/openai_response.rs`
## 验收标准
| # | 验收条件 | 验证方式 |
|---|----------|----------|
| AC1 | `tools_defs` 空 + `builtin_tools``{"type":"web_search"}` → 请求体 `tools` 包含 `{"type":"web_search"}` | 单测 T1 |
| AC2 | 混用场景 → tools 数组同时包含 function 和非 function 工具 | 单测 T2 |
| AC3 | 两端空 → tools 字段为 null/None | 单测 T3 |
| AC4 | 无效 builtin_tools → 不 panic,有效项不受影响 | 单测 T4 |
| AC5 | 全部现有测试通过 | `cargo test` |
| AC6 | `cargo clippy` 无新增警告 | `cargo clippy` |
| AC7 | `ResponseTool::Function` 序列化后的 JSON 结构与改动前一致 | 单测:验证字段顺序和值 |
---
**编写人**Writer Agent
**编写日期**2026-07-20
**基于**agcore builtin_tools 注入问题分析结论
View File
View File
@@ -5,7 +5,8 @@
> **已分版本的内容**:请查阅
> - [`roadmap-v0.1.0.md`](./roadmap-v0.1.0.md) — Phase 04c + v0.1.0 Release
> - [`roadmap-v0.2.0.md`](./roadmap-v0.2.0.md) — Phase 512 + v0.2.0-rc.1
> - [`roadmap-v0.3.0.md`](./roadmap-v0.3.0.md) — Phase 131913-18 已完成,19 待实施
> - [`roadmap-v0.3.0.md`](./roadmap-v0.3.0.md) — Phase 1319全部完成
> - [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md) — Phase A-E 多 Agent 编排路线图
>
> 返回总入口:[`roadmap.md`](./roadmap.md)
@@ -15,7 +16,7 @@
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
**当前状态**v0.2.0-rc.1 已打标签。Phase 0-18 全部完成。v0.3.0 实施中,Phase 19 共 1 个增量 Phase 待交付。目标是从"LLM 调用工具箱"升级为"能构建多 Agent 协作、RAG、长记忆 Agent 产品的基础系统"。
**当前状态**v0.3.5。Phase 0-30 全部完成。v0.4.0 规划已确定,覆盖 5 个增量 Phase(A-E):Swarm 编排抽象、结果聚合、Human-in-the-loop + 用户 Steering、TokenJuice 语义压缩、自动校正。目标是从"多 Agent 基础系统"升级为"多 Agent 多职责编排系统"。
---
@@ -35,21 +36,30 @@ AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可
---
## v0.4+ 展望
## v0.4.0 规划
### 已规划的功能
v0.4.0 的完整规划已移入独立的 [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md),包含 5 个增量 Phase
| 功能 | 说明 | 预计版本 |
|------|------|---------|
| Multi-Agent Swarm 编排 | Supervisor/Subgraph 模式,基于 v0.3 dispatch 构建 | v0.4 |
| Human-in-the-loop 审批 | `interrupt()` + `Command(resume=...)` 异步审批回调 | v0.4 |
| Agent 自动创生 | LLM 自主决定何时派发子 agent、派发什么角色 | v0.4 |
| 分布式 session 共享 | SessionManager Redis 后端支持跨进程 | v0.4 |
| 精确 tokenizer 计数 | 引入 `tiktoken-rs`,绑定模型具体 tokenizer,替换字符估算 | v0.4+ |
| TokenJuice 语义压缩 | 对工具结果做语义压缩而非字节截断 | v0.4+ |
| Markdown 技能按需加载 | 技能注册表 + 按 prompt 上下文动态加载 | v0.4+ |
| 增量 checkpoint | 仅存储变化部分,替换当前全量 JSON 模式 | v0.4+ |
| RL 轨迹导出 | ShareGPT 格式轨迹、Atropos 集成 | v0.4+ |
| Phase | 内容 | 状态 |
|-------|------|------|
| **Phase A** | Swarm 编排(Star/Sequential/Hierarchical + Subgraph | 📋 待实施 |
| **Phase B** | 结果聚合 + 编排模式完善 | 📋 待实施 |
| **Phase C** | Human-in-the-loop + 用户 Steering | 📋 待实施 |
| **Phase D** | TokenJuice 语义压缩(工具结果/历史/跨 Agent) | 📋 待实施 |
| **Phase E** | 自动校正 / Reflection | 📋 待实施 |
### 未来版本(v0.5+
以下功能已从 v0.4 范围移出:
| 功能 | 说明 |
|------|------|
| Agent 自动创生 | LLM 自主决定何时派发子 agent — 设计复杂,v0.4 专注显式声明式编排 |
| 分布式 session 共享(Redis 后端) | 与编排正交,多数用户单进程即可 |
| 精确 tokenizer 计数(tiktoken-rs | 依赖引入,不在 v0.4 核心范围内 |
| 增量 Checkpoint | 存储优化,当前全量 JSON 够用 |
| 路线 BStateGraph 通用图引擎) | 预留为路线 A 的未来升级路径 |
| RL 轨迹导出 | 专项需求 |
### 明确不做(agcore 范围外)
@@ -74,10 +84,10 @@ AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可
## 下一步行动
1. **v0.3.0 Phase 19 启动**KnowledgeGraph + 双通道检索,落地 `docs/note-knowledge-graph-design.md` 中记录的知识图谱设计
2. **Phase 19 收尾**完成 v0.3.0 最后一个 Phase 后准备 rc.1 标签 + CHANGELOG
3. **示例先行**完成 Phase 19 后立即创建对应的 knowledge_graph_demo 示例,确保 `cargo run --example` 可验证
4. **里程碑追踪**:以 M13Phase 17+ M14Phase 18)为已达成里程碑,逐 Phase 推进 M15
1. **v0.4.0 启动**:按 [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md) 规划,从 Phase A(Swarm 编排)开始实施
2. **Phase A 实施**engine/supervisor.rs + tools/builtin.rs + Swarm::star/sequential/hierarchical
3. **示例先行**每个 Phase 交付时同步提交对应的示例程序
4. **里程碑追踪**:以 M16-M20 为目标里程碑,逐 Phase 推进
---
@@ -107,3 +117,84 @@ AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可
- ✅ **v0.3.0 Phase 16 完成**`SummaryConfig` 配置结构体(6 个字段:`trigger_token_ratio=0.75` / `max_context_tokens=32_000` / `summary_prompt` / `debounce_turns=3` / `summary_model=None` / `max_tool_result_chars=500`,默认 `None` 沿用主模型避断裂非 OpenAI 用户)+ `AgentBuilder::summary_config(cfg)` 链式方法 + `AgentConfig.summary_config: Option<SummaryConfig>` 字段;`AgentSession` 新增 `last_summary_turn: Option<u32>` 字段(首次不受防抖约束,`should_summarize``Option` 哨兵实现)+ `maybe_summarize(current_turn)` 内联检查点(OnTurnEnd 之后 / `turn_index` 之前,对称 `submit_turn` / `finalize_turn` 两个入口,流式路径 `saturating_sub(1)` 修正)+ 关联函数 `generate_summary`(构造独立 `LlmCycle``submit_messages``vec![Message::user_text(prompt)]``max_tokens=1024`,空消息守卫直接返回空串)+ 公开 API `get_conversation_summary()``src/agent/summary.rs`~240 行,含 8 个 SummaryConfig/`format_messages_as_text` 内联测试——默认值/空输入/系统用户助理/ToolResult(含 `tool_call_id`/工具调用/Unicode 安全截断/整体 30K 截断保留最新;有效字符数截断多字节安全,droptest 验证保留尾部消息)+ `src/agent/session.rs` 注入 10 个摘要集成测试(默认值不触发 / 超阈值触发 / 防抖阻止重复 / SessionMemory 写入 / Full 模式不注入 / 失败不阻断主流程 / 流式路径触发 / 默认配置零影响 / **Focused `summary_override` 写入正向验证** / **空消息不调用 LLM** / **巨型 `max_context_tokens` 永不触发**);`format_messages_as_text` 简洁版消息格式化(`[Tool: name]` + `Tool Result [id]:` + ToolResult 字符级 `chars().take(max_tool_result_chars)` 截断 + 整段 30K 总长度截断从头部保留最新);所有错误静默(失败用 `tracing::error!`,成功用 `tracing::info!(turn, summary_len)`);`MergeStrategy` 注释中过时 "Summarize 指向"与 `context.rs:78` "v0.3 将支持 Hook 驱动" 过时注释在实施时同步移除/更新;方案文档 `docs/22-phase16-summary-auto-generation.md`(471 行),实施后**两轮审查 PASS**:第一轮 PM/SA 审查 11 项问题修复 + 第二轮实施审查 9 项问题修复(🔴 `generate_summary` 空消息 bug + 🟡 W4 流式路径防抖 + 🟡 W2 模型硬编码 + 🟡 W5 Full 模式无谓 save + 🟡 W3 30K 截断 + 🟡 W6 成功无日志 + 🟡 W1/W7 测试补全 + 💭 注释同步);零新外部依赖;全量 335 → **353**(+18 新测试,含二次审查增补 4 个),clippy 0 警告,doc 0 warning`quick_start` 示例正常 exit 0;**M12 里程碑达成** + 第二轮审查门禁 PASS
- ✅ **v0.3.0 Phase 17 完成** — 新建 `src/engine/` 模块(5 文件:`mod.rs`/`error.rs`/`snapshot.rs`/`checkpointer.rs`/`session_manager.rs`),实现 **SessionManager**10 个公开方法:`create`/`create_child`/`get`/`recover`/`replace`/`children`/`parent`/`destroy`/`submit_turn`/`submit_turn_stream`/`finalize_turn_stream`,内部 `RwLock<HashMap>` + `Arc<tokio::sync::Mutex<AgentSession>>` + `Checkpointer` 组合)和 **Checkpointer**5 个公开方法:`checkpoint`/`rollback_load`/`list_checkpoints`/`delete_all`/`latest_snapshot`);`SessionSnapshot` 独立 struct 避开 `Arc<dyn Agent>` 不可序列化,配套 `SessionMemoryEntry` 保留 metadata/created_at`AgentSession` 扩展三段式快照(`to_snapshot` async 读 MemoryStore + `from_snapshot` 纯同步构造 + `restore_memory` &mut self async 写回持久层);`SessionMemory` 新增 `list_entries()``set_with_meta()` 方法(恢复时保留完整 entry 数据);存储 key 风格统一为 `session:{id}:meta` / `ckpt:{id}:{ckpt_id}`(与 `slot_data:` 风格一致);`EngineError` 6 个变体(含 `Memory(#[from] MemoryError)` 透传 + `Agent(#[from] AgentError)`);`CkptMeta``created_at_nanos` 字段确保同秒内精确降序排序;ckpt_id 用纳秒+单调计数器生成(零外部依赖,ponytail);session_id 用纳秒+计数器自动生成(统一策略,UUID v4 备选);自动 checkpoint 失败 `tracing::error!` 不阻断主流程(不提供强持久化保证);流式 checkpoint 仅在 `finalize_turn_stream` 创建(不留半成品污染);孤儿策略:`destroy()` 不递归删除子 session,父被销毁后 `parent()` 返回 `Ok(None)`3 处 derive 改动(`CostTracker` + `ContextSlot` + `MergeStrategy` 加 serde`CostTracker` 额外加 `Clone`);`SessionManager::recover` + `replace` 内部自动 `restore_memory` 写回持久层;零新外部依赖;方案文档 `docs/23-phase17-agent-execution-engine.md`(775 行,经两轮 PM+SA 审查 + 实施后第三轮 PM+SA+Code Reviewer 三方联合审查),实施后**两轮审查门禁 PASS**:第一轮修复 6 🔴 + 第二轮修复 2 🔴(to_snapshot 同步→async + Roadmap 同步)+ 实施后修复 8 个 🟡(restore_memory metadata/created_at 完整恢复 + &mut self 签名 + 死代码清理 + 3 个边界测试 + tracing 补全 + 文档语义统一 + 示例 rollback 一致性 assert);15 个 `SessionManager` 内联测试(CRUD/recover/replace/树形/孤儿/auto_checkpoint on-off+ 6 个 `Checkpointer` 内联测试(roundtrip/不存在的 ckpt/同秒降序/delete_all 幂等/latest/隔离)+ 1 个 `snapshot_deserialize_with_minimal_fields` 序列化兼容测试;全量 353 → **374**+21 新测试),clippy 0 警告,doc 0 warning`engine_demo` 示例端到端演示 create→submit_turn→checkpoint→rollback→replace→destroy 全链路并验证 rollback 一致性;**M13 里程碑达成** + 两轮审查门禁 PASS
- ✅ **v0.3.0 Phase 18 完成** — 新增 `src/engine/switch.rs`222 行)实现 `SessionManager::switch_agent()` 热切换(替换 `Arc<dyn Agent>`slot 历史 / `turn_index` / `session_memory` / `cost_so_far` 全部保留,同步更新 `SessionMeta.agent_name` 到持久层,`created_at` / `parent_id` 保持原始不可变)+ 新增 `src/engine/sub_agent.rs`1071 行)实现 4 个公开方法(`dispatch` / `dispatch_all` / `dispatch_stream` 与前述 `switch_agent` 共 4 个 Phase 18 核心 API+ 3 个公开类型(`DispatchConfig` / `SubTaskResult` / `SubTaskStreamEvent`);`DispatchConfig` 4 字段(`max_concurrency=10` / `inherit_session_memory=true` / `bridge_keys=None` / `shared_namespace=None`+ 三态 `bridge_keys` 语义(`None` = 不继承 / `Some(vec![])` = 全部 / `Some(keys)` = 指定 keys+ 约定式 `shared_namespace` 子↔子共享(`shared:{prefix}:{key}`)不触发自动注入;`dispatch` 流程:`create_child``inherit_session_memory`(快照语义)→ `submit_turn` → 返回 `SubTaskResult``dispatch_all` `tokio::sync::Semaphore` 并发控制 + `Vec<Result<...>>` 部分成功语义按输入顺序 indexed 收集;`dispatch_stream` `unbounded_channel` + spawn task 消息重建 + `finalize_turn` 后台落库(明确不参与 `auto_checkpoint` 防重复);`SubTaskStreamEvent` 事件序列:`ChildCreated``Stream(StreamEvent) × N``Completed(SubTaskResult)``Error { child_id, error }``EngineError` 新增 `DispatchFailed(#[source] String)` 变体 + `CostTracker``From<Usage>` 转换;`save_session_meta` / `load_session_meta``pub(crate)``switch.rs` 调用;4 个端到端示例:`agent_switch_demo`115 行)+ `sub_agent_dispatch_demo`141 行)+ `bridge_keys_demo`197 行)+ `dispatch_stream_demo`121 行)全部 exit 017 个内联测试(4 switch + 5 dispatch + 4 dispatch_all + 4 dispatch_stream);零新外部依赖;方案文档 `docs/24-phase18-agent-switch-and-dispatch.md`700 行);全量 374 → **391**(+17 新测试,0 失败),clippy 0 警告,doc 0 warning**M14 里程碑达成**
- ✅ **v0.3.0 Phase 19 完成** — 知识图谱 + 双通道检索,详见 `docs/25-phase19-knowledge-graph-and-retrieval.md`;全量 391 → **427 passed / 0 failed**(+36 新测试);**M15 里程碑达成**
- ✅ **v0.3.2 Phase 20-27 全部完成** — Cargo features 拆分(16 模块级 + 5 provider + 4 快捷组合),详见 `docs/roadmap-v0.3.2.md`;全量 427 → **427 passed**(不变,门控验证)
- ✅ **Phase 28-30 OpenAI Response API Provider 完成** — 独立 feature `provider-openai-response`,全量约 450 passed
- 📋 **v0.4.0 规划完成** — 5 个增量 PhaseA-E)覆盖多 Agent 编排、HITL + Steering、TokenJuice、自动校正。详见 [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md)
---
## 设计笔记
### Checkpointer 分层存储模型
> 来源:v0.4.0 规划讨论中涉及增量 Checkpoint 的技术推演。当前全量 JSON checkpoint 够用,但为未来优化预留设计方案。
#### 分层叠加模型(OverlayFS 模式)
受容器分层文件系统启发,增量 Checkpoint 可以借鉴 overlayfs 的"底层只读 + 上层可写叠加"设计:
**全量基座(只读)**
```rust
pub struct SnapshotBase {
pub checkpoint_id: String,
pub session_id: String,
pub snapshot: SessionSnapshot, // 完整 JSON 化状态
}
```
**增量层(叠加 diff**
```rust
pub struct SnapshotLayer {
pub base_checkpoint_id: String,
pub applies_to_id: String, // 在哪个 checkpoint 上叠加
pub diff: Vec<DiffOp>, // JSON Patch 操作集合
}
pub enum DiffOp {
MessageAppended { message: Message },
SlotChanged { slot_id: String, diff: serde_json::Value },
TurnIndexIncremented { from: u32, to: u32 },
CostUpdated { diff: CostTracker },
}
```
**重建路径**
```
rollback_load("session_x", 6)
→ 读取 "ckpt:{session_x}:base"(全量)
→ 读取 "ckpt:{session_x}:layer:1" ~ "ckpt:{session_x}:layer:6"
→ 依次应用 layer.1 → layer.2 → ... → layer.6
→ 得到 session_6 的状态
```
**层折叠(类似 docker squash**
```
layer.1 → layer.2 → layer.3 → layer.4 → layer.5
↓ 合并
base.ckpt'(包含 layer.1-3)→ layer.4 → layer.5
```
#### Shadow FS 模型(运行中保护)
与分层模型互补,shadow 模型适用于运行中的 session 保护而非长期存储:
```rust
// submit_turn 在 shadow session 上执行,commit 时才原子切换
let shadow = current_session.fork(); // 复用 ContextSlot::fork
let result = shadow.submit_turn(input).await;
if result.is_ok() {
current_session.commit(shadow); // 原子替换
} else {
drop(shadow); // 丢弃,当前 session 完好无损
}
```
#### 适用场景对比
| 模型 | 适合场景 | 不适合场景 |
|------|---------|-----------|
| **分层叠加(OverlayFS** | Checkpoint 链长期存储、time-travel、多版本回退 | session 较小(< 10KB/轮)时复杂度不值得 |
| **Shadow FSCoW** | 运行中 session 保护、防止 submit_turn 失败污染 | 不能替代 checkpoint 链、不支持多时间点回退 |
**触发条件**:当单 session checkpoint 超过 500KB 且频繁保存导致性能瓶颈时,考虑实现分层模型。
+476
View File
@@ -0,0 +1,476 @@
# AG Core Roadmap — v0.3.2
**状态**:✅ Phase 20-27 全部交付(v0.3.2 交付完毕)
> 本文件聚焦 **v0.3.2 版本** 的规划与交付(Phase 20–27)。
> 返回总入口:[`roadmap.md`](./roadmap.md)
> **进度更新(2026-07-19**v0.3.2 全部交付。Step 1 完成 Phase 20-25Cargo features 定义 + 依赖 optional 化 + 全模块 cfg 门控);Step 3 完成 Phase 26-27CI 测试矩阵固化 + LlmProvider trait 归属修正 + examples required-features + 文档更新)。验证矩阵 6 种组合全部通过(427/416/363/369/401/407 passed),clippy 0 警告,18 个 example 单独编译通过。
>
> **Step 3 实施中的关键调整**(超出原方案的发现):
> - `LlmProvider` trait + `ProviderCapabilities` + `ProviderFeatures` 从 `provider.rs` 移至新建的 `provider_trait.rs`,归属 `#[cfg(feature = "llm")]`ADR-1,纯 Mock 场景不再需要 provider feature
> - `llm` feature 补充 imply `futures-util`(修复 `cycle.rs` 隐式依赖)
> - `bundle()` 方法加 `#[cfg(feature = "engine")]` 门控修复 dead_code 警告
> - `prompt_composer` / `custom_tool` 的 required-features 需额外 `llm``response_v2.rs` 依赖 `LlmError`,预存耦合)
> - cargo fmt 全量格式化(修复预存格式问题,CI format job 可通过)
>
> 实施方案见 [`docs/26-step1-phase20-cargo-features-implementation.md`](./26-step1-phase20-cargo-features-implementation.md) 和 [`docs/27-step3-phase26-ci-verification.md`](./27-step3-phase26-ci-verification.md)。
## v0.3.2 愿景
通过 Cargo features 拆分,让下游按需选择模块,跳过不需要的编译单元和重型依赖。
## v0.3.2 总体范围
**版本等级**patchv0.3.2),`default = ["full"]` 保持向后兼容,非破坏性变更。
**改造基线**v0.3.0 已交付 23,718 行 Rust 代码,66 个源文件。当前所有依赖全量编译——引用 agcore 就意味着拉入 rusqlite bundled、reqwest、tokio full 等全部重型依赖。
**改造目标**16 个 features10 模块级 + 5 provider + 1 工具)+ 4 个快捷组合。下游可只选 `chat` 组合跳过 SQLite 和 MCP 的编译,或只选 `document` 实现纯文档分割零外部依赖。
**工作性质**:纯 cfg 门控 + Cargo.toml 配置变更,不新增功能代码。
**总体规模**8 个增量 PhasePhase 2027),预计新增/修改约 330 行配置与条件编译代码。
---
## 功能清单
### 模块级 features10 个)
| Feature | 覆盖内容 | imply | 外部依赖成本 |
|---------|---------|-------|-------------|
| `document` | Document + RecursiveCharacterSplitter | — | 无 |
| `llm-types` | Message, ToolDef, Usage, ToolChoice 等 IR 类型 | — | 无(只 serde + thiserror |
| `prompt` | PromptTemplate + PromptComposer | `llm-types` | 无 |
| `llm` | Provider trait + LlmCycle + hooks + compact + embedding + mock | `llm-types` | tokio, async-stream, futures-core, futures-util, tokio-stream |
| `tools` | BaseTool + ToolRegistry | `llm-types` | futures, tokio-util, tokio |
| `tools-mcp` | McpClientStdio/StreamableHttp | `tools` | reqwest |
| `memory` | MemoryStore(InMemory) + Conversation + VectorStore(InMemory) + KnowledgeGraph + Retriever | `document` + `llm` | tokio, time(继承 llm 的依赖) |
| `memory-sqlite` | SqliteStore | `memory` | rusqlite (bundled), time |
| `agent` | Agent + Builder + Session + ContextSlot + Summary | `llm` + `tools` + `memory` | 继承下层 |
| `engine` | SessionManager + Checkpointer + SubAgent + Switch | `agent` | 继承下层 |
### Provider features5 个,各自独立)
| Feature | imply | 外部依赖 |
|---------|-------|---------|
| `provider-openai` | `llm` | reqwest + bytes + futures-util |
| `provider-anthropic` | `llm` | reqwest + bytes + futures-util |
| `provider-deepseek` | `llm` | reqwest |
| `provider-qwen` | `llm` | reqwest |
| `provider-ollama` | `llm` | reqwest |
### 工具 features1 个)
| Feature | 控制 | 依赖 |
|---------|------|------|
| `tracing-init` | `init_tracing()` 函数 | tracing-subscriber |
### 快捷组合(4 个)
| 组合 | 定义 | 场景 |
|------|------|------|
| `full`default | 全部 16 个 feature | 全栈(兼容 v0.3 |
| `light` | llm + provider-openai + tools + tools-mcp + memory + agent + engine + prompt + document | 生产常用 |
| `chat` | agent + provider-openai | 纯对话(context+session+轻量记忆,跳过 SQLiteMCP 按需加 `tools-mcp` |
| `multi` | engine + provider-openai | 多 Agent 复合(chat + subagent + switch + checkpointerMCP 按需加 `tools-mcp` |
---
## 实施计划 — 8 个增量 Phase
> **编号说明**Phase 20-27 接续 v0.3.0 的 Phase 13-19。
### 实施节奏:4 个 Step
将 8 个 Phase 合并为 4 个实施步骤,平衡变更风险与执行效率。
| Step | Phase | 内容 | 验证方式 | 预估行数 |
|------|-------|------|---------|---------|
| **Step 1** ✅ | Phase 20-25 | Cargo.toml features 定义 + 依赖 optional 化 + 全模块 cfg 门控(合并实施) | 14 条编译验证全通过 + `cargo test -F full` 427 passed | ~100 |
| **Step 2** | (已合并至 Step 1 | — | — | — |
| **Step 3** ✅ | Phase 26 | 测试矩阵验证 + 修复 cfg 遗漏 + LlmProvider trait 归属修正 + examples required-features | 6 种组合全部测试通过 + clippy 0 警告 + 18 个 example 单独编译通过 | ~80 |
| **Step 4** ✅ | Phase 27 | README + 示例标注 + 总入口同步 | review 通过 | ~100 |
**Step 1 单独成步**Cargo.toml 是基础设施变更,编译通过后打 checkpoint,后续都是纯源文件变更。
**Step 2 合并 Phase 2125**:全是 `#[cfg(feature = "...")]` 公式化插门控,按依赖顺序(底层模块 → LLM/Provider → Tools/MCP → Memory → Agent/Engine)实施,每插一个 feature 门控就验证。按子模块分批 commit 控制粒度。
---
### Phase 20: Cargo.toml 基础设施改造
**目标**:定义完整的 [features] 表,重型依赖改为 optional,建立 imply 链。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **20.1** | 定义 16 个 features + 4 个快捷组合,`default = ["full"]` | `Cargo.toml` | `cargo build --features "full"` 编译通过,行为与原版一致 |
| **20.2** | tokio / reqwest / rusqlite / tracing-subscriber 改为 optional | `Cargo.toml` | `cargo build --no-default-features` 成功(空 crate |
| **20.3** | tokio-stream / futures / futures-util / futures-core / bytes / async-stream / tokio-util / time 改为 optional | `Cargo.toml` | `cargo build --features "full"` 全量依赖正确拉取 |
| **20.4** | tokio features 拆细:从 `["full"]` 改为 `["rt", "sync", "time", "macros", "process", "io-util"]`,仅保留实际使用的子模块 | `Cargo.toml` | `cargo build --features "llm,provider-openai"` 不拉入 tokio net/http 等无关子模块 |
| **20.5** | feature imply 链配置:`prompt → llm-types``llm → llm-types``tools → llm-types``memory → document``agent → llm + tools + memory`(不含 tools-mcp),`engine → agent` | `Cargo.toml` | `cargo build --features "agent,provider-openai"` transitive 依赖自动拉取 |
**依赖**:无(Cargo.toml 独立改造)
**优先级**P0
**预估规模**:约 40 行
**状态**:✅ 已交付(2026-07-19)— features 定义 + 依赖 optional 化 + tokio features 拆细(含 `rt-multi-thread` 修正)
---
### Phase 21: 底层模块 cfg 门控注入
**目标**:为 llm-types、document、prompt 三个零/低外部依赖模块添加条件编译门控。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **21.1** | `src/lib.rs` 中所有 `pub mod` 声明加 `#[cfg(feature = "...")]` | `src/lib.rs` | `cargo build --no-default-features` 无模块引入 |
| **21.2** | llm-types 模块条件编译 + 公共类型条件导出 | `src/llm/types/` | `cargo build --no-default-features --features "llm-types"` 编译通过 |
| **21.3** | document 模块条件编译 + `pub use Document` 条件导出 | `src/document.rs` | `cargo build --no-default-features --features "document"` 编译通过 |
| **21.4** | prompt 模块条件编译 | `src/prompt.rs` | `cargo build --no-default-features --features "prompt"` 编译通过 |
**依赖**Phase 20(需 feature 定义就绪)
**优先级**P0
**预估规模**:约 30 行
**状态**:✅ 已交付(2026-07-19Step 1 合并)— `src/lib.rs` 全部 `pub mod` + `pub use Document` 门控完成
---
### Phase 22: LLM + Provider 门控注入
**目标**:llm 模块整体门控 + 5 个 Provider 独立条件编译 + cycle.rs 中 ToolRegistry 引用的 `#[cfg]` 隔离。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **22.1** | llm 模块 cfg + embedding 子模块条件导出 + MockProvider 条件编译 | `src/llm.rs` | `cargo build --no-default-features --features "llm"` 编译通过 |
| **22.2** | `create_provider()` + `build_client_*` 条件编译,按 feature 分别暴露 | `src/llm/provider.rs` | 各 provider feature 单独启用 |
| **22.3** | OpenAI provider `#[cfg(feature = "provider-openai")]` | `src/llm/provider/openai.rs` | `--features "llm,provider-openai"` 编译通过;不含时不编译 |
| **22.4** | Anthropic provider 条件编译 | `src/llm/provider/anthropic.rs` | `--features "llm,provider-anthropic"` 编译通过 |
| **22.5** | DeepSeek + Qwen 共享 `openai_compat.rs``any(feature = "provider-deepseek", feature = "provider-qwen")` 条件 | `src/llm/provider/openai_compat.rs` | 各自单独编译通过 |
| **22.6** | Ollama provider 条件编译 | `src/llm/provider/ollama.rs` | `--features "llm,provider-ollama"` 编译通过 |
| **22.7** | `cycle.rs` 中 ToolRegistry 引用 + `submit_with_tools` 系列方法 `#[cfg(feature = "tools")]` | `src/llm/cycle.rs` | `--features "llm,provider-openai"` 不含 tools 编译通过 |
**依赖**Phase 20 + Phase 21
**优先级**P0
**预估规模**:约 80 行(中复杂度,cycle.rs 门控需精确隔离)
**状态**:✅ 已交付(2026-07-19Step 1 合并)— `src/llm.rs` 子模块按 llm-types/llm/provider 三类门控;`cycle.rs``ToolRegistry` import + `submit_with_tools` / `submit_with_tools_stream` / `run_tool_loop``#[cfg(feature = "tools")]`Phase 22.7 提前)
---
### Phase 23: Tools + MCP 门控注入
**目标**tools 模块整体门控 + mcp 子模块条件编译。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **23.1** | tools 模块 cfg + pub use 条件导出 | `src/tools.rs` | `--features "tools"` 编译通过;不含时不编译 |
| **23.2** | `mcp.rs` 整个文件 `#[cfg(feature = "tools-mcp")]` | `src/tools/mcp.rs` | `--features "tools"` 不含 mcp 时编译通过;加 `tools-mcp` 时引入 |
| **23.3** | ToolRegistry 中 McpClient 引用的条件导出 | `src/tools/registry.rs` | `--features "tools"` 不含 mcp 编译通过 |
**依赖**Phase 20 + Phase 21
**优先级**P0
**预估规模**:约 20 行
**状态**:✅ 已交付(2026-07-19Step 1 合并)— `src/tools.rs``pub mod mcp` + `pub use mcp::*``#[cfg(feature = "tools-mcp")]`
---
### Phase 24: Memory 门控注入
**目标**memory 模块门控 + vector_store 中 Embedding 引用隔离 + SqliteStore 可选化。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **24.1** | memory 模块 cfg + pub use 条件导出 | `src/memory.rs` | `--features "memory"` imply document 编译通过 |
| **24.2** | vector_store 中 Embedding trait 引用 `#[cfg(feature = "llm")]` | `src/memory/vector_store.rs` | `--features "memory"` 不含 `llm` 编译通过 |
| **24.3** | `sqlite_store.rs` 整个文件 `#[cfg(feature = "memory-sqlite")]` | `src/memory/store/sqlite_store.rs` | `--features "memory"` 不含 sqlite 编译通过 |
| **24.4** | `memory.rs``pub use SqliteStore` 条件导出 | `src/memory.rs` | `--features "memory-sqlite"` 正确导出 SqliteStore |
**依赖**Phase 20 + Phase 21
**优先级**P0
**预估规模**:约 30 行
**状态**:✅ 已交付(2026-07-19Step 1 合并)— Phase 24.3`sqlite_store` 模块门控)+ Phase 24.4`pub use SqliteStore` 门控)已完成;Phase 24.1memory 模块 pub use)由 `src/lib.rs``#[cfg(feature = "memory")]` 覆盖;Phase 24.2vector_store 中 Embedding 引用隔离)经 Phase 26 验证无需补充——`memory` feature imply `llm``Embedding` trait 在 `memory` 启用时一定可用
---
### Phase 25: Agent + Engine 门控注入
**目标**agent 和 engine 两个高层模块的条件编译门控。注意 agent 不再 imply tools-mcp——MCP 作为可选工具层由用户显式启用。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **25.1** | agent 模块 cfg + pub use 条件导出 | `src/agent.rs` | `--features "agent,provider-openai"` 编译通过 |
| **25.2** | engine 模块 cfg + 子模块条件导出(switch / sub_agent / checkpointer | `src/engine/` | `--features "engine,provider-openai"` 编译通过 |
| **25.3** | `lib.rs` 中 agent / engine 模块声明 cfg + 条件重导出 | `src/lib.rs` | 验证 `engine` imply `agent` 链正确,transitive 依赖完整 |
**依赖**Phase 20-24(全链路依赖就绪后操作)
**优先级**P0
**预估规模**:约 20 行
**状态**:✅ 已交付(2026-07-19Step 1 合并)— Phase 25.3`src/lib.rs` 中 agent/engine 模块声明 cfg)已完成;Phase 25.1/25.2agent/engine 内部子模块条件导出)由 `src/lib.rs` 顶层门控覆盖;`src/agent/session.rs` 中 engine 相关 import + `to_snapshot` / `from_snapshot` / `restore_memory` / `has_pending_memory_restore` + `pending_memory_restore` 字段加 `#[cfg(feature = "engine")]`Phase 26 验证 `bundle()` 方法加 `#[cfg(feature = "engine")]` 门控修复 dead_code 警告
---
### Phase 26: 快捷组合验证 + 测试矩阵
**目标**:验证 4 个快捷组合 + clippy 完整性检查。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **26.1** | `default = ["full"]` 回归验证 | CI | `cargo test --features "full"` 全绿(427 passed |
| **26.2** | light 组合编译 + 单元测试 | CI | `cargo test --no-default-features --features "light"` 通过 |
| **26.3** | chat 组合(无 MCP)编译 + 单元测试 | CI | `cargo test --no-default-features --features "chat,provider-openai"` 通过 |
| **26.4** | chat + MCP 组合编译 + 单元测试 | CI | `cargo test --no-default-features --features "chat,provider-openai,tools-mcp"` 通过 |
| **26.5** | multi 组合(无 MCP)编译 + 单元测试 | CI | `cargo test --no-default-features --features "multi,provider-openai"` 通过 |
| **26.6** | multi + MCP 组合编译 + 单元测试 | CI | `cargo test --no-default-features --features "multi,provider-openai,tools-mcp"` 通过 |
| **26.7** | clippy `--all-features` 无警告 | CI | `cargo clippy --all-features -- -D warnings` 0 警告 |
| **26.8** | 修复各组合编译中发现的 cfg 遗漏 | 全量 | 7 种组合全部编译 + 测试通过 |
**依赖**Phase 20-25(所有门控就绪)
**优先级**P0
**预估规模**:约 10 行(CI 配置)
**状态**:✅ 已交付(2026-07-19)— 6 种 feature 组合测试矩阵 + clippy + format + examples 验证 job 全部通过;`RUSTFLAGS=-D warnings` 强制零警告;LlmProvider trait 归属修正 + bundle() 门控 + llm feature 补充 imply futures-util
---
### Phase 27: 文档更新 + 示例标注 + README feature 表
**目标**:让下游使用者能快速理解 feature 体系并选择合适组合。
| Step | 内容 | 文件范围 | 验证标准 |
|------|------|---------|---------|
| **27.1** | README.md 添加 feature 表格 + `Cargo.toml` 使用示例 + 各组合推荐场景 | `README.md` | review 通过 |
| **27.2** | 各示例文件顶部添加所需的 feature 组合标注注释 | `examples/*.rs` | review 通过 |
| **27.3** | 更新 `docs/roadmap.md` 总入口添加 v0.3.2 链接和简要状态 | `docs/roadmap.md` | review 通过 |
**依赖**Phase 20-26
**优先级**P0
**预估规模**:约 100 行
**状态**:✅ 已交付(2026-07-19)— README 添加 feature 表格 + 快捷组合 + 模块级 features 清单 + 升级指南(LlmProvider 路径迁移);18 个 example 顶部添加 Required features 注释;roadmap 总入口同步
---
## Feature 依赖关系图
```mermaid
graph TD
subgraph "快捷组合"
FULL["full (default)"]
LIGHT["light"]
CHAT["chat"]
MULTI["multi"]
end
subgraph "模块级"
ENGINE["engine"]
AGENT["agent"]
LLM["llm"]
TOOLS["tools"]
TOOLS_MCP["tools-mcp"]
MEMORY["memory"]
MEMORY_SQLITE["memory-sqlite"]
PROMPT["prompt"]
LLM_TYPES["llm-types"]
DOCUMENT["document"]
end
subgraph "Provider"
P_OPENAI["provider-openai"]
P_ANTHROPIC["provider-anthropic"]
P_DEEPSEEK["provider-deepseek"]
P_QWEN["provider-qwen"]
P_OLLAMA["provider-ollama"]
end
FULL --> LIGHT & CHAT & MULTI
ENGINE --> AGENT
AGENT --> LLM & TOOLS & MEMORY
CHAT -.-> TOOLS_MCP
MULTI -.-> TOOLS_MCP
MEMORY_SQLITE --> MEMORY
TOOLS_MCP --> TOOLS
MEMORY --> DOCUMENT
LLM --> LLM_TYPES
TOOLS --> LLM_TYPES
PROMPT --> LLM_TYPES
P_OPENAI --> LLM
P_ANTHROPIC --> LLM
P_DEEPSEEK --> LLM
P_QWEN --> LLM
P_OLLAMA --> LLM
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
classDef pending fill:#fbbf24,stroke:#d97706,color:#1a1a1a
classDef provider fill:#93c5fd,stroke:#2563eb,color:#1a1a1a
class P_OPENAI,P_ANTHROPIC,P_DEEPSEEK,P_QWEN,P_OLLAMA provider
class FULL,ENGINE,AGENT,LLM,TOOLS,TOOLS_MCP,MEMORY,MEMORY_SQLITE,PROMPT,LLM_TYPES,DOCUMENT,LIGHT,CHAT,MULTI done
```
## 关键里程碑
| 里程碑 | Phase 完成条件 | 可验证指标 | 状态 |
|--------|---------------|-----------|------|
| **M16** | Phase 20 | `cargo build --no-default-features` 成功;`cargo build --features "full"` 与原行为一致 | ✅ 2026-07-19 |
| **M17** | Phase 21 | 三种零依赖模块各自独立编译通过 | ✅ 2026-07-19Step 1 合并) |
| **M18** | Phase 22 | 5 个 provider 各自单独编译;cycle.rs 无 tools 时编译通过 | ✅ 2026-07-19Step 1 合并) |
| **M19** | Phase 23 | tools 不含 mcp 编译通过;加 tools-mcp 引入 McpClient | ✅ 2026-07-19Step 1 合并) |
| **M20** | Phase 24 | memory imply document+llm 编译通过;不含 sqlite 编译通过;加 memory-sqlite 引入 SqliteStore | ✅ 2026-07-19Step 1 合并) |
| **M21** | Phase 25 | agent + engine 全链路门控编译通过 | ✅ 2026-07-19Step 1 合并) |
| **M22** | Phase 26 | 7 种 CI 组合全部编译 + 测试通过;clippy --all-features 0 警告 | ✅ 2026-07-19 |
| **M23** | Phase 27 | 文档 review 通过 | ✅ 2026-07-19 |
## Cargo.toml [features] 草案
```toml
[dependencies]
# 轻量核心依赖(始终编译)
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
async-trait = "0.1"
tracing = "0.1"
# 按 feature 可选的重依赖
tokio = { version = "1", features = ["rt", "sync", "time", "macros", "process", "io-util"], optional = true }
reqwest = { version = "0.12", features = ["json", "stream"], optional = true }
rusqlite = { version = "0.32", features = ["bundled"], optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
tokio-stream = { version = "0.1", optional = true }
futures = { version = "0.3", optional = true }
futures-util = { version = "0.3", optional = true }
futures-core = { version = "0.3", optional = true }
bytes = { version = "1", optional = true }
async-stream = { version = "0.3", optional = true }
tokio-util = { version = "0.7", features = ["rt"], optional = true }
time = { version = "0.3", features = ["serde", "parsing", "formatting", "macros"], optional = true }
```
```toml
[features]
default = ["full"]
# === 模块级 features ===
document = []
llm-types = []
prompt = ["llm-types"]
llm = ["llm-types", "tokio", "async-stream", "futures-core", "futures-util", "tokio-stream"]
tools = ["llm-types", "futures", "tokio-util", "tokio"]
tools-mcp = ["tools", "reqwest"]
memory = ["document", "llm", "tokio", "time"]
memory-sqlite = ["memory", "rusqlite", "time"]
agent = ["llm", "tools", "memory", "futures-util"]
engine = ["agent"]
# === Provider features ===
provider-openai = ["llm", "reqwest", "bytes", "futures-util"]
provider-anthropic = ["llm", "reqwest", "bytes", "futures-util"]
provider-deepseek = ["llm", "reqwest"]
provider-qwen = ["llm", "reqwest"]
provider-ollama = ["llm", "reqwest"]
# === 工具 features ===
tracing-init = ["tracing-subscriber"]
# === 快捷组合 ===
full = [
"document", "llm-types", "prompt", "llm",
"tools", "tools-mcp",
"memory", "memory-sqlite",
"agent", "engine",
"provider-openai", "provider-anthropic", "provider-deepseek",
"provider-qwen", "provider-ollama",
"tracing-init",
]
light = [
"llm", "provider-openai", "tools", "tools-mcp",
"memory", "agent", "engine",
"prompt", "document",
]
chat = ["agent", "provider-openai"]
multi = ["engine", "provider-openai"]
```
### 依赖 optional 化对照
| 依赖 | 启用者 | 当前声明 |
|------|--------|---------|
| `tokio`features = `rt, rt-multi-thread, sync, time, macros, process, io-util` | llm, tools, memory | `optional = true` |
| `reqwest`features = ["json", "stream"] | provider-*, tools-mcp | `optional = true` |
| `rusqlite`features = ["bundled"] | memory-sqlite | `optional = true` |
| `tracing-subscriber`features = ["env-filter"] | tracing-init | `optional = true` |
| `tokio-stream` | llm | `optional = true` |
| `futures` | tools | `optional = true` |
| `futures-util` | llm, provider-*, agent | `optional = true` |
| `futures-core` | llm | `optional = true` |
| `bytes` | provider-openai, provider-anthropic | `optional = true` |
| `async-stream` | llm | `optional = true` |
| `tokio-util`features = ["rt"] | tools | `optional = true` |
| `time`features = ["serde","parsing","formatting","macros"] | memory, memory-sqlite | `optional = true` |
**始终编译**(轻量依赖,不参与 feature 门控):`serde``serde_json``thiserror``async-trait``tracing`
### CI 测试矩阵(已实施)
```yaml
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
env:
RUSTFLAGS: "-D warnings"
jobs:
test-matrix:
strategy:
fail-fast: false
matrix:
features:
- "full"
- "light"
- "chat,provider-openai"
- "chat,provider-openai,tools-mcp"
- "multi,provider-openai"
- "multi,provider-openai,tools-mcp"
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo test --no-default-features --features "${{ matrix.features }}" --lib
clippy:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo clippy --all-features --lib -- -D warnings
format:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
- run: cargo fmt --check
examples:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: nightly
- run: cargo test --features "full"
```
**关键设计决策**
- 矩阵使用 `--lib` 避免 examples 编译干扰模块测试验证
- `RUSTFLAGS=-D warnings` 强制零警告
- format job 使用 stable toolchain`cargo fmt --check` 无需 nightly
- 独立 `examples` job 验证所有 example 在完整 features 下编译
- 每个 job 设置 `timeout-minutes` 兜底
---
返回总入口:[`roadmap.md`](./roadmap.md)
+233
View File
@@ -0,0 +1,233 @@
# AG Core Roadmap — v0.4.0
> 本文件聚焦 **v0.4.0 版本** 的规划。Phase A-E 计划中,覆盖多 Agent 编排、Human-in-the-loop 与 Steering、语义压缩、自动校正。
> 返回总入口:[`roadmap.md`](./roadmap.md)
## v0.4.0 愿景
从 v0.3 的"多 Agent 基础系统"升级为"多 Agent 多职责编排系统"。补齐高层编排抽象(Swarm/Supervisor/Subgraph)、生产级人工干预能力(HITL + Steering)、工具与消息的语义压缩(TokenJuice),以及自动质量校正(Reflection)。为即将开发的多 Agent 协作产品提供完整的编排、干预与质量保证层。
## v0.4.0 总体范围
**总体规模**5 个增量 PhasePhase A-E),总新增代码约 1,950 行,零强制新外部依赖,零破坏性变更。
### 架构决策
**路线选择**:采用轻量编排模式(路线 A),不引入通用有向图引擎。通过 `Swarm::star()` / `Swarm::sequential()` / `Swarm::hierarchical()` 等具名模式提供编排能力,底层复用现有 `dispatch` / `create_child` / `SessionManager` 基础设施。预留路线 B(StateGraph 抽象)作为未来版本的升级路径。
**模块位置**
- 编排逻辑 → `src/engine/supervisor.rs`(新增)
- 内建工具 → `src/tools/builtin.rs`(新增)
- TokenJuice 压缩 → `src/llm/compress.rs`(新增)
- Steering 机制 → `src/engine/steer.rs`(新增,或并入 supervisor.rs
### 功能清单
#### P0 — 必须交付
| # | 功能 | 模块 | 方案要点 |
|---|------|------|---------|
| 1 | Swarm 编排(Star/Sequential/Hierarchical + Subgraph | `engine/supervisor` | `Swarm::star().supervisor(A).worker(B)` 声明式 API`Swarm::sequential().link(A).link(B)` 串联;`Swarm::hierarchical().supervisor(root).group("sub", ...)` 层次嵌套 |
| 2 | 结果聚合 | `engine/supervisor` | `aggregation_prompt` 模板将子 Agent 结果合并到 Supervisor 上下文;`DispatchConfig` 扩展 `result_key` 字段 |
| 3 | Human-in-the-loop 审批 | `engine/steer` | `interrupt()` 暂停执行 + `Command(resume=bool)` 恢复;`HookEvent::OnInterrupt` 新变体 |
| 4 | 用户 Steering(运行中校正) | `engine/steer` | `Command(resume=Correction{...})` 结构化校正;Steer 消息在工具批处理边界注入 |
| 5 | TokenJuice 语义压缩 | `llm/compress` | `Compressor` trait 统一抽象;覆盖工具结果、对话历史、跨 Agent 消息三层;LLM 摘要压缩 + 确定性兜底 |
#### P1 — 推荐交付
| # | 功能 | 模块 | 方案要点 |
|---|------|------|---------|
| 6 | 自动校正 / Reflection | `engine/reflect` | Evaluator-Optimizer 循环;Producer-Critic 角色分离;上限 2-3 轮迭代 |
### 实施计划 — 5 个增量 Phase
> **编号说明**Phase A-E 为 v0.4.0 专属编号,接续已完成的 Phase 30。
---
#### Phase A: Swarm 编排抽象(Star / Sequential / Hierarchical + Subgraph
**目标**:在现有 `dispatch` 原语基础上,提供声明式多 Agent 编排 API。Supervisor 作为 `Arc<dyn Agent>`,通过内建工具 `dispatch_sub_agent` 驱动子 Agent 执行。
**交付物**
1. `src/engine/supervisor.rs` 新文件:
- `Swarm` 枚举/结构体:`Swarm::star()`(星型,一个 Supervisor + N 个 Worker)、`Swarm::sequential()`(顺序链 A→B→C)、`Swarm::hierarchical()`(层次嵌套,Supervisor 下的 Sub-Supervisor
- 各模式的 `build()``run(input)` 方法
- 底层通过 `SessionManager::dispatch()` / `dispatch_all()` 实现
2. `src/tools/builtin.rs` 新文件:
- `dispatch_sub_agent(name, task, config)` 内建工具 — 从 Agent 注册表查找 Agent 工厂 → `SessionManager::dispatch()`
3. `AgentRegistry``HashMap<String, Box<dyn Fn() -> Arc<dyn Agent>>>` 轻量工厂注册表(约 50 行)
4. Subgraph 嵌套:`Swarm::hierarchical()` 支持 `group(name, inner_swarm)`,内层 Swarm 作为子节点编译后嵌入
**设计要点**
- Supervisor 就是 `Arc<dyn Agent>`,不新增 `SupervisorAgent` trait
- 路由逻辑写在 Supervisor 的 system prompt 中(LLM 决定的动态路由)
- 三种模式覆盖常见编排拓扑,不引入通用图引擎(路线 B 留作未来)
- Subgraph 编译为独立的 `SessionManager` 子树(复用 `create_child` 的父子关系)
**依赖**Phase 18SubAgent dispatch / SessionManager
**优先级**P0
**预估规模**:约 500 行
**状态**:📋 待实施
---
#### Phase B: 结果聚合 + 编排模式完善
**目标**:让 Supervisor 能智能地合并 Worker 结果。完善三种编排模式的容错性和易用性。
**交付物**
1. `aggregation_prompt` 模板系统 — 内建 `DEFAULT_AGGREGATION_PROMPT`,用户可自定义聚合逻辑
2. `DispatchConfig` 扩展:
- `result_key: Option<String>` — 将子结果存入 `session_memory` 的指定 key,供后续阶段使用
- `aggregate_strategy: AggregateStrategy``Concatenate` / `Summarize` / `Custom(Value)`
3. 编排模式增强:
- `Swarm::sequential()` 支持失败时停止 / 跳过 / 重试策略
- `Swarm::star()` 支持 Worker 超时
4. 端到端示例 3 个:
- `swarm_star_demo.rs` — 星型编排 + 并发派发 + 结果聚合
- `swarm_sequential_demo.rs` — 串联流水线
- `swarm_hierarchical_demo.rs` — 层次嵌套(Supervisor → Sub-Supervisor → Worker
**依赖**Phase A
**优先级**P0
**预估规模**:约 200 行
**状态**:📋 待实施
---
#### Phase C: Human-in-the-loop + 用户 Steering
**目标**:生产级多 Agent 系统的关键门禁。提供执行中暂停-审批-恢复机制,以及用户运行中校正方向的能力。
**交付物**
1. `src/engine/steer.rs` 新文件:
- `interrupt(value)` 函数 — 在工具循环中插入暂停点,持久化当前状态后返回控制权
- `Command` 枚举:
- `Command::Resume(bool)` — 二元审批(批准/拒绝)
- `Command::ResumeWith(Correction)` — 结构化校正(修改工具参数 / 调整方向)
2. `LlmCycle` 扩展:可中断工具循环模式
- `submit_with_tools_interruptible()` — 支持在工具批处理边界检查中断信号
- 中断时保存当前 `LlmCycle` 状态到 checkpoint
3. `HookEvent::OnInterrupt` / `OnSteer` 新变体 — 监听中断和校正事件
4. `SessionManager::resume_turn(session_id, resume_data)` — 从 checkpoint 恢复并注入审批结果
5. `tools/builtin.rs` 扩展:
- `request_approval(question, context)` — 请求用户审批
- `emit_steer(correction)` — 用户校正
6. Steering 生命周期:
- `interrupt` → 用户收到提示 → 用户决定方向 → `Command::ResumeWith(correction)` → Agent 在新方向上继续
**设计要点**
- User Steering 不是简单的"批准/拒绝",而是 `Correction { action, reason, amended_params }` 结构化指令
- Steering 消息在工具批处理边界(Worker 返回后、Supervisor 决策前)注入,不中断正在执行的工具
- 继承 `ContextSlot::fork/merge` 模式,steer 前 fork 快照,允许用户回退到 steer 前状态
**依赖**Phase ASwarm 编排)
**优先级**P0
**预估规模**:约 500 行
**状态**:📋 待实施
---
#### Phase D: TokenJuice 语义压缩
**目标**:替代当前字节级截断(`microcompact``[pruned]`),提供语义级别的压缩。在三层管道中接入:工具结果压缩、对话历史压缩、跨 Agent 消息压缩。
**交付物**
1. `src/llm/compress.rs` 新文件:
- `Compressor` trait`async fn compress(&self, input: &str, ctx: &CompressionContext) -> Result<String>`
- `CompressionContext``target_tokens` / `preserve_keys` / `strategy`
- `CompressionStrategy` 枚举:`Semantic { model }`LLM 摘要)、`Extractive { ratio }`(抽取式)、`Hybrid { semantic_first }`(混合)
- `SemanticCompressor` 实现(复用已有 provider 做 LLM 摘要压缩)
- `ExtractiveCompressor` 实现(确定性关键句提取,零 LLM 调用)
2. 三层接入点:
- **工具结果压缩**:在 `run_tool_loop` 中,`tool.execute()` 后插入 `compress_result()`,压缩结果再 `push ToolResult`
- **对话历史压缩**:在 `load_messages()` 后插入 `compress_history()`,替代/补充 `microcompact`
- **跨 Agent 消息压缩**:在 `inherit_session_memory` 的子 memory 写入前压缩(减少子 Agent 的 context 水位)
3. `CycleConfig` / `CompactConfig` 扩展:
- `token_compression: Option<CompressionConfig>` — 可选语义压缩配置
- `fallback_to_microcompact: bool`(默认 `true`)— LLM 压缩失败时退化为字节截断
4. TokenJuice 与现有 `microcompact` 的关系:
- `microcompact` 保留为最轻量级兜底(零 LLM 调用)
- TokenJuice 是可选增强层(默认关闭,用户 opt-in)
**设计要点**
- 零新外部依赖:LLM 摘要压缩复用已有 provider,抽取式压缩纯 Rust 实现
- 与现有 `CompactState` 断路器模式兼容(LLM 压缩失败 3 次后自动降级到 `microcompact`
- `preserve_keys` 确保关键数据(数字、ID、SQL、代码片段)不被压缩掉
**依赖**Phase 14Embedding trait 可选参考)
**优先级**P0
**预估规模**:约 400 行
**状态**:📋 待实施
---
#### Phase E: 自动校正 / Reflection
**目标**:实现 Agent 输出后的自我质量评估与自动修正循环。基于 `interrupt/resume` 基础设施,构建 Producer-Critic 闭环。
**交付物**
1. `src/engine/reflect.rs` 新文件:
- `ReflectionConfig``max_cycles`(默认 2/ `critic_agent`(可选不同模型)/ `criteria: Vec<String>`(评估标准)
- `Reflectable` trait`fn reflection_criteria(&self) -> Vec<String>` + `fn needs_refinement(&self, critique: &Critique) -> bool`
- `ReflectionLoop``evaluate(output) → Critique``should_refine? → yes: refine(output, critique) → 循环 / no: 返回`
2. Swarm 内建 Reflection 模式:
- `Swarm::reflect(producer_agent, critic_agent)` — 专用 Reflection Swarm
- 可在 Supervisor 流程中嵌入 `reflect_on(worker_result)` — 对 Worker 结果自动过一遍质量检查
3. `Critique` 结构体:`issues: Vec<Issue>` / `score: f32` / `should_refine: bool` / `suggestions: Vec<String>`
4. `tools/builtin.rs` 扩展:`verify_output(claim, evidence)` 工具 — 让 Agent 自行验证输出真实性
**设计要点**
- Producer 和 Critic 使用**不同模型**(避免同一模型的自我审查盲区 bias)
- 上限 2-3 轮(第一轮修正捕获 70–80% 改善空间,第 4+ 轮收益递减)
- 基于已有 `HookEvent::OnTurnEnd` 或扩展 `HookEvent::OnOutputGenerated` 触发反思
- 失败静默:Reflection 失败不阻断主流程(`tracing::warn!` 后继续交付原始输出)
**依赖**Phase Cinterrupt/resume 基础设施)
**优先级**P0
**预估规模**:约 350 行
**状态**:📋 待实施
---
### v0.4.0 Phase 依赖关系图
```mermaid
graph BT
PA["<b>Phase A: Swarm 编排</b><br/>Swarm::star/sequential/hierarchical<br/>Subgraph 嵌套<br/>内建 dispatch_sub_agent 工具<br/>~500 行"]:::pending
PB["<b>Phase B: 结果聚合</b><br/>aggregation_prompt 模板<br/>DispatchConfig result_key<br/>编排模式完善<br/>3 个端到端示例<br/>~200 行"]:::pending
PC["<b>Phase C: HITL + Steering</b><br/>interrupt/resume<br/>Command(ResumeWith Correction)<br/>HookEvent::OnInterrupt<br/>~500 行"]:::pending
PD["<b>Phase D: TokenJuice</b><br/>Compressor trait<br/>工具结果/历史/跨 Agent 压缩<br/>Semantic + Extractive 策略<br/>~400 行"]:::pending
PE["<b>Phase E: 自动校正</b><br/>ReflectionLoop<br/>Producer-Critic<br/>上限 2-3 轮<br/>~350 行"]:::pending
PB --> PA
PC --> PA
PE --> PC
classDef done fill:#4ade80,stroke:#16a34a,color:#1a1a1a
classDef pending fill:#fbbf24,stroke:#d97706,color:#1a1a1a
classDef future fill:#94a3b8,stroke:#64748b,color:#1a1a1a
```
### 关键里程碑
| 里程碑 | Phase 完成条件 | 可验证指标 | 状态 |
|--------|---------------|-----------|------|
| **M16** | Phase A | `Swarm::star().supervisor(A).worker(B).run(input)` 端到端验证;`dispatch_sub_agent` 内建工具注册并可用;2 个示例 exit 0 | 📋 待启动 |
| **M17** | Phase B | `Swarm::sequential()` 串联执行验证;`Swarm::hierarchical()` 层次嵌套验证;结果聚合正确合并;3 个新示例 exit 0 | 📋 待启动 |
| **M18** | Phase C | `interrupt()` 暂停 + `Command::Resume(bool)` 恢复全链路验证;`Command::ResumeWith(Correction)` 结构化校正验证;HookEvent 触发验证 | 📋 待启动 |
| **M19** | Phase D | 工具结果经语义压缩后保留关键信息(验证压缩比 ≥ 3:1);`microcompact` 降级路径验证;对话历史压缩验证 | 📋 待启动 |
| **M20** | Phase E | ReflectionLoop 正确性验证:已知缺陷的输出被修复、无缺陷的输出不被修改(不变性保证);2 轮迭代上限验证;Critic 不同模型配置验证 | 📋 待启动 |
### 不做(v0.5+
| 功能 | 原因 |
|------|------|
| Agent 自动创生(LLM 驱动动态分派) | 设计复杂且不确定性高,v0.4 专注显式声明式编排 |
| 分布式 Session 共享(Redis 后端) | 与编排正交,大多数用户单进程即可 |
| 精确 tokenizer 计数(tiktoken-rs | 依赖引入,v0.4 专注编排与压缩能力本身 |
| 增量 Checkpoint | 存储优化,当前全量 JSON 够用 |
| 路线 BStateGraph 通用图引擎) | 当前编排需求在路线 A 范围内,图引擎留给未来版本 |
| RL 轨迹导出 | 专项需求,非通用 |
| Markdown 技能按需加载 | 独立功能 |
@@ -1,7 +1,7 @@
# AG Core Roadmap
> 拆分式 roadmap:按版本归档 + 未归类内容
> 最后更新:2026-07-17v0.3.0 Phase 19 完成 + M15 里程碑达成 + v0.3.0 全部交付完毕
> 最后更新:2026-07-21v0.4.0 规划完成 — Phase A-E 多 Agent 编排路线图制定
## 文件索引
@@ -10,11 +10,14 @@
| [`roadmap-v0.1.0.md`](./roadmap-v0.1.0.md) | v0.1.0 计划与交付 — Phase 04c + v0.1.0 Release | ✅ 已发布 2026-07-04 |
| [`roadmap-v0.2.0.md`](./roadmap-v0.2.0.md) | v0.2.0 计划与交付 — Phase 512 + v0.2.0-rc.1 | 🟡 Phase 5-11 已完成;Phase 12 P2 锦上添花可选 |
| [`roadmap-v0.3.0.md`](./roadmap-v0.3.0.md) | v0.3.0 计划与交付 - Phase 1319 | ✅ Phase 13-19 全部完成,v0.3.0 交付完毕 |
| [`roadmap-v0.3.2.md`](./roadmap-v0.3.2.md) | v0.3.2 计划与交付 — Phase 2027Cargo features 拆分) | ✅ Phase 20-27 全部完成,v0.3.2 交付完毕 |
| [`28-phase28-openai-response-api-provider.md`](./28-phase28-openai-response-api-provider.md) | Phase 28-30 OpenAI Response API Provider 实施方案(独立 feature `provider-openai-response` | ✅ Phase 28-30 已交付 |
| [`roadmap-v0.4.0.md`](./roadmap-v0.4.0.md) | v0.4.0 计划 — Phase A-ESwarm 编排、HITL + Steering、TokenJuice 语义压缩、自动校正) | 📋 计划中 |
| [`roadmap-unsorted.md`](./roadmap-unsorted.md) | 未归到任何版本的内容 — 全局愿景、当前状态、模块完整性、v0.4+ 展望、风险与建议、下一步行动、阶段总回顾 | — |
## 阅读建议
- **按版本顺序追溯历史**v0.1.0 → v0.2.0 → v0.3.0
- **按版本顺序追溯历史**v0.1.0 → v0.2.0 → v0.3.0 → v0.4.0
- **了解产品演进全貌**:从 `roadmap-unsorted.md` 顶部开始读
- **查找特定 Phase**:每个版本文件内按 Phase 编号顺序排列
- **了解项目当前关注点**:从 `roadmap-unsorted.md` 的「下一步行动」开始
+1
View File
@@ -1,4 +1,5 @@
//! agent_session_demo —— Agent 装配 + 会话链路 + SessionMemory 桥接。
//! Required features: cargo run --example agent_session_demo --features "agent"
//!
//! 演示:
//! 1. 实现 `Agent` trait(定义角色 + system prompt
+5 -1
View File
@@ -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");
}
+16 -9
View File
@@ -1,4 +1,5 @@
//! bridge_keys_demo —— bridge_keys 过滤 + 子↔子共享 namespace 示例。
//! Required features: cargo run --example bridge_keys_demo --features "engine"
//!
//! 演示:
//! 1. 父 session 设置 SessionMemorykey: "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");
+14 -5
View File
@@ -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
View File
@@ -1,4 +1,5 @@
//! conversation_memory_demo —— 对话记忆滑动窗口与隔离。
//! Required features: cargo run --example conversation_memory_demo --features "memory"
//!
//! 演示:
//! 1. `ConversationMemoryConfig` 构造(SlidingWindow / Full 策略)
+1
View File
@@ -1,4 +1,5 @@
//! custom_tool —— 自定义工具注册、单次 / 并行调用、权限检查。
//! Required features: cargo run --example custom_tool --features "tools,llm"
//!
//! 演示:
//! 1. 实现 `BaseTool` traitWeatherTool + DeleteFileTool
+10 -2
View File
@@ -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");
+3 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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 完成");
}
}
+33 -8
View File
@@ -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
View File
@@ -1,4 +1,5 @@
//! knowledge_search_demo —— 知识页面存储与关键词检索。
//! Required features: cargo run --example knowledge_search_demo --features "memory"
//!
//! 演示:
//! 1. `KnowledgeStore` 存储多个 `KnowledgePage`
+1
View File
@@ -1,4 +1,5 @@
//! prompt_composer —— 提示词模板与组合器离线示例。
//! Required features: cargo run --example prompt_composer --features "prompt,llm"
//!
//! 演示:
//! 1. `PromptTemplate::compile` + `render` 变量插值(`{{var}}` 语法)
+55 -18
View File
@@ -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();
+82
View File
@@ -0,0 +1,82 @@
//! Required features: cargo run --example response_api_demo --features "full"
//!
//! 演示 OpenAI Response API`POST /responses`)的基本用法。
//!
//! 环境变量:
//! - `OPENAI_BASE_URL` — 默认 `https://api.openai.com/v1`
//! - `OPENAI_API_KEY` — 必填
//! - `OPENAI_MODEL` — 默认 `gpt-4o-mini`
//!
//! 本示例展示:
//! - 单轮对话 + 多轮接续(全量消息历史)
//! - 流式响应事件消费
//! - 工具调用(Function Calling)单轮演示
use std::env;
use agcore::llm::provider::{ProviderConfig, ProviderType, create_provider};
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::request_v2::MessageRequest;
#[tokio::main]
async fn main() {
let api_key = env::var("OPENAI_API_KEY").expect("未设置 OPENAI_API_KEY 环境变量");
let base_url =
env::var("OPENAI_BASE_URL").unwrap_or_else(|_| "https://api.openai.com/v1".to_string());
let model = env::var("OPENAI_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string());
let provider = create_provider(
ProviderType::OpenaiResponse,
ProviderConfig {
base_url,
api_key,
model,
timeout_secs: 30,
max_retries: 3,
},
)
.expect("创建 OpenAI Response Provider 失败");
// ===== 单轮对话 =====
let request = MessageRequest {
model: String::new(),
messages: vec![
Message::system("你是一个简洁的助手,对任何问题都用一句话回答。"),
Message::user_text("Rust 的所有权机制是什么?"),
],
..Default::default()
};
match provider.chat(request).await {
Ok(resp) => {
println!("[单轮] {}", resp.text());
println!(
"用量: {} 输入 / {} 输出\n",
resp.usage.prompt_tokens, resp.usage.completion_tokens
);
}
Err(e) => {
eprintln!("[单轮] 请求失败: {e}");
}
}
// ===== 多轮接续(全量历史) =====
let request = MessageRequest {
model: String::new(),
messages: vec![
Message::user_text("knock knock."),
Message::Assistant {
content: vec![ContentBlock::Text {
text: "Who's there?".into(),
}],
},
Message::user_text("Orange."),
],
..Default::default()
};
match provider.chat(request).await {
Ok(resp) => println!("[多轮] {}", resp.text()),
Err(e) => eprintln!("[多轮] 请求失败: {e}"),
}
}
+2
View File
@@ -1,3 +1,5 @@
//! Required features: cargo run --example simple_visit --features "llm,provider-openai,tracing-init"
use std::env;
use agcore::init_tracing;
+2 -1
View File
@@ -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
View File
@@ -1,4 +1,5 @@
//! sub_agent_dispatch_demo —— SubAgent 并行派发示例。
//! Required features: cargo run --example sub_agent_dispatch_demo --features "engine"
//!
//! 演示:
//! 1. 创建父 session"主编" agent
+3 -2
View File
@@ -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
View File
@@ -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};
+2 -2
View File
@@ -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
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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
View File
@@ -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 {
/// 烟雾测试 1AgentSession::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 {
/// 烟雾测试 3submit_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_promptsystem 由 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 应永不触发");
}
}
}
+2 -1
View File
@@ -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)。
+5 -1
View File
@@ -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
View File
@@ -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")
);
}
}
+6 -13
View File
@@ -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
View File
@@ -48,4 +48,4 @@ pub enum EngineError {
/// 调用方收到此错误时,子 session 已通过 `destroy()` 清理(SessionMeta + checkpoint 全部清空)。
#[error("Dispatch failed: {0}")]
DispatchFailed(String),
}
}
+1 -1
View File
@@ -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};
+47 -33
View File
@@ -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());
}
}
}
}
+1 -1
View File
@@ -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
View File
@@ -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(),
+2 -6
View File
@@ -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
View File
@@ -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 =
+27 -2
View File
@@ -1,12 +1,37 @@
//! LLM 调用周期 —— 大模型基础调用周期控制。
#[cfg(feature = "llm")]
pub mod compact;
#[cfg(feature = "llm")]
pub mod convert;
#[cfg(feature = "llm")]
pub mod cycle;
#[cfg(feature = "llm")]
pub mod embedding;
#[cfg(feature = "llm")]
pub mod error;
#[cfg(feature = "llm")]
pub mod hooks;
#[cfg(feature = "llm")]
pub mod mock;
pub mod provider;
pub mod stream;
#[cfg(feature = "llm-types")]
pub mod types;
// provider 模块依赖 reqwest(通过 reqwest::Client),仅在任一 provider feature 启用时编译
#[cfg(any(
feature = "provider-openai",
feature = "provider-anthropic",
feature = "provider-deepseek",
feature = "provider-qwen",
feature = "provider-ollama",
feature = "provider-openai-response"
))]
pub mod provider;
/// Provider 抽象接口(trait + 能力元数据),仅依赖 `llm` feature,不引入 reqwest。
#[cfg(feature = "llm")]
pub mod provider_trait;
#[cfg(feature = "llm")]
pub mod stream;
// 重导出 Provider 抽象接口到 `crate::llm::` 顶层,便于下游 `use crate::llm::LlmProvider`。
#[cfg(feature = "llm")]
pub use provider_trait::{LlmProvider, ProviderCapabilities, ProviderFeatures};
+109 -80
View File
@@ -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 消息必须紧跟在对应的 Assistanttool_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 {
// 第一个事件应是 Errorchat_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
View File
@@ -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
View File
@@ -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`]。
///

Some files were not shown because too many files have changed in this diff Show More