From c2c0d498eed51685fe1c75d62fe1ac55632bb5a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Fri, 3 Jul 2026 14:13:30 +0800 Subject: [PATCH 01/14] =?UTF-8?q?docs(v0.1):=20=E6=B7=BB=E5=8A=A0=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/11-v0.1-release-plan.md | 340 +++++++++++++++++++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 docs/11-v0.1-release-plan.md diff --git a/docs/11-v0.1-release-plan.md b/docs/11-v0.1-release-plan.md new file mode 100644 index 0000000..9a20679 --- /dev/null +++ b/docs/11-v0.1-release-plan.md @@ -0,0 +1,340 @@ +# v0.1 发布实施计划 + +> 状态:待实施 +> 关联文档:`docs/roadmap.md`、`docs/8-examples-plan.md`、`docs/10c-phase2-llm-cycle-simplify.md` + +## 背景与目标 + +AG Core 已完成 Phase 0-4c 全部 7 个 Phase 的核心功能,以及 Provider IR 重构(新类型系统 + Anthropic/DeepSeek/Qwen Provider)+ LlmCycle 简化(IR 消息类型切换 + 桥接层移除)。 + +**当前基线**: +- `cargo build` — ✅ 通过(5 个 warning) +- `cargo test` — ❌ 编译失败(4 个 error:session.rs/cycle.rs 测试模块中 `ContentBlock`/`ProviderCapabilities`/`ProviderFeatures` 导入缺失,均为 Provider IR 重构后未同步的回归) +- LlmCycle 简化合并前全量测试为 177 通过,合并后尚未跑通过过全量测试 + +v0.1 发布的阻塞项不是"功能不足",而是"已有功能不能被用户快速看见和使用"。本计划聚焦于: + +1. **扫清技术债** — composer.rs 迁移、锁修复、clippy 清零 +2. **提供可离线运行的示例** — 让用户 5 分钟内上手 +3. **补充面向用户的文档** — README、MockProvider、错误消息友好化 +4. **完成发布前准备** — CI 验证、Roadmap 同步、版本标记 + +--- + +## 总体时间线 + +预计 8-10 个工作日(2 周),分为两条并行线: + +``` +Week 1 ────┬── 测试修复 + 技术债扫清 + │ ├ T0 测试编译回归修复(~0.5d)← ⚠️ 任何改动前的前置步骤 + │ ├ T1.1 composer.rs IR 迁移(~1d) + │ ├ T1.2 knowledge.rs 锁修复(~0.5d) + │ ├ T1.3 旧类型废弃标记(~0.5d) + │ └ T1.4 clippy 清零(~0.5d) + │ + ├── 示例 + 开发者体验 + │ ├ T2.0 MockProvider 公开化(~0.5d) + │ ├ T2.1 4个🥇示例(~2-3d,可并行) + │ └ T3.1 README 初稿(~1d,并行) + │ +Week 2 ────┬── 示例继续 + │ ├ T2.2 3个🥈示例(~2d) + │ └ T3.2 错误消息 review(~0.5d) + │ + └── 发布准备 + ├ T3.3 README 定稿(~1d) + ├ T4.1 CI 示例验证(~0.5d) + ├ T4.2 Roadmap 更新(~0.5d) + ├ T4.3 CHANGELOG 初始化(~0.5d) + └ T4.4 v0.1 tag(~0.5d) +``` + +--- + +## 实施步骤 + +### Phase A — 测试修复 + 技术债扫清(Week 1 前半) + +> **重要**:Task A0 是后续所有任务的前提——不修复测试编译,所有 Task 的验收条件(`cargo test` 全通过)都不可执行。 + +#### Task A0:修复测试编译回归(~0.5d) + +**问题**:Provider IR 重构后,`ContentBlock`/`ProviderCapabilities`/`ProviderFeatures` 被移到新位置,但 2 个测试模块的导入未同步,导致 `cargo test` 编译失败。 + +**涉及文件**: +- `src/agent/session.rs` — 测试模块缺少 `ContentBlock` 导入 +- `src/llm/cycle.rs` — 测试模块缺少 `ProviderCapabilities`/`ProviderFeatures` 导入 + +**修复方案**:在相应测试模块内补全 `use` 语句(每处加一行即可)。 + +**额外收益**:修复后 `convert.rs` 中 2 处 `irrefutable if let` 警告也一并修正(改为 `let`),减少 clippy 基数。 + +**前置依赖**:无 + +**验收条件**: +- `cargo test` 全量编译通过 +- `cargo test` 全量运行通过 + +--- + +#### Task A1:`composer.rs` IR 迁移(~1d) + +**前置依赖**:Task A0(否则无法通过 `cargo test` 验证) + +**涉及文件**: +- `src/prompt/composer.rs` — 主要改动 +- `src/prompt/mod.rs` — 类型重导出 + +**改动范围**:约 60 处 `OpenaiChatMessage` → `Message`,`ContentField`/`OpenaiContentPart` → `ContentBlock` + +**具体清单**: +1. `PromptComposer` 内部 `messages` 字段类型 `Vec` → `Vec` +2. 所有 `.system_text()`/`.user_text()`/`.assistant_text()`/`.developer_text()`/`.tool_result()` 构造方法 — 新 `Message` 已有同名方法,直接替换调用 +3. 所有 `xx_content()`/`xx_contents()` 方法 — `ContentBlock` 替代 `ContentField`/`OpenaiContentPart` +4. `set_message_name()` — 新 `Message` 是扁平 enum,无 `name` 字段,移除或忽略 +5. `build()` 返回类型 `Vec` → `Vec` +6. `validate_messages()` 消息校验函数 — `OpenaiChatMessage::Tool { .. }` → `Message::ToolResult { .. }`,`tool_calls` 从 `Assistant` 变体的 `ContentBlock::ToolUse` 中提取 + +**测试**:内联测试中 `OpenaiChatMessage::Tool { .. }` pattern match 需同步更新 + +**验收条件**: +- `cargo build` 无错误 +- `cargo test` 中 prompt 模块测试全通过 +- 无新增 clippy 警告 + +--- + +#### Task A2:`knowledge.rs` 锁修复(~0.5d) + +**涉及文件**:`src/memory/knowledge.rs` + +**问题**:`std::sync::Mutex` guard 在 `search()` 方法中跨 `.await` 持有,可能在高并发下阻塞 tokio 工作线程 + +**修复方案**:`std::sync::Mutex>` → `tokio::sync::Mutex>` + +**影响范围**:5 处 `.lock().unwrap()` 调用(`rebuild_index`、`add_page`、`delete_page`、`search`、`get_index`) + +**验收条件**: +- `cargo build` 无错误 +- `cargo test` 中 memory 模块测试全通过 +- clippy 不再报 `await_holding_lock` 警告 + +--- + +#### Task A3:旧类型公开 API 废弃标记(~0.5d) + +**涉及文件**:`src/llm/types/mod.rs` + +**改动**: +- `ChatResponse` struct 加 `#[deprecated(since = "0.1.0", note = "请改用 MessageResponse")]` +- `ToolDefinition` 类型别名加 `#[deprecated]`(如果仍公开) +- 保留结构体定义(OpenAI `chat_inner()` 内部转换层仍然在用),不删除 + +**验收条件**: +- `cargo build` 产生废弃警告(期望行为)但不产生编译错误 +- 外部调用方能看到有用提示 + +--- + +#### Task A4:clippy 警告清零(~0.5d) + +当前剩余 8 个: + +| # | 警告类型 | 文件 | 修复方式 | +|---|---------|------|---------| +| 1 | irrefutable `if let` | — | 改为直接 `let` | +| 2 | `with_plan_step_index` 未用 | `src/llm/hooks.rs` | 加 `#[allow(dead_code)]` | +| 3-5 | 字段未读(api_key, stop_sequence, next_block_index) | `anthropic.rs`, `response_v2.rs` | 加 `#[allow(dead_code)]` | +| 6 | 手动前缀剥离 | — | 用 `strip_prefix()` | +| 7 | MutexGuard 跨 await | knowledge.rs | Task A2 修复后自动消失 | +| 8 | `if` 相同分支 | retriever.rs | 合并条件 | + +**验收条件**:`cargo clippy --lib -p agcore` 0 警告 + +--- + +### Phase B — 示例实现(Week 1 后半 ~ Week 2 前半) + +#### Task B0:MockProvider 公开化(~0.5d) + +**涉及文件**: +- `src/llm/mock.rs` — 新建,公开 `MockProvider` struct +- `src/llm/mod.rs` — 加 `pub mod mock;` +- `src/agent/session.rs` — 测试中 `MockProvider` 改为引用 `crate::llm::mock::MockProvider` + +**MockProvider 接口**: + +```rust +pub struct MockProvider { .. } +impl MockProvider { + pub fn new(responses: Vec) -> Self; + pub fn chat(&self, request: MessageRequest) -> Result; + pub fn chat_stream(&self, request: MessageRequest) + -> Result> + Send>>, LlmError>; +} +``` + +**决策**:同时实现 `chat_stream`,从 `MessageResponse` 拆解为 `StreamEvent::ContentBlockStart + ContentBlockDelta * N + MessageComplete` 序列,使流式示例也能不依赖 API key。 + +**验收条件**: +- 公开 `MockProvider` 可被外部 crate 引用 +- `cargo test` 中所有 session 测试通过 +- `agent_session_demo.rs` 示例可引用 `MockProvider` 并运行 + +--- + +#### Task B1:🥇 示例 × 4(~2-3d,可并行) + +4 个示例相互无依赖,按技术债消除进度安排: + +| # | 示例 | 代码量 | 前置依赖 | 说明 | +|---|------|--------|---------|------| +| B1a | `agent_session_demo.rs` | ~100 行 | Task B0 | AgentBuilder → AgentSession → submit_turn → SessionMemory 完整链路 | +| B1b | `custom_tool.rs` | ~80 行 | 无 | 实现模拟工具 → ToolRegistry 注册 → invoke/invoke_all → PermissionChecker | +| B1c | `prompt_composer.rs` | ~60 行 | Task A1 | 模板变量插值 → PromptComposer 构建消息链 → 断言验证,纯离线 | +| B1d | `task_agent_demo.rs` | ~70 行 | 无 | 构造 JSON → JsonPlanParser → Plan → Step 状态机 → Hook 事件 | + +每个示例的详细设计见 `docs/8-examples-plan.md`,按该文档直接实现。 + +**验收条件**: +- `cargo run --example prompt_composer` → 成功退出 +- `cargo run --example custom_tool` → 成功退出 +- `cargo run --example agent_session_demo` → 成功退出 +- `cargo run --example task_agent_demo` → 成功退出 + +--- + +#### Task B2:🥈 示例 × 3(~2d) + +| # | 示例 | 代码量 | 前置依赖 | +|---|------|--------|---------| +| B2a | `conversation_memory_demo.rs` | ~70 行 | 无 | +| B2b | `knowledge_search_demo.rs` | ~60 行 | 无 | +| B2c | `streaming_events_demo.rs` | ~80 行 | Task B0(MockProvider 需支持 `chat_stream`) | + +**验收条件**:额外 3 个示例均可 `cargo run` 成功退出 + +--- + +### Phase C — 开发者体验 + 文档(Week 2 后半,与 Phase B 后段并行) + +#### Task C1:README 完整版(~1.5d) + +**涉及文件**:`README.md` + +**内容结构**: + +``` +# AG Core + +## 这是什么? ← 一句话定位 +## 快速上手 ← cargo add + MockProvider 示例 +## 核心模块 ← 7 个模块一句话说明 +## 架构关系图 ← ASCII 或 Mermaid +## 模块依赖关系 ← 依赖关系图 + 说明 +## 环境变量 ← 当前支持的环境变量表 +## 参考项目 ← OpenClaw / Hermes / OpenHuman / OpenHarness +## 许可证 ← MIT / Apache-2.0 +``` + +**要求**:快速上手代码段在提交前必须真实编译通过。 + +--- + +#### Task C2:错误消息友好化 review(~0.5d) + +**涉及文件**: +- `src/agent/error.rs` — AgentError 消息 +- `src/llm/error.rs` — LlmError 消息 +- `src/tools/error.rs` — ToolError 消息 +- `src/memory/error.rs` — MemoryError 消息 +- `src/prompt/error.rs` — PromptError 消息 + +**检查标准**: +- 用户能理解"哪里错了" +- 有可操作的建议("请检查 API key"、"请配置环境变量 LLM_API_KEY") +- 无英文残留的工程师视角消息 + +--- + +### Phase D — 发布准备(Week 2 末) + +#### Task D1:CI 示例验证集成(~0.5d) + +- 确认 `cargo build` 无新增警告 +- 确认 `cargo test` 全部通过(退出码 0) +- 确认 `cargo test --examples` 全部通过 +- 确认 `cargo clippy --lib -p agcore` 0 警告 + +#### Task D2:Roadmap 更新(~0.5d) + +更新 `docs/roadmap.md`: +- Phase 2 状态更新为 ✅ 全部交付物已完成(Provider IR 重构 + LlmCycle 简化) +- 测试计数更新为 D1 确认的最终实际数值 +- 移除 clippy 警告计数 +- 添加 v0.1 发布里程碑 + +#### Task D3:依赖裁剪(~0.5d,非阻塞,可跳过) + +`tokio = { features = ["full"] }` → 按需 feature(`rt`、`macros`、`sync`、`time`、`net`) + +**理由**:减少编译时间 + 减少安全面。时间不够可延后到 v0.2。 + +#### Task D4:CHANGELOG 初始化(~0.5d) + +**涉及文件**:`CHANGELOG.md` + +**内容要求**: +- 版本号:`v0.1.0` +- 发布日期:标记当天 +- 变更摘要:Phase 0-4c(LLM 调用周期、提示词工程、工具系统、记忆系统、Agent 运行时、任务执行、会话级记忆)+ Provider IR 重构(统一类型系统 + Anthropic/DeepSeek/Qwen 适配)+ LlmCycle 简化 +- 格式参考 [Keep a Changelog](https://keepachangelog.com/) 规范 + +**验收条件**: +- `CHANGELOG.md` 文件存在,内容与当前版本一致 +- 文件随 tag commit 一起提交 + +--- + +#### Task D5:版本标记(~0.5d) + +```bash +git tag v0.1.0 && git push --tags +``` + +--- + +## 并行机会 + +| 并行组 | 包含任务 | 说明 | +|--------|---------|------| +| 组 0 | A0 | 单独执行,后续所有 Task 的前提 | +| 组 1 | A1 + A2 + A3 | A0 完成后可并行修 | +| 组 2 | B1a + B1b + B1d | 三个示例无前置依赖(B1a 需等 B0,B1c 需等 A1) | +| 组 3 | C1 + C2 + D2 | README / 错误消息 / Roadmap 更新,纯文档工作 | +| 组 4 | D1 + D3 | CI 验证 + 依赖裁剪,可并行 | + +--- + +## 风险与应对 + +| 风险 | 影响 | 概率 | 应对 | +|------|------|------|------| +| A0 修复后仍有未发现的测试回归 | 🔴 后续 Task 验收不可信 | 低 | `cargo test` 全量通过后才启动 A1 | +| composer.rs 迁移发现未预见的 API 依赖 | 🔴 A1 延期 | 低 | 保持每次 commit 可编译;分 2 次提交(先私有不影响构建,再改返回类型) | +| MockProvider stream 实现比预期复杂 | 🟡 B2c 延期 | 中 | 先简化实现(只支持基本 text delta),复杂 case 留给 v0.2 | +| 示例与库 API 不同步 | 🟡 持续风险 | 中 | 所有示例纳入 `cargo test --examples` 作为 CI gate | +| clippy `#[allow(dead_code)]` 积累过多 | 🟢 低 | 高 | v0.2 发布前专门 review 一次允许列表 | +| tokio feature 裁剪导致编译失败 | 🟡 D3 延期 | 低 | 裁剪前确认现有 feature 覆盖所有 `tokio::` 调用点 | + +--- + +## 验收标准 + +1. **代码质量**:`cargo build` 0 错误,`cargo clippy --lib -p agcore` 0 警告 +2. **测试覆盖**:`cargo test` 全量编译通过且全部运行通过,`cargo test --examples` 全通过 +3. **示例可用**:4 个🥇 + 3 个🥈共 7 个示例均可离线 `cargo run` 成功退出 +4. **文档完整**:README 包含快速上手 + 架构概览,错误消息全部友好化 +5. **版本标记**:`git tag v0.1.0` +6. **Roadmap 同步**:`docs/roadmap.md` 测试计数和状态与代码一致 From c084c57e2ce7a3d83b90f4a60eb6b54c0cb091a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Fri, 3 Jul 2026 15:19:37 +0800 Subject: [PATCH 02/14] =?UTF-8?q?refactor(core):=20=E6=89=AB=E6=B8=85=20v0?= =?UTF-8?q?.1=20Phase=20A=20=E6=8A=80=E6=9C=AF=E5=80=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复测试编译回归:补全 session.rs/cycle.rs 测试模块导入; convert.rs 2 处 irrefutable if let 改为 let - composer.rs 迁移至 IR:OpenaiChatMessage → Message, ContentField/OpenaiContentPart → ContentBlock;删除 set_message_name 和 build_request;developer 消息映射为 Message::System - knowledge.rs 锁修复:std::sync::Mutex → tokio::sync::Mutex; search() 优化锁粒度(锁内仅 clone IDs,避免锁内异步 IO) - 标记 ChatResponse / ToolDefinition 为废弃(#[deprecated(since = "0.1.0")]), 内部使用点加 #[allow(deprecated)] 抑制警告 - clippy 清零:合并冗余 if、手动 strip_prefix 改 strip_prefix、 多处 dead_code 抑制、测试代码清理 --- src/agent/agent.rs | 2 + src/agent/session.rs | 1 + src/agent/task.rs | 2 + src/llm/convert.rs | 31 +++--- src/llm/cycle.rs | 16 +-- src/llm/hooks.rs | 1 + src/llm/provider/anthropic.rs | 3 + src/llm/provider/openai.rs | 4 +- src/llm/types/mod.rs | 4 + src/memory/conversation.rs | 4 +- src/memory/knowledge.rs | 49 +++++---- src/memory/retriever.rs | 4 +- src/prompt/composer.rs | 191 +++++++++++----------------------- src/tools/error.rs | 2 +- src/tools/mcp.rs | 3 + src/tools/registry.rs | 2 + 16 files changed, 138 insertions(+), 181 deletions(-) diff --git a/src/agent/agent.rs b/src/agent/agent.rs index 6eac718..a4fd81f 100644 --- a/src/agent/agent.rs +++ b/src/agent/agent.rs @@ -7,12 +7,14 @@ //! - **不绑定业务循环**:`submit_turn` 在 `AgentSession` 上,不在 trait 上 use crate::agent::runtime::RuntimeBundle; +#[allow(deprecated)] use crate::llm::types::ToolDefinition; /// Agent 角色抽象。 /// /// 实现此 trait 即可接入 Agent Runtime。典型实现是 struct 持有静态配置(name、system prompt 模板), /// 也可以是基于配置动态生成的轻量实现。 +#[allow(deprecated)] pub trait Agent: Send + Sync { /// 角色名(用于日志、调试、UI 展示)。 fn name(&self) -> &str; diff --git a/src/agent/session.rs b/src/agent/session.rs index 7219c96..1aeb889 100644 --- a/src/agent/session.rs +++ b/src/agent/session.rs @@ -178,6 +178,7 @@ mod tests { use crate::llm::error::LlmError; use crate::llm::hooks::{Hook, HookContext, HookExecutor, HookResult}; use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures}; + use crate::llm::types::message::ContentBlock; use crate::llm::types::request_v2::MessageRequest; use crate::llm::types::response_v2::{MessageResponse, StopReason, StreamEvent}; use crate::tools::ToolRegistry; diff --git a/src/agent/task.rs b/src/agent/task.rs index fab032d..3d4bba8 100644 --- a/src/agent/task.rs +++ b/src/agent/task.rs @@ -10,6 +10,7 @@ //! - 重试由上层新建 `Plan` 实现,`TaskAgent` 不做自动重试 use crate::agent::error::AgentError; +#[allow(deprecated)] use crate::llm::types::ChatResponse; use async_trait::async_trait; @@ -55,6 +56,7 @@ impl Step { /// 均未派生 `Clone`(保留原始错误信息,传递所有权而非克隆)。如需复制 `Plan`, /// 只能 clone 处于 `Pending` / `Running` / `Completed` / `Skipped` 状态的步骤。 #[derive(Debug)] +#[allow(deprecated)] pub enum StepStatus { /// 初始状态 —— 等待执行。 Pending, diff --git a/src/llm/convert.rs b/src/llm/convert.rs index adc4955..727f2e6 100644 --- a/src/llm/convert.rs +++ b/src/llm/convert.rs @@ -40,15 +40,14 @@ pub fn from_openai(msg: &OpenaiChatMessage) -> Message { let mut blocks = content_to_blocks(content); if let Some(calls) = tool_calls { for call in calls { - if let OpenaiToolCall::Function { id, function } = call { - let input: Value = - serde_json::from_str(&function.arguments).unwrap_or(Value::Null); - blocks.push(ContentBlock::ToolUse { - id: id.clone(), - name: function.name.clone(), - input, - }); - } + let OpenaiToolCall::Function { id, function } = call; + let input: Value = + serde_json::from_str(&function.arguments).unwrap_or(Value::Null); + blocks.push(ContentBlock::ToolUse { + id: id.clone(), + name: function.name.clone(), + input, + }); } } Message::Assistant { content: blocks } @@ -254,8 +253,7 @@ pub fn blocks_to_content(blocks: &[ContentBlock]) -> ContentField { #[cfg(test)] mod tests { use super::*; - use crate::llm::types::message::ImageSource; - use crate::llm::types::shared::{AudioFormat, ImageDetail}; + use crate::llm::types::shared::ImageDetail; use serde_json::json; #[test] @@ -364,13 +362,10 @@ mod tests { assert!(matches!(content, ContentField::String(_))); let calls = tool_calls.expect("tool_calls"); assert_eq!(calls.len(), 1); - if let OpenaiToolCall::Function { id, function } = &calls[0] { - assert_eq!(id, "call_1"); - assert_eq!(function.name, "search"); - assert!(function.arguments.contains("rust")); - } else { - panic!("expected Function variant"); - } + let OpenaiToolCall::Function { id, function } = &calls[0]; + assert_eq!(id, "call_1"); + assert_eq!(function.name, "search"); + assert!(function.arguments.contains("rust")); } _ => panic!("expected Assistant"), } diff --git a/src/llm/cycle.rs b/src/llm/cycle.rs index 0ca5fe8..81962c2 100644 --- a/src/llm/cycle.rs +++ b/src/llm/cycle.rs @@ -21,6 +21,7 @@ use crate::llm::stream::StreamEvent; use crate::llm::types::message::{ContentBlock, Message}; use crate::llm::types::request_v2::MessageRequest; use crate::llm::types::response_v2::{MessageResponse, StopReason}; +#[allow(deprecated)] use crate::llm::types::{ToolChoice, ToolDefinition}; /// LLM 调用周期配置。 @@ -79,6 +80,7 @@ pub struct LlmCycle { compact_state: CompactState, } +#[allow(deprecated)] impl LlmCycle { /// 创建一个新的 LlmCycle(持有 `Box` 的独占所有权)。 /// @@ -175,13 +177,10 @@ impl LlmCycle { messages: Vec, tools: Vec, ) -> Result { - let ir_messages: Vec = messages; - let ir_tools: Vec = tools.clone(); - let request = MessageRequest { model: self.config.model.clone(), - messages: ir_messages, - tools: ir_tools, + messages, + tools, tool_choice: ToolChoice::Auto, max_tokens: self.config.max_tokens, temperature: self.config.temperature, @@ -672,6 +671,7 @@ fn truncate_tool_result(s: &str, max_bytes: usize) -> String { #[cfg(test)] mod tests { use super::*; + use crate::llm::provider::{ProviderCapabilities, ProviderFeatures}; use crate::tools::{BaseTool, ToolRegistry}; use async_trait::async_trait; use futures_core::Stream; @@ -858,8 +858,10 @@ mod tests { #[tokio::test] async fn test_submit_with_tools_max_turns_exceeded() { - let mut config = CycleConfig::default(); - config.max_tool_turns = Some(2); + let config = CycleConfig { + max_tool_turns: Some(2), + ..Default::default() + }; let responses = vec![ assistant_tool_call_response(vec![("c1", "add", r#"{"a":1,"b":1}"#)]), assistant_tool_call_response(vec![("c2", "add", r#"{"a":1,"b":1}"#)]), diff --git a/src/llm/hooks.rs b/src/llm/hooks.rs index 7485025..4364a5d 100644 --- a/src/llm/hooks.rs +++ b/src/llm/hooks.rs @@ -75,6 +75,7 @@ impl<'a> HookContext<'a> { } /// 设置 plan step 序号(仅 OnPlanStepComplete 使用,Phase 4b 新增)。 + #[allow(dead_code)] pub(crate) fn with_plan_step_index(mut self, plan_step_index: usize) -> Self { self.plan_step_index = Some(plan_step_index); self diff --git a/src/llm/provider/anthropic.rs b/src/llm/provider/anthropic.rs index bfc6955..cf8a3a1 100644 --- a/src/llm/provider/anthropic.rs +++ b/src/llm/provider/anthropic.rs @@ -36,6 +36,7 @@ const DEFAULT_MAX_TOKENS: u32 = 4096; pub struct AnthropicProvider { http_client: Client, base_url: String, + #[allow(dead_code)] api_key: String, model: String, } @@ -586,6 +587,7 @@ enum AnthropicDelta { struct AnthropicMessageDeltaInner { stop_reason: Option, #[serde(default)] + #[allow(dead_code)] stop_sequence: Option, } @@ -599,6 +601,7 @@ pub struct AnthropicSseStream { chunks: Pin> + Send>>, buffer: String, partial: PartialMessageResponse, + #[allow(dead_code)] next_block_index: u32, saw_terminal: bool, } diff --git a/src/llm/provider/openai.rs b/src/llm/provider/openai.rs index 824b9ef..4d6b12a 100644 --- a/src/llm/provider/openai.rs +++ b/src/llm/provider/openai.rs @@ -720,8 +720,8 @@ impl Stream for ChunkToEventStream { } let data = if let Some(p) = trimmed.strip_prefix("data: ") { p - } else if trimmed.starts_with("data:") { - &trimmed[5..] + } else if let Some(p) = trimmed.strip_prefix("data:") { + p } else { continue; }; diff --git a/src/llm/types/mod.rs b/src/llm/types/mod.rs index a33efea..98e4a2a 100644 --- a/src/llm/types/mod.rs +++ b/src/llm/types/mod.rs @@ -40,6 +40,7 @@ pub use usage::{CompletionTokensDetails, CostTracker, PromptTokensDetails, Usage // Phase 1 起移除 `ChatRequest` 别名 —— 新代码统一使用 `MessageRequest`(v2 IR)。 // `ChatResponse` 结构体仍存在,作为 OpenAI `chat_inner()` 内部 wire-format 转换目标。 /// 旧 wire-format 响应结构(保留用于 OpenAI 内部转换层)。 +#[deprecated(since = "0.1.0", note = "请改用 MessageResponse")] #[derive(Debug, Clone)] pub struct ChatResponse { pub message: OpenaiChatMessage, @@ -47,6 +48,7 @@ pub struct ChatResponse { pub stop_reason: Option, } +#[allow(deprecated)] impl From for ChatResponse { fn from(response: OpenaiChatResponse) -> Self { let message = response @@ -63,6 +65,7 @@ impl From for ChatResponse { } } +#[allow(deprecated)] impl From for OpenaiChatChunk { fn from(response: ChatResponse) -> Self { let delta = Delta::from(response.message.clone()); @@ -95,4 +98,5 @@ impl From for OpenaiChatChunk { } /// 工具定义别名(无新类型冲突,保留)。 +#[deprecated(since = "0.1.0", note = "ToolDefinition 仍直接对应 OpenAI wire-format;未来 v0.2 引入 IR 工具类型后会再次更新")] pub type ToolDefinition = OpenaiToolDefinition; diff --git a/src/memory/conversation.rs b/src/memory/conversation.rs index 9405012..4bfea81 100644 --- a/src/memory/conversation.rs +++ b/src/memory/conversation.rs @@ -236,7 +236,7 @@ mod tests { }; let mut conv = ConversationMemory::new(store, "s1", config); for i in 0..5 { - conv.add_message(Message::user_text(&format!("msg-{i}"))) + conv.add_message(Message::user_text(format!("msg-{i}"))) .await .unwrap(); } @@ -253,7 +253,7 @@ mod tests { }; let mut conv = ConversationMemory::new(store, "s1", config); for i in 0..5 { - conv.add_message(Message::user_text(&format!("msg-{i}"))) + conv.add_message(Message::user_text(format!("msg-{i}"))) .await .unwrap(); } diff --git a/src/memory/knowledge.rs b/src/memory/knowledge.rs index 8fca0cd..de9d96c 100644 --- a/src/memory/knowledge.rs +++ b/src/memory/knowledge.rs @@ -19,7 +19,7 @@ pub const KNOWLEDGE_PREFIX: &str = "knowledge_"; /// 同时维护一个 `Vec` 索引以加速列表遍历。 pub struct KnowledgeStore { store: Arc, - index: std::sync::Mutex>, + index: tokio::sync::Mutex>, } impl KnowledgeStore { @@ -27,7 +27,7 @@ impl KnowledgeStore { pub fn new(store: Arc) -> Self { Self { store, - index: std::sync::Mutex::new(Vec::new()), + index: tokio::sync::Mutex::new(Vec::new()), } } @@ -40,7 +40,7 @@ impl KnowledgeStore { ..Default::default() }) .await?; - let mut index = self.index.lock().unwrap(); + let mut index = self.index.lock().await; index.clear(); for item in items { let page: KnowledgePage = serde_json::from_str(&item.content) @@ -66,7 +66,7 @@ impl KnowledgeStore { created_at: now, }; self.store.save(item).await?; - let mut index = self.index.lock().unwrap(); + let mut index = self.index.lock().await; // 替换或追加 if let Some(existing) = index.iter_mut().find(|e| e.id == page.id) { *existing = PageIndexEntry::from(&page); @@ -106,7 +106,7 @@ impl KnowledgeStore { pub async fn delete_page(&self, id: &str) -> Result<(), MemoryError> { let full_id = format!("{KNOWLEDGE_PREFIX}{id}"); self.store.delete(&full_id).await?; - let mut index = self.index.lock().unwrap(); + let mut index = self.index.lock().await; index.retain(|e| e.id != id); Ok(()) } @@ -120,22 +120,31 @@ impl KnowledgeStore { return Ok(Vec::new()); } let needle = query.to_lowercase(); + // 锁内仅 clone 匹配的 entry id,避免异步 get_page() 持有 index 锁。 + let ids: Vec = { + let index = self.index.lock().await; + index + .iter() + .filter(|entry| { + entry.title.to_lowercase().contains(&needle) + || entry.summary.to_lowercase().contains(&needle) + || entry.tags.iter().any(|t| t.to_lowercase().contains(&needle)) + }) + .map(|entry| entry.id.clone()) + .collect() + }; let mut results = Vec::new(); - let index = self.index.lock().unwrap(); - for entry in index.iter() { - if (entry.title.to_lowercase().contains(&needle) - || entry.summary.to_lowercase().contains(&needle) - || entry.tags.iter().any(|t| t.to_lowercase().contains(&needle))) - && let Some(page) = self.get_page(&entry.id).await? { - results.push(page); - } + for id in &ids { + if let Some(page) = self.get_page(id).await? { + results.push(page); + } } Ok(results) } /// 获取内容目录(所有页面的轻量级索引条目)。 - pub fn get_index(&self) -> Vec { - self.index.lock().unwrap().clone() + pub async fn get_index(&self) -> Vec { + self.index.lock().await.clone() } } @@ -222,7 +231,7 @@ mod tests { let ks = KnowledgeStore::new(store); ks.add_page(make_page("p1", "A", &[])).await.unwrap(); ks.add_page(make_page("p2", "B", &[])).await.unwrap(); - let index = ks.get_index(); + let index = ks.get_index().await; assert_eq!(index.len(), 2); } @@ -233,13 +242,13 @@ mod tests { // 添加页面 ks.add_page(make_page("p1", "A", &[])).await.unwrap(); ks.add_page(make_page("p2", "B", &[])).await.unwrap(); - assert_eq!(ks.get_index().len(), 2); + assert_eq!(ks.get_index().await.len(), 2); // 模拟 index 漂移:清空后重建 - ks.index.lock().unwrap().clear(); - assert_eq!(ks.get_index().len(), 0); + ks.index.lock().await.clear(); + assert_eq!(ks.get_index().await.len(), 0); ks.rebuild_index().await.unwrap(); - assert_eq!(ks.get_index().len(), 2); + assert_eq!(ks.get_index().await.len(), 2); } } diff --git a/src/memory/retriever.rs b/src/memory/retriever.rs index 09fa7bf..da3b280 100644 --- a/src/memory/retriever.rs +++ b/src/memory/retriever.rs @@ -113,9 +113,7 @@ fn extract_keywords(query: &str, stop_words: &HashSet) -> Vec { .split(|c: char| !c.is_alphanumeric()) .filter_map(|s| { let lower = s.to_lowercase(); - if lower.is_empty() || lower.chars().count() < 2 { - None - } else if stop_words.contains(&lower) { + if lower.is_empty() || lower.chars().count() < 2 || stop_words.contains(&lower) { None } else { Some(lower) diff --git a/src/prompt/composer.rs b/src/prompt/composer.rs index e953bd6..ccf2092 100644 --- a/src/prompt/composer.rs +++ b/src/prompt/composer.rs @@ -1,12 +1,11 @@ -use crate::llm::types::openai_message::{ContentField, OpenaiChatMessage, OpenaiContentPart}; -use crate::llm::types::request::OpenaiChatRequest; +use crate::llm::types::message::{ContentBlock, Message}; use crate::prompt::error::PromptError; use crate::prompt::template::{PromptTemplate, TemplateContext}; /// 提示词组合器——构建多角色消息序列。 #[derive(Default)] pub struct PromptComposer { - messages: Vec, + messages: Vec, } impl PromptComposer { @@ -16,7 +15,7 @@ impl PromptComposer { } /// 从已有的消息列表初始化。 - pub fn from_messages(messages: Vec) -> Self { + pub fn from_messages(messages: Vec) -> Self { Self { messages } } @@ -24,34 +23,32 @@ impl PromptComposer { /// 添加一条纯文本 system 消息。 pub fn system(mut self, text: impl Into) -> Self { - self.push_message(OpenaiChatMessage::system_text(text.into())); + self.push_message(Message::system(text.into())); self } /// 添加一条纯文本 user 消息。 pub fn user(mut self, text: impl Into) -> Self { - self.push_message(OpenaiChatMessage::user_text(text.into())); + self.push_message(Message::user_text(text.into())); self } /// 添加一条纯文本 assistant 消息。 pub fn assistant(mut self, text: impl Into) -> Self { - self.push_message(OpenaiChatMessage::assistant_text(text.into())); + self.push_message(Message::assistant(text.into())); self } /// 添加一条纯文本 developer 消息(o1 系列模型使用)。 + /// IR 层统一映射为 `Message::System`,由 Provider 在发送时按目标模型决定 `role`。 pub fn developer(mut self, text: impl Into) -> Self { - self.push_message(OpenaiChatMessage::developer_text(text.into())); + self.push_message(Message::system(text.into())); self } /// 添加一条 Tool 消息(工具执行结果回传)。 pub fn tool(mut self, tool_call_id: impl Into, content: impl Into) -> Self { - self.push_message(OpenaiChatMessage::tool_result( - tool_call_id.into(), - content.into(), - )); + self.push_message(Message::tool_result(tool_call_id.into(), content.into(), false)); self } @@ -64,7 +61,7 @@ impl PromptComposer { ctx: &TemplateContext, ) -> Result { let text = template.render(ctx)?; - self.push_message(OpenaiChatMessage::user_text(text)); + self.push_message(Message::user_text(text)); Ok(self) } @@ -75,7 +72,7 @@ impl PromptComposer { ctx: &TemplateContext, ) -> Result { let text = template.render(ctx)?; - self.push_message(OpenaiChatMessage::system_text(text)); + self.push_message(Message::system(text)); Ok(self) } @@ -86,7 +83,7 @@ impl PromptComposer { ctx: &TemplateContext, ) -> Result { let text = template.render(ctx)?; - self.push_message(OpenaiChatMessage::assistant_text(text)); + self.push_message(Message::assistant(text)); Ok(self) } @@ -97,150 +94,98 @@ impl PromptComposer { ctx: &TemplateContext, ) -> Result { let text = template.render(ctx)?; - self.push_message(OpenaiChatMessage::developer_text(text)); + self.push_message(Message::system(text)); Ok(self) } - // ===== 多模态 ContentPart ===== + // ===== 多模态 ContentBlock ===== - /// 添加一条含指定 ContentPart 的 system 消息。 - pub fn system_content(mut self, part: OpenaiContentPart) -> Self { - self.push_message(OpenaiChatMessage::System { - content: ContentField::Array(vec![part]), - name: None, + /// 添加一条含指定 ContentBlock 的 system 消息。 + pub fn system_content(mut self, block: ContentBlock) -> Self { + self.push_message(Message::System { + content: vec![block], }); self } - /// 添加一条含指定 ContentPart 的 user 消息。 - pub fn user_content(mut self, part: OpenaiContentPart) -> Self { - self.push_message(OpenaiChatMessage::User { - content: ContentField::Array(vec![part]), - name: None, + /// 添加一条含指定 ContentBlock 的 user 消息。 + pub fn user_content(mut self, block: ContentBlock) -> Self { + self.push_message(Message::User { + content: vec![block], }); self } - /// 添加一条含指定 ContentPart 的 assistant 消息。 - pub fn assistant_content(mut self, part: OpenaiContentPart) -> Self { - self.push_message(OpenaiChatMessage::Assistant { - content: ContentField::Array(vec![part]), - refusal: None, - name: None, - tool_calls: None, + /// 添加一条含指定 ContentBlock 的 assistant 消息。 + pub fn assistant_content(mut self, block: ContentBlock) -> Self { + self.push_message(Message::Assistant { + content: vec![block], }); self } - /// 添加一条含指定 ContentPart 的 developer 消息。 - pub fn developer_content(mut self, part: OpenaiContentPart) -> Self { - self.push_message(OpenaiChatMessage::Developer { - content: ContentField::Array(vec![part]), - name: None, + /// 添加一条含指定 ContentBlock 的 developer 消息。 + pub fn developer_content(mut self, block: ContentBlock) -> Self { + self.push_message(Message::System { + content: vec![block], }); self } - /// 添加一条含指定 ContentPart 的 Tool 消息。 + /// 添加一条含指定 ContentBlock 的 Tool 消息。 pub fn tool_content( mut self, tool_call_id: impl Into, - part: OpenaiContentPart, + block: ContentBlock, ) -> Self { - self.push_message(OpenaiChatMessage::Tool { - content: ContentField::Array(vec![part]), + self.push_message(Message::ToolResult { tool_call_id: tool_call_id.into(), + content: vec![block], + is_error: false, }); self } - /// 批量添加 ContentPart 作为 user 消息。 - pub fn user_contents(mut self, parts: Vec) -> Self { - self.push_message(OpenaiChatMessage::User { - content: ContentField::Array(parts), - name: None, - }); + /// 批量添加 ContentBlock 作为 user 消息。 + pub fn user_contents(mut self, blocks: Vec) -> Self { + self.push_message(Message::User { content: blocks }); self } - /// 批量添加 ContentPart 作为 system 消息。 - pub fn system_contents(mut self, parts: Vec) -> Self { - self.push_message(OpenaiChatMessage::System { - content: ContentField::Array(parts), - name: None, - }); + /// 批量添加 ContentBlock 作为 system 消息。 + pub fn system_contents(mut self, blocks: Vec) -> Self { + self.push_message(Message::System { content: blocks }); self } - /// 批量添加 ContentPart 作为 assistant 消息。 - pub fn assistant_contents(mut self, parts: Vec) -> Self { - self.push_message(OpenaiChatMessage::Assistant { - content: ContentField::Array(parts), - refusal: None, - name: None, - tool_calls: None, - }); + /// 批量添加 ContentBlock 作为 assistant 消息。 + pub fn assistant_contents(mut self, blocks: Vec) -> Self { + self.push_message(Message::Assistant { content: blocks }); self } - /// 批量添加 ContentPart 作为 developer 消息。 - pub fn developer_contents(mut self, parts: Vec) -> Self { - self.push_message(OpenaiChatMessage::Developer { - content: ContentField::Array(parts), - name: None, - }); - self - } - - // ===== 角色标识 ===== - - /// 为上一条添加的消息设置 `name` 字段。 - pub fn with_name(mut self, name: impl Into) -> Self { - let name = name.into(); - if let Some(msg) = self.messages.last_mut() { - set_message_name(msg, name); - } + /// 批量添加 ContentBlock 作为 developer 消息。 + pub fn developer_contents(mut self, blocks: Vec) -> Self { + self.push_message(Message::System { content: blocks }); self } // ===== 构建 ===== /// 构建最终的消息列表。 - pub fn build(self) -> Vec { + pub fn build(self) -> Vec { self.messages } - /// 构建并直接创建 ChatRequest(需搭配 model 参数)。 - /// 返回的 `OpenaiChatRequest` 中 `tools`、`temperature`、`max_tokens` 等字段均为 `None`, - /// 可通过结构体更新语法补全:`OpenaiChatRequest { tools: Some(...), ..req }`。 - pub fn build_request(self, model: impl Into) -> OpenaiChatRequest { - OpenaiChatRequest { - model: model.into(), - messages: self.messages, - ..Default::default() - } - } - // ===== 内部方法 ===== - fn push_message(&mut self, msg: OpenaiChatMessage) { + fn push_message(&mut self, msg: Message) { self.messages.push(msg); } } -fn set_message_name(msg: &mut OpenaiChatMessage, name: String) { - match msg { - OpenaiChatMessage::Developer { name: n, .. } => *n = Some(name), - OpenaiChatMessage::System { name: n, .. } => *n = Some(name), - OpenaiChatMessage::User { name: n, .. } => *n = Some(name), - OpenaiChatMessage::Assistant { name: n, .. } => *n = Some(name), - OpenaiChatMessage::Tool { .. } => {} - OpenaiChatMessage::Function { .. } => {} - } -} - -/// 验证消息序列是否符合 OpenAI API 要求。 -pub fn validate_messages(messages: &[OpenaiChatMessage]) -> Result<(), PromptError> { +/// 验证消息序列是否符合 LLM API 要求(Tool 消息必须紧跟含 tool_calls 的 Assistant)。 +pub fn validate_messages(messages: &[Message]) -> Result<(), PromptError> { if messages.is_empty() { return Err(PromptError::InvalidSequence( "消息列表不能为空".to_string(), @@ -251,10 +196,7 @@ pub fn validate_messages(messages: &[OpenaiChatMessage]) -> Result<(), PromptErr for (i, msg) in messages.iter().enumerate() { match msg { - OpenaiChatMessage::Tool { - tool_call_id, - .. - } => { + Message::ToolResult { tool_call_id, .. } => { if last_tool_call_ids.is_empty() { return Err(PromptError::InvalidSequence(format!( "消息[{i}] Tool 消息前必须有 Assistant 消息且含 tool_calls" @@ -267,21 +209,14 @@ pub fn validate_messages(messages: &[OpenaiChatMessage]) -> Result<(), PromptErr ))); } } - OpenaiChatMessage::Assistant { - tool_calls: Some(calls), - .. - } => { + Message::Assistant { content } => { last_tool_call_ids.clear(); - for call in calls { - let crate::llm::types::OpenaiToolCall::Function { id, .. } = call; - last_tool_call_ids.push(id.clone()); + for block in content { + if let ContentBlock::ToolUse { id, .. } = block { + last_tool_call_ids.push(id.clone()); + } } } - OpenaiChatMessage::Assistant { - tool_calls: None, .. - } => { - last_tool_call_ids.clear(); - } _ => { last_tool_call_ids.clear(); } @@ -318,18 +253,18 @@ mod tests { assert_eq!(msgs.len(), 4); match &msgs[3] { - OpenaiChatMessage::Tool { + Message::ToolResult { tool_call_id, content, .. } => { assert_eq!(tool_call_id, "call_123"); - match content { - ContentField::String(s) => assert_eq!(s, "Sunny, 25°C"), - _ => {} + match &content[0] { + ContentBlock::Text { text } => assert_eq!(text, "Sunny, 25°C"), + _ => panic!("Expected Text block"), } } - _ => panic!("Expected Tool message"), + _ => panic!("Expected ToolResult message"), } } @@ -345,7 +280,7 @@ mod tests { #[test] fn test_validate_messages_empty() { - let msgs: Vec = vec![]; + let msgs: Vec = vec![]; assert!(validate_messages(&msgs).is_err()); } diff --git a/src/tools/error.rs b/src/tools/error.rs index 0c546c7..579be63 100644 --- a/src/tools/error.rs +++ b/src/tools/error.rs @@ -105,7 +105,7 @@ mod tests { #[test] fn test_io_is_not_recoverable() { - let io_err = std::io::Error::new(std::io::ErrorKind::Other, "disk"); + let io_err = std::io::Error::other("disk"); let err = ToolError::from(io_err); assert!(!err.is_recoverable()); } diff --git a/src/tools/mcp.rs b/src/tools/mcp.rs index cc68151..1a6f423 100644 --- a/src/tools/mcp.rs +++ b/src/tools/mcp.rs @@ -20,6 +20,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, ChildStdin, ChildStdout, Command}; use tokio::sync::{oneshot, Mutex}; +#[allow(deprecated)] use crate::llm::types::ToolDefinition; use crate::tools::base::{BaseTool, ToolContext, ToolRef}; use crate::tools::error::ToolError; @@ -135,6 +136,7 @@ impl std::fmt::Debug for McpClient { } } +#[allow(deprecated)] impl McpClient { /// 创建一个 MCP 客户端。 pub fn new(server_name: impl Into, transport: McpTransport) -> Self { @@ -540,6 +542,7 @@ enum McpClientHandle { } #[async_trait] +#[allow(deprecated)] impl BaseTool for McpToolAdapter { fn name(&self) -> &str { &self.name diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 9cf1627..aa66b2a 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -7,6 +7,7 @@ use std::time::Duration; use futures::future::join_all; use serde_json::Value; +#[allow(deprecated)] use crate::llm::types::ToolDefinition; use crate::tools::base::{ToolContext, ToolRef}; use crate::tools::error::ToolError; @@ -70,6 +71,7 @@ impl std::fmt::Debug for ToolRegistry { } } +#[allow(deprecated)] impl ToolRegistry { /// 创建一个新的工具注册表。 pub fn new() -> Self { From 7b2d2db322c81d025753b12b00ee9d733582fa56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Fri, 3 Jul 2026 15:50:39 +0800 Subject: [PATCH 03/14] =?UTF-8?q?feat(llm):=20=E5=85=AC=E5=BC=80=20MockPro?= =?UTF-8?q?vider=20=E5=B9=B6=E4=BF=AE=E5=A4=8D=E6=B5=8B=E8=AF=95=E4=B8=8E?= =?UTF-8?q?=20API=20=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 src/llm/mock.rs:公开 MockProvider,实现 chat 与 chat_stream (chat_stream 从 MessageResponse 拆解为 StreamEvent 序列),含 5 个内联测试 - src/llm.rs 注册 pub mod mock,供示例与外部 crate 引用 - src/agent/session.rs 测试改用 crate::llm::mock::MockProvider, 删除内嵌私有副本,统一公开路径 - src/prompt.rs re-export validate_messages,与 PromptComposer 对齐公开路径 --- src/agent/session.rs | 46 +------ src/llm.rs | 1 + src/llm/mock.rs | 305 +++++++++++++++++++++++++++++++++++++++++++ src/prompt.rs | 2 +- 4 files changed, 309 insertions(+), 45 deletions(-) create mode 100644 src/llm/mock.rs diff --git a/src/agent/session.rs b/src/agent/session.rs index 1aeb889..475ef34 100644 --- a/src/agent/session.rs +++ b/src/agent/session.rs @@ -175,16 +175,12 @@ impl AgentSession { mod tests { use super::*; use crate::agent::builder::AgentBuilder; - use crate::llm::error::LlmError; use crate::llm::hooks::{Hook, HookContext, HookExecutor, HookResult}; - use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures}; + use crate::llm::mock::MockProvider; use crate::llm::types::message::ContentBlock; - use crate::llm::types::request_v2::MessageRequest; - use crate::llm::types::response_v2::{MessageResponse, StopReason, StreamEvent}; + use crate::llm::types::response_v2::{MessageResponse, StopReason}; use crate::tools::ToolRegistry; use async_trait::async_trait; - use futures_core::Stream; - use std::pin::Pin; use std::sync::atomic::{AtomicU32, Ordering}; /// 计数 hook —— 每被调用一次 +1。 @@ -208,44 +204,6 @@ mod tests { } } - /// MockProvider:按调用顺序返回预设响应。 - struct MockProvider { - responses: std::sync::Mutex>, - } - - impl MockProvider { - fn new(responses: Vec) -> Self { - Self { - responses: std::sync::Mutex::new(responses), - } - } - } - - #[async_trait] - impl LlmProvider for MockProvider { - async fn chat(&self, _request: MessageRequest) -> Result { - let mut responses = self.responses.lock().unwrap(); - if responses.is_empty() { - return Err(LlmError::Other("no more mock responses".into())); - } - Ok(responses.remove(0)) - } - async fn chat_stream( - &self, - _request: MessageRequest, - ) -> Result> + Send>>, LlmError> - { - unimplemented!() - } - fn capabilities(&self) -> ProviderCapabilities { - ProviderCapabilities { - provider_name: "mock", - supported_models: None, - features: ProviderFeatures::default(), - } - } - } - struct StubAgent { name: String, prompt: Option, diff --git a/src/llm.rs b/src/llm.rs index f502eab..218e480 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -5,6 +5,7 @@ pub mod convert; pub mod cycle; pub mod error; pub mod hooks; +pub mod mock; pub mod provider; pub mod stream; pub mod types; diff --git a/src/llm/mock.rs b/src/llm/mock.rs new file mode 100644 index 0000000..5deb4a3 --- /dev/null +++ b/src/llm/mock.rs @@ -0,0 +1,305 @@ +//! 公开的 [`MockProvider`] —— 在示例与测试中按顺序返回预设响应。 +//! +//! 提供与真实 LLM Provider 一致的 `chat` / `chat_stream` 行为, +//! 但不发起任何网络请求,因此适合做离线示例、回归测试和 CI gate。 +//! +//! # 用法 +//! +//! ```no_run +//! use std::sync::Arc; +//! use agcore::llm::mock::MockProvider; +//! use agcore::llm::provider::LlmProvider; +//! use agcore::llm::types::message::{ContentBlock, Message}; +//! use agcore::llm::types::response_v2::{MessageResponse, StopReason}; +//! use agcore::llm::types::Usage; +//! +//! let response = MessageResponse { +//! id: "resp-1".into(), +//! model: "mock".into(), +//! message: Message::Assistant { +//! content: vec![ContentBlock::Text { text: "hi".into() }], +//! }, +//! usage: Usage::from_input_output(2, 1), +//! stop_reason: StopReason::Stop, +//! extra: Default::default(), +//! }; +//! +//! let provider: Arc = Arc::new(MockProvider::new(vec![response])); +//! ``` +//! +//! # 流式行为 +//! +//! `chat_stream` 把当前响应的 `ContentBlock` 序列拆解为标准流事件: +//! +//! - `Text` → `ContentBlockStart(Text)` + `TextDelta` + `ContentBlockEnd` +//! - `Thinking` → `ContentBlockStart(Thinking)` + `ThinkingDelta` + `ContentBlockEnd` +//! - `ToolUse` → `ContentBlockStart(ToolUse)` + `ToolCallArgumentsDelta` + `ToolCallEnd` +//! - 其他变体(Image/Audio/File/Extension)—— 不在流路径中模拟,跳过 + +use std::pin::Pin; +use std::sync::Mutex; + +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}; + +/// 按调用顺序返回预设响应的 [`LlmProvider`]。 +/// +/// 内部用 `Mutex>` 存储队列;`chat` / `chat_stream` 均弹出队首响应。 +/// 当队列耗尽时返回 `LlmError::Other("MockProvider: 预设响应已用完")`。 +pub struct MockProvider { + responses: Mutex>, +} + +impl MockProvider { + /// 用预设响应列表创建。 + pub fn new(responses: Vec) -> Self { + Self { + responses: Mutex::new(responses), + } + } + + /// 创建空队列的 Provider(后续可用 `extend` 注入响应)。 + pub fn empty() -> Self { + Self::new(Vec::new()) + } + + /// 追加更多响应到队列末尾。 + pub fn extend(&self, responses: Vec) { + self.responses.lock().unwrap().extend(responses); + } + + /// 队列中剩余响应数量。 + pub fn remaining(&self) -> usize { + self.responses.lock().unwrap().len() + } + + fn pop(&self) -> Result { + let mut guard = self.responses.lock().unwrap(); + if guard.is_empty() { + return Err(LlmError::Other("MockProvider: 预设响应已用完".into())); + } + Ok(guard.remove(0)) + } +} + +#[async_trait::async_trait] +impl LlmProvider for MockProvider { + async fn chat(&self, _request: MessageRequest) -> Result { + self.pop() + } + + async fn chat_stream( + &self, + _request: MessageRequest, + ) -> Result> + Send>>, LlmError> + { + let response = self.pop()?; + // 提前 clone 出在 stream 闭包中需要的字段;最后 yield 时 move response。 + let id = response.id.clone(); + let model = response.model.clone(); + let usage = response.usage; + let blocks: Vec = match &response.message { + Message::Assistant { content } => content.clone(), + _ => Vec::new(), + }; + + let stream = stream! { + yield Ok(StreamEvent::MessageStart { + id: id.clone(), + model: model.clone(), + }); + + for (i, block) in blocks.iter().enumerate() { + let index = i as u32; + match block { + ContentBlock::Text { text } => { + yield Ok(StreamEvent::ContentBlockStart { + index, + block_type: ContentBlockType::Text, + }); + if !text.is_empty() { + yield Ok(StreamEvent::TextDelta { text: text.clone() }); + } + yield Ok(StreamEvent::ContentBlockEnd { index }); + } + ContentBlock::Thinking { text, .. } => { + yield Ok(StreamEvent::ContentBlockStart { + index, + block_type: ContentBlockType::Thinking, + }); + if !text.is_empty() { + yield Ok(StreamEvent::ThinkingDelta { text: text.clone() }); + } + yield Ok(StreamEvent::ContentBlockEnd { index }); + } + ContentBlock::ToolUse { id: tu_id, name, input } => { + yield Ok(StreamEvent::ContentBlockStart { + index, + block_type: ContentBlockType::ToolUse { + id: tu_id.clone(), + name: name.clone(), + }, + }); + yield Ok(StreamEvent::ToolCallArgumentsDelta { + index, + arguments: serde_json::to_string(input) + .unwrap_or_else(|_| "{}".into()), + }); + yield Ok(StreamEvent::ToolCallEnd { index }); + } + // 其他变体(Image / Audio / File / ToolResult / Extension / Refusal) + // 在 mock 流路径下跳过:示例仅需 text/thinking/tool 三种主流路径。 + _ => {} + } + } + + yield Ok(StreamEvent::CostUpdate { + usage: PartialUsage { + prompt_tokens: Some(usage.prompt_tokens), + completion_tokens: Some(usage.completion_tokens), + total_tokens: Some(usage.total_tokens), + ..Default::default() + }, + }); + + yield Ok(StreamEvent::MessageComplete { full_response: response }); + }; + + Ok(Box::pin(stream)) + } + + fn capabilities(&self) -> ProviderCapabilities { + ProviderCapabilities { + provider_name: "mock", + supported_models: None, + features: ProviderFeatures::default(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::llm::types::{StopReason, Usage}; + use futures_util::StreamExt; + use serde_json::json; + + fn text_response(text: &str) -> MessageResponse { + MessageResponse { + id: "r".into(), + model: "mock".into(), + message: Message::Assistant { + content: vec![ContentBlock::Text { text: text.into() }], + }, + usage: Usage::from_input_output(3, 7), + stop_reason: StopReason::Stop, + extra: Default::default(), + } + } + + #[tokio::test] + async fn chat_returns_queued_response() { + let provider = MockProvider::new(vec![text_response("hello")]); + let resp = provider + .chat(MessageRequest::default()) + .await + .unwrap(); + assert_eq!(resp.text(), "hello"); + assert_eq!(provider.remaining(), 0); + } + + #[tokio::test] + async fn chat_errors_when_empty() { + let provider = MockProvider::empty(); + let err = provider.chat(MessageRequest::default()).await.unwrap_err(); + assert!(matches!(err, LlmError::Other(_))); + } + + #[tokio::test] + async fn extend_appends_responses() { + let provider = MockProvider::new(vec![text_response("a")]); + provider.extend(vec![text_response("b"), text_response("c")]); + assert_eq!(provider.remaining(), 3); + } + + #[tokio::test] + async fn chat_stream_emits_text_delta_sequence() { + let provider = MockProvider::new(vec![text_response("hi")]); + let mut stream = provider.chat_stream(MessageRequest::default()).await.unwrap(); + + let mut seen_start = false; + let mut seen_block_start = false; + let mut seen_delta = false; + let mut seen_block_end = false; + let mut seen_complete = false; + + while let Some(event) = stream.next().await { + match event.unwrap() { + StreamEvent::MessageStart { .. } => seen_start = true, + StreamEvent::ContentBlockStart { + block_type: ContentBlockType::Text, + .. + } => seen_block_start = true, + StreamEvent::TextDelta { text } => { + assert_eq!(text, "hi"); + seen_delta = true; + } + StreamEvent::ContentBlockEnd { .. } => seen_block_end = true, + StreamEvent::MessageComplete { .. } => { + seen_complete = true; + break; + } + _ => {} + } + } + + assert!(seen_start); + assert!(seen_block_start); + assert!(seen_delta); + assert!(seen_block_end); + assert!(seen_complete); + } + + #[tokio::test] + async fn chat_stream_emits_tool_call_arguments() { + let response = MessageResponse { + id: "r".into(), + model: "mock".into(), + message: Message::Assistant { + content: vec![ContentBlock::ToolUse { + id: "call_1".into(), + name: "search".into(), + input: json!({"q": "rust"}), + }], + }, + usage: Usage::from_input_output(2, 5), + stop_reason: StopReason::ToolUse, + extra: Default::default(), + }; + let provider = MockProvider::new(vec![response]); + let mut stream = provider.chat_stream(MessageRequest::default()).await.unwrap(); + + let mut saw_tool_args = false; + let mut saw_tool_end = false; + while let Some(event) = stream.next().await { + match event.unwrap() { + StreamEvent::ToolCallArgumentsDelta { arguments, .. } => { + let v: serde_json::Value = serde_json::from_str(&arguments).unwrap(); + assert_eq!(v, json!({"q": "rust"})); + saw_tool_args = true; + } + StreamEvent::ToolCallEnd { .. } => saw_tool_end = true, + StreamEvent::MessageComplete { .. } => break, + _ => {} + } + } + assert!(saw_tool_args); + assert!(saw_tool_end); + } +} \ No newline at end of file diff --git a/src/prompt.rs b/src/prompt.rs index b6086c1..1b9f277 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -4,4 +4,4 @@ pub mod composer; pub use error::PromptError; pub use template::{PromptTemplate, PromptTemplateRegistry, TemplateContext, TemplateValue}; -pub use composer::PromptComposer; +pub use composer::{validate_messages, PromptComposer}; From 2d0d5c1592ec04c823466cf494a47920cacbaa6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Fri, 3 Jul 2026 15:50:51 +0800 Subject: [PATCH 04/14] =?UTF-8?q?feat(examples):=20=E6=96=B0=E5=A2=9E=20Ph?= =?UTF-8?q?ase=20B=207=20=E4=B8=AA=E7=A6=BB=E7=BA=BF=E5=8F=AF=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E7=A4=BA=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 7 个示例覆盖全 Phase 公共 API(不依赖 API key 即可运行): - agent_session_demo:AgentBuilder → AgentSession → SessionMemory - custom_tool:BaseTool 注册 + invoke/invoke_all + 权限检查 - prompt_composer:PromptTemplate + PromptComposer + validate_messages - task_agent_demo:JsonPlanParser + Step 状态机 + 错误路径 - conversation_memory_demo:滑动窗口 + 多角色 + 隔离 - knowledge_search_demo:KnowledgeStore + 关键词检索 + 停用词过滤 - streaming_events_demo:submit_stream 事件消费 + 队列耗尽错误路径 --- examples/agent_session_demo.rs | 130 +++++++++++++++++++++ examples/conversation_memory_demo.rs | 143 +++++++++++++++++++++++ examples/custom_tool.rs | 169 +++++++++++++++++++++++++++ examples/knowledge_search_demo.rs | 149 +++++++++++++++++++++++ examples/prompt_composer.rs | 108 +++++++++++++++++ examples/streaming_events_demo.rs | 117 +++++++++++++++++++ examples/task_agent_demo.rs | 113 ++++++++++++++++++ 7 files changed, 929 insertions(+) create mode 100644 examples/agent_session_demo.rs create mode 100644 examples/conversation_memory_demo.rs create mode 100644 examples/custom_tool.rs create mode 100644 examples/knowledge_search_demo.rs create mode 100644 examples/prompt_composer.rs create mode 100644 examples/streaming_events_demo.rs create mode 100644 examples/task_agent_demo.rs diff --git a/examples/agent_session_demo.rs b/examples/agent_session_demo.rs new file mode 100644 index 0000000..b321be8 --- /dev/null +++ b/examples/agent_session_demo.rs @@ -0,0 +1,130 @@ +//! agent_session_demo —— Agent 装配 + 会话链路 + SessionMemory 桥接。 +//! +//! 演示: +//! 1. 实现 `Agent` trait(定义角色 + system prompt) +//! 2. 用 `MockProvider` 预设响应(离线可跑) +//! 3. `AgentBuilder` 装配 `RuntimeBundle` +//! 4. `AgentSession::submit_turn` 跑多轮对话 +//! 5. `SessionMemory` 读写 + snapshot 输出 +//! 6. 跨 session 数据隔离验证 +//! +//! 运行:`cargo run --example agent_session_demo` + +use std::sync::Arc; + +use agcore::agent::{Agent, AgentBuilder, AgentSession}; +use agcore::llm::hooks::HookExecutor; +use agcore::llm::mock::MockProvider; +use agcore::llm::types::message::{ContentBlock, Message}; +use agcore::llm::types::response_v2::{MessageResponse, StopReason}; +use agcore::llm::types::Usage; +use agcore::tools::ToolRegistry; + +/// 计算器角色 Agent。 +struct CalculatorAgent; + +impl Agent for CalculatorAgent { + fn name(&self) -> &str { + "calculator" + } + fn system_prompt(&self) -> Option<&str> { + Some("你是一个简洁的计算器助手,每轮回答一句话。") + } +} + +/// 构造预设的纯文本 Assistant 响应。 +fn assistant_text(text: &str) -> MessageResponse { + MessageResponse { + id: String::new(), + model: String::new(), + message: Message::Assistant { + content: vec![ContentBlock::Text { text: text.into() }], + }, + usage: Usage::from_input_output(8, 4), + stop_reason: StopReason::Stop, + extra: Default::default(), + } +} + +#[tokio::main] +async fn main() { + // 1. MockProvider:预设三轮响应(无须 API key 即可离线运行) + let provider = Arc::new(MockProvider::new(vec![ + assistant_text("1 + 1 = 2"), + assistant_text("2 + 2 = 4"), + assistant_text("会话即将结束。"), + ])); + + // 2. AgentBuilder 装配 RuntimeBundle(必填:provider / tool_registry / hook_executor) + let bundle = Arc::new( + AgentBuilder::new() + .provider(provider) + .tool_registry(Arc::new(ToolRegistry::new())) + .hook_executor(Arc::new(HookExecutor::new())) + .build() + .expect("RuntimeBundle 装配失败"), + ); + + // 3. 创建会话 + let agent: Arc = Arc::new(CalculatorAgent); + let mut session = AgentSession::new(agent, "demo-session", bundle.clone()); + assert_eq!(session.turn_index(), 0); + + // 4. 提交第一轮 + println!("=== 提交第 1 轮 ==="); + let resp = session.submit_turn("1+1=?").await.expect("submit_turn 失败"); + println!("LLM: {}", resp.text()); + session + .set_session_data("last_q", "1+1=?") + .await + .expect("set_session_data 失败"); + session + .set_session_data("last_a", resp.text()) + .await + .expect("set_session_data 失败"); + assert_eq!(session.turn_index(), 1); + + // 5. 提交第二轮 + println!("\n=== 提交第 2 轮 ==="); + let resp = session.submit_turn("再加一次 2+2=?").await.unwrap(); + println!("LLM: {}", resp.text()); + assert_eq!(session.turn_index(), 2); + + // 6. 验证 SessionMemory 读取 + println!("\n=== Session Memory 读取 ==="); + println!( + "last_q = {:?}", + session.get_session_data("last_q").await.unwrap() + ); + println!( + "last_a = {:?}", + session.get_session_data("last_a").await.unwrap() + ); + + // 7. Snapshot 格式化输出 + println!("\n=== Session Memory Snapshot ==="); + println!("{}", session.session_memory().snapshot().await.unwrap()); + + // 8. 跨 session 数据隔离验证 + println!("=== 数据隔离验证 ==="); + let other = AgentSession::new( + Arc::new(CalculatorAgent), + "other-session", + bundle, + ); + assert!( + other.get_session_data("last_q").await.unwrap().is_none(), + "新会话不应看到旧 session 的 last_q" + ); + println!("新会话 last_q = None ✓"); + + // 9. 用量累计验证 + println!("\n=== 用量累计 ==="); + let total = session.usage().total(); + println!( + "prompt={}, completion={}, total={}", + total.prompt_tokens, total.completion_tokens, total.total_tokens + ); + + println!("\n✓ agent_session_demo 完成"); +} \ No newline at end of file diff --git a/examples/conversation_memory_demo.rs b/examples/conversation_memory_demo.rs new file mode 100644 index 0000000..d04b844 --- /dev/null +++ b/examples/conversation_memory_demo.rs @@ -0,0 +1,143 @@ +//! conversation_memory_demo —— 对话记忆滑动窗口与隔离。 +//! +//! 演示: +//! 1. `ConversationMemoryConfig` 构造(SlidingWindow / Full 策略) +//! 2. `add_message` 写入多角色消息(Message IR) +//! 3. 滑动窗口自动淘汰旧消息 +//! 4. Full 策略保留全部 +//! 5. `get_history` / `len` / `clear` +//! 6. 跨 session 数据隔离(共用 MemoryStore) +//! +//! 运行:`cargo run --example conversation_memory_demo` + +use std::sync::Arc; + +use agcore::llm::types::message::Message; +use agcore::memory::{ConversationMemory, ConversationMemoryConfig, InMemoryStore, MemoryStrategy}; + +fn message_text(msg: &Message) -> &str { + match msg { + Message::User { content } + | Message::System { content } + | Message::Assistant { content } + | Message::ToolResult { content, .. } => content + .iter() + .filter_map(|b| match b { + agcore::llm::types::message::ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .next() + .unwrap_or(""), + Message::UserImage { .. } => "[image]", + } +} + +#[tokio::main] +async fn main() { + // 1. 滑动窗口策略:写入 5 条但只保留最近 3 条 + println!("=== SlidingWindow 策略(max_turns=3)==="); + let store = Arc::new(InMemoryStore::new()); + let config = ConversationMemoryConfig { + strategy: MemoryStrategy::SlidingWindow, + max_turns: 3, + compact_config: None, + }; + let mut memory = ConversationMemory::new(store, "session-1", config); + + for i in 0..5 { + memory + .add_message(Message::user_text(format!("消息 {i}"))) + .await + .expect("写入失败"); + } + println!("写入 5 条 → len = {} (期望 3)", memory.len()); + let history = memory.get_history(); + for (i, msg) in history.iter().enumerate() { + println!(" [{}] {}", i, message_text(msg)); + } + assert_eq!(memory.len(), 3); + assert_eq!(message_text(&history[0]), "消息 2", "最旧应是消息 2"); + assert_eq!(message_text(&history[2]), "消息 4", "最新应是消息 4"); + + // 2. Full 策略:保留全部 + println!("\n=== Full 策略(max_turns=3)==="); + let store2 = Arc::new(InMemoryStore::new()); + let config2 = ConversationMemoryConfig { + strategy: MemoryStrategy::Full, + max_turns: 3, + compact_config: None, + }; + let mut memory2 = ConversationMemory::new(store2, "session-2", config2); + for i in 0..5 { + memory2 + .add_message(Message::user_text(format!("Full {i}"))) + .await + .unwrap(); + } + println!("写入 5 条 → len = {} (期望 5)", memory2.len()); + assert_eq!(memory2.len(), 5); + + // 3. 多角色混合 + clear + println!("\n=== 多角色写入 + clear ==="); + let store3 = Arc::new(InMemoryStore::new()); + let mut memory3 = ConversationMemory::new( + store3, + "session-3", + ConversationMemoryConfig::default(), + ); + memory3 + .add_message(Message::user_text("你好")) + .await + .unwrap(); + memory3 + .add_message(Message::assistant("你好!有什么可以帮你的吗?")) + .await + .unwrap(); + memory3 + .add_message(Message::user_text("今天天气怎么样?")) + .await + .unwrap(); + memory3 + .add_message(Message::assistant("我无法查询实时天气,但你可以查看天气应用。")) + .await + .unwrap(); + println!( + "写入 4 条多角色消息 → len = {}, 最后一条: {:?}", + memory3.len(), + message_text(memory3.get_history().last().unwrap()) + ); + assert_eq!(memory3.len(), 4); + + memory3.clear().await.unwrap(); + println!( + "clear 后 → len = {}, is_empty = {}", + memory3.len(), + memory3.is_empty() + ); + assert!(memory3.is_empty()); + + // 4. Session 隔离 + println!("\n=== Session 隔离(共用 InMemoryStore)==="); + let store4 = Arc::new(InMemoryStore::new()); + let mut a = ConversationMemory::new( + store4.clone(), + "s-a", + ConversationMemoryConfig::default(), + ); + let mut b = ConversationMemory::new( + store4.clone(), + "s-b", + ConversationMemoryConfig::default(), + ); + a.add_message(Message::user_text("A 的消息")).await.unwrap(); + b.add_message(Message::user_text("B 的消息")).await.unwrap(); + println!( + "A.len = {}, B.len = {} (期望 1/1,互不污染)", + a.len(), + b.len() + ); + assert_eq!(a.len(), 1); + assert_eq!(b.len(), 1); + + println!("\n✓ conversation_memory_demo 完成"); +} \ No newline at end of file diff --git a/examples/custom_tool.rs b/examples/custom_tool.rs new file mode 100644 index 0000000..c3f5e2d --- /dev/null +++ b/examples/custom_tool.rs @@ -0,0 +1,169 @@ +//! custom_tool —— 自定义工具注册、单次 / 并行调用、权限检查。 +//! +//! 演示: +//! 1. 实现 `BaseTool` trait(WeatherTool + DeleteFileTool) +//! 2. 注册到 `ToolRegistry` +//! 3. 单次 `invoke`(含 tool_call_id 关联) +//! 4. 并行 `invoke_all` +//! 5. 调用未注册工具 → `ToolError::NotFound` +//! 6. `PermissionChecker` 黑名单阻断 `DeleteFileTool` +//! +//! 运行:`cargo run --example custom_tool` + +use std::sync::Arc; + +use agcore::tools::{ + BaseTool, Permission, PermissionChecker, PermissionConfig, ToolContext, ToolError, ToolRef, + ToolRegistry, +}; +use async_trait::async_trait; +use serde_json::{json, Value}; + +/// 天气查询工具 —— 模拟根据城市返回天气数据。 +struct WeatherTool; + +#[async_trait] +impl BaseTool for WeatherTool { + fn name(&self) -> &str { + "get_weather" + } + fn description(&self) -> &str { + "查询指定城市的天气" + } + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "city": { "type": "string", "description": "城市名" } + }, + "required": ["city"] + }) + } + fn required_permissions(&self) -> Vec { + vec![Permission::Network] + } + async fn execute( + &self, + args: Value, + _ctx: &ToolContext<'_>, + ) -> Result { + let city = args["city"].as_str().unwrap_or("未知"); + // 模拟查询:根据城市名给出不同温度 + let (temperature, condition) = match city { + "北京" => (22_i32, "晴"), + "上海" => (25, "多云"), + "广州" => (28, "雷阵雨"), + _ => (20, "晴"), + }; + Ok(json!({ + "city": city, + "temperature": temperature, + "condition": condition + })) + } +} + +/// 删除文件工具 —— 用于演示权限黑名单阻断。 +struct DeleteFileTool; + +#[async_trait] +impl BaseTool for DeleteFileTool { + fn name(&self) -> &str { + "delete_file" + } + fn description(&self) -> &str { + "删除指定路径的文件" + } + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { "path": { "type": "string" } }, + "required": ["path"] + }) + } + fn required_permissions(&self) -> Vec { + vec![Permission::Delete] + } + async fn execute( + &self, + _args: Value, + _ctx: &ToolContext<'_>, + ) -> Result { + Ok(json!({"deleted": true})) + } +} + +#[tokio::main] +async fn main() { + // 1. 注册工具 + let mut registry = ToolRegistry::new(); + let weather: ToolRef = Arc::new(WeatherTool); + let deleter: ToolRef = Arc::new(DeleteFileTool); + registry.register(weather).expect("注册 get_weather 失败"); + registry.register(deleter).expect("注册 delete_file 失败"); + println!("=== 已注册工具 ==="); + println!("{:?}", registry.list_tools()); + + // 2. 单次 invoke:tool_call_id 用于回传时与原始调用关联 + println!("\n=== 单次 invoke ==="); + let result = registry + .invoke("call_1", "get_weather", json!({"city": "北京"})) + .await + .expect("invoke 失败"); + println!("tool_call_id: {}", result.tool_call_id); + println!("tool_name: {}", result.tool_name); + println!("output: {}", result.output.unwrap()); + + // 3. 并行 invoke_all:三个并行天气查询 + println!("\n=== 并行 invoke_all(30s 超时)==="); + let calls = vec![ + ("c1".into(), "get_weather".into(), json!({"city": "北京"})), + ("c2".into(), "get_weather".into(), json!({"city": "上海"})), + ("c3".into(), "get_weather".into(), json!({"city": "广州"})), + ]; + let results = registry.invoke_all(calls, 30).await; + assert_eq!(results.len(), 3); + for r in &results { + let output = r.output.as_ref().unwrap(); + println!("[{}] {}", r.tool_call_id, output); + } + + // 4. 调用未注册工具 + println!("\n=== 未注册工具 ==="); + let err = registry + .invoke("c_x", "nope_tool", json!({})) + .await + .unwrap_err(); + println!("错误: {err}"); + + // 5. 权限检查:默认 PermissionConfig 黑名单含 Delete + println!("\n=== 权限检查(默认 PermissionConfig,denied = [Delete, Shell])==="); + let mut registry_with_checker = ToolRegistry::new().with_permission_checker(PermissionChecker::new( + PermissionConfig::default(), + )); + registry_with_checker + .register(Arc::new(WeatherTool) as ToolRef) + .unwrap(); + registry_with_checker + .register(Arc::new(DeleteFileTool) as ToolRef) + .unwrap(); + + // get_weather 声明 Network → 在 allowed 列表 → 通过 + let r = registry_with_checker + .invoke("c1", "get_weather", json!({"city": "北京"})) + .await + .unwrap(); + println!( + "get_weather 权限检查: {}", + if r.output.is_ok() { "通过 ✓" } else { "阻断 ✗" } + ); + + // delete_file 声明 Delete → 在 denied 列表 → 阻断 + let err = registry_with_checker + .invoke("c2", "delete_file", json!({"path": "/tmp/x"})) + .await + .unwrap_err(); + println!("delete_file 权限检查: 阻断 ✗ ({err})"); + + println!("\n✓ custom_tool 完成"); +} \ No newline at end of file diff --git a/examples/knowledge_search_demo.rs b/examples/knowledge_search_demo.rs new file mode 100644 index 0000000..3c702cd --- /dev/null +++ b/examples/knowledge_search_demo.rs @@ -0,0 +1,149 @@ +//! knowledge_search_demo —— 知识页面存储与关键词检索。 +//! +//! 演示: +//! 1. `KnowledgeStore` 存储多个 `KnowledgePage` +//! 2. `MemoryRetriever` 按关键词检索 + TextOverlap (Dice) 评分 +//! 3. 评分 [0.0, 1.0] 范围校验 +//! 4. `RetrieverConfig::min_score` 阈值过滤 +//! 5. `RetrieverConfig::max_results` 截断 +//! 6. 空 query 返回空结果 +//! +//! 运行:`cargo run --example knowledge_search_demo` + +use std::sync::Arc; + +use agcore::memory::{ + InMemoryStore, KnowledgePage, KnowledgeStore, MemoryRetriever, MemoryStore, RetrieverConfig, +}; +use time::OffsetDateTime; + +fn make_page(id: &str, title: &str, content: &str) -> KnowledgePage { + let now = OffsetDateTime::now_utc(); + KnowledgePage { + id: id.to_string(), + title: title.to_string(), + summary: content.chars().take(30).collect(), + content: content.to_string(), + tags: Vec::new(), + references: Vec::new(), + created_at: now, + updated_at: now, + } +} + +#[tokio::main] +async fn main() { + // 1. 创建知识库 + 批量存储页面 + let store: Arc = Arc::new(InMemoryStore::new()); + let ks = KnowledgeStore::new(store); + + let pages = vec![ + make_page("rust-1", "Rust 入门", "Rust 是一门系统级编程语言,注重安全性与并发。"), + make_page("python-1", "Python 简介", "Python 是一门动态类型的高级编程语言。"), + make_page( + "langgraph-1", + "LangGraph 框架", + "LangGraph 是 LangChain 的状态图扩展,用于构建多步 Agent。", + ), + make_page("rust-async", "Rust 异步编程", "Rust 异步基于 tokio 与 futures 抽象。"), + ]; + for p in &pages { + ks.add_page(p.clone()).await.expect("保存页面失败"); + } + println!("=== 已存储 {} 个知识页面 ===", pages.len()); + let index = ks.get_index().await; + for entry in &index { + println!(" - {} ({})", entry.title, entry.id); + } + + // 2. 关键词检索 —— 期望命中 Rust 相关页面 + println!("\n=== 关键词检索:'Rust 异步' ==="); + let retriever = MemoryRetriever::new(ks, RetrieverConfig::default()); + let result = retriever.retrieve("Rust 异步").await.unwrap(); + println!("query: {}", result.query); + for item in &result.items { + println!( + " 命中: {} (score={:.3})", + item.page.title, item.score + ); + assert!( + (0.0..=1.0).contains(&item.score), + "score 应在 [0, 1] 区间" + ); + } + assert!(!result.items.is_empty(), "应至少命中一个页面"); + + // 3. min_score 阈值过滤 + println!("\n=== min_score=0.5 阈值过滤(无关 query)==="); + let store2: Arc = Arc::new(InMemoryStore::new()); + let ks2 = KnowledgeStore::new(store2); + ks2.add_page(make_page("rust-1", "Rust 入门", "Rust 入门内容。")) + .await + .unwrap(); + let cfg = RetrieverConfig { + max_results: 20, + min_score: 0.5, + }; + let retriever2 = MemoryRetriever::new(ks2, cfg); + let result = retriever2 + .retrieve("完全不相关的火锅配方") + .await + .unwrap(); + println!( + "无关 query → items.len = {} (期望 0)", + result.items.len() + ); + assert!(result.items.is_empty()); + + // 4. max_results 截断 + println!("\n=== max_results=2 截断 ==="); + let store3: Arc = Arc::new(InMemoryStore::new()); + let ks3 = KnowledgeStore::new(store3); + for i in 0..5 { + ks3.add_page(make_page( + &format!("rust-{i}"), + "Rust 主题", + &format!("第 {i} 篇关于 Rust 的内容"), + )) + .await + .unwrap(); + } + let cfg = RetrieverConfig { + max_results: 2, + min_score: 0.0, + }; + let retriever3 = MemoryRetriever::new(ks3, cfg); + let result = retriever3.retrieve("Rust").await.unwrap(); + println!( + "5 个相关页面 → 返回 items.len = {} (期望 2)", + result.items.len() + ); + assert_eq!(result.items.len(), 2); + + // 5. 空 query + println!("\n=== 空 query ==="); + let empty = retriever3.retrieve("").await.unwrap(); + println!("空 query → items.len = {}", empty.items.len()); + assert!(empty.items.is_empty()); + + // 6. 停用词过滤:`extract_keywords` 在检索前过滤单字符词与停用词 + println!("\n=== 停用词过滤 ==="); + let mixed = retriever3.retrieve("the Rust is").await.unwrap(); + println!( + "query='the Rust is' → 命中 {} 个 (停用词 'the'/'is' 被过滤,仅 'rust' 进入搜索)", + mixed.items.len() + ); + assert!( + !mixed.items.is_empty(), + "非停用词 'rust' 应命中页面(即使 query 中含停用词)" + ); + + let only_stop = retriever3.retrieve("the is are").await.unwrap(); + println!( + "纯停用词 query='the is are' → 命中 {} 个 (期望 0,所有 token 均被过滤)", + only_stop.items.len() + ); + assert!(only_stop.items.is_empty(), "纯停用词 query 必须返回空结果"); + + println!("\n✓ knowledge_search_demo 完成"); +} \ No newline at end of file diff --git a/examples/prompt_composer.rs b/examples/prompt_composer.rs new file mode 100644 index 0000000..d23e1b8 --- /dev/null +++ b/examples/prompt_composer.rs @@ -0,0 +1,108 @@ +//! prompt_composer —— 提示词模板与组合器离线示例。 +//! +//! 演示: +//! 1. `PromptTemplate::compile` + `render` 变量插值(`{{var}}` 语法) +//! 2. 缺失变量返回 `PromptError` +//! 3. `PromptTemplateRegistry` 注册 + 按名渲染 +//! 4. `PromptComposer` 构造多角色消息序列 +//! 5. `validate_messages` 校验消息序列合法性 +//! +//! 运行:`cargo run --example prompt_composer` + +use agcore::llm::types::message::{ContentBlock, Message}; +use agcore::prompt::{ + validate_messages, PromptComposer, PromptTemplate, PromptTemplateRegistry, TemplateContext, +}; + +fn message_text(msg: &Message) -> String { + match msg { + Message::System { content } + | Message::User { content } + | Message::Assistant { content } + | Message::ToolResult { content, .. } => content + .iter() + .filter_map(|b| match b { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect(), + Message::UserImage { .. } => "[image]".into(), + } +} + +fn main() { + // 1. PromptTemplate::compile + render —— 直接构造模板 + println!("=== PromptTemplate::compile + render ==="); + let tpl = PromptTemplate::compile( + "今日 {{location}} 天气:{{condition}},温度 {{temperature}}", + ) + .expect("编译失败"); + let mut ctx = TemplateContext::new(); + ctx.insert("location", "北京"); + ctx.insert("condition", "晴"); + ctx.insert("temperature", "25°C"); + let rendered = tpl.render(&ctx).expect("渲染失败"); + println!("渲染结果: {rendered}"); + + // 2. 缺失变量 → PromptError + println!("\n=== 缺失变量 ==="); + match tpl.render(&TemplateContext::new()) { + Ok(_) => println!("意外成功"), + Err(e) => println!("按预期报错: {e}"), + } + + // 3. PromptTemplateRegistry —— 按名注册 + 渲染(支持 #if 条件) + println!("\n=== PromptTemplateRegistry ==="); + let mut registry = PromptTemplateRegistry::new(); + registry + .register("weather", "今日 {{location}}:{{condition}}") + .expect("注册失败"); + registry + .register("greet", "你好 {{name}}!{{#if formal}} 见到您很荣幸。{{/if}}") + .expect("注册失败"); + + let mut ctx = TemplateContext::new(); + ctx.insert("name", "Alice"); + println!( + "greet (formal=false): {}", + registry.render("greet", &ctx).unwrap() + ); + ctx.insert("formal", true); + println!( + "greet (formal=true): {}", + registry.render("greet", &ctx).unwrap() + ); + + // 4. PromptComposer —— 构造多角色消息序列 + println!("\n=== PromptComposer ==="); + let messages = PromptComposer::new() + .system("你是一个天气助手") + .user("今天天气怎么样?") + .assistant("请告诉我城市名。") + .user(rendered) + .build(); + println!("消息数: {}", messages.len()); + for (i, m) in messages.iter().enumerate() { + let role = match m { + Message::System { .. } => "system", + Message::User { .. } | Message::UserImage { .. } => "user", + Message::Assistant { .. } => "assistant", + Message::ToolResult { .. } => "tool", + }; + println!("[{i}] {role}: {}", message_text(m)); + } + + // 5. validate_messages —— 消息序列合法性校验 + println!("\n=== validate_messages ==="); + match validate_messages(&messages) { + Ok(()) => println!("消息序列合法 ✓"), + Err(e) => println!("消息序列非法: {e}"), + } + let empty: Vec = Vec::new(); + match validate_messages(&empty) { + Ok(()) => println!("空消息合法"), + Err(e) => println!("空消息按预期报错: {e}"), + } + + println!("\n✓ prompt_composer 完成"); +} \ No newline at end of file diff --git a/examples/streaming_events_demo.rs b/examples/streaming_events_demo.rs new file mode 100644 index 0000000..b6bc5f0 --- /dev/null +++ b/examples/streaming_events_demo.rs @@ -0,0 +1,117 @@ +//! streaming_events_demo —— LLM 流式响应事件流消费(含错误路径)。 +//! +//! 演示: +//! 1. `MockProvider::chat_stream` 输出标准 `StreamEvent` 流(离线可跑) +//! 2. `LlmCycle::submit_stream` 消费流 +//! 3. match 各类 `StreamEvent`:MessageStart / ContentBlockStart / TextDelta / +//! ContentBlockEnd / CostUpdate / MessageComplete +//! 4. 实时累计文本与解析事件计数 +//! 5. 从 `MessageComplete` 拿到完整 `MessageResponse` +//! 6. **错误路径**:队列耗尽时 `chat_stream` 返回 `Err`,`submit_stream` 同样 +//! 返回 `Err`(错误不进流,直接 fail-fast) +//! +//! 运行:`cargo run --example streaming_events_demo` + +use std::sync::Arc; + +use agcore::llm::cycle::{CycleConfig, LlmCycle}; +use agcore::llm::mock::MockProvider; +use agcore::llm::provider::LlmProvider; +use agcore::llm::types::message::{ContentBlock, Message}; +use agcore::llm::types::response_v2::{MessageResponse, StopReason, StreamEvent}; +use agcore::llm::types::Usage; +use futures_util::StreamExt; + +/// 构造预设的纯文本响应。 +fn text_response(id: &str, text: &str) -> MessageResponse { + MessageResponse { + id: id.to_string(), + model: "mock".into(), + message: Message::Assistant { + content: vec![ContentBlock::Text { text: text.into() }], + }, + usage: Usage::from_input_output(3, text.chars().count() as u32), + stop_reason: StopReason::Stop, + extra: Default::default(), + } +} + +/// 消费一个流直到终止事件,并打印事件轨迹。 +async fn drain_stream(stream: &mut (impl futures_util::Stream + Unpin)) { + while let Some(event) = stream.next().await { + match event { + StreamEvent::MessageStart { id, model } => { + println!("[MessageStart] id={id}, model={model}"); + } + StreamEvent::ContentBlockStart { index, .. } => { + println!("[ContentBlockStart] index={index}"); + } + StreamEvent::TextDelta { text } => { + print!("[TextDelta] {text}"); + use std::io::Write; + std::io::stdout().flush().ok(); + } + StreamEvent::ContentBlockEnd { index } => { + println!("\n[ContentBlockEnd] index={index}"); + } + StreamEvent::CostUpdate { usage } => { + let p = usage.prompt_tokens.unwrap_or(0); + let c = usage.completion_tokens.unwrap_or(0); + println!("[CostUpdate] prompt={p}, completion={c}"); + } + StreamEvent::MessageComplete { full_response } => { + println!( + "[MessageComplete] stop_reason={:?}", + full_response.stop_reason + ); + break; + } + other => { + // 未匹配的事件变体(ThinkingDelta / RefusalDelta / ToolCall* 等) + // 在 MockProvider 当前实现下不可达;保留打印以便未来 Provider 扩展时易调试。 + eprintln!("[unhandled event] {other:?}"); + } + } + } +} + +#[tokio::main] +async fn main() { + // 1. 准备 Mock provider(仅含一轮文本响应,第二次调用会触发错误) + let provider = Arc::new(MockProvider::new(vec![text_response( + "resp-001", + "今天天气晴朗,适合户外活动。", + )])); + let dyn_provider: Arc = provider.clone(); + + // ===== 阶段 1:正常流式响应 ===== + println!("=== 阶段 1:正常流式响应 ==="); + let mut cycle = LlmCycle::new_with_arc(dyn_provider.clone(), CycleConfig::default()); + let mut stream = cycle + .submit_stream("讲个笑话".to_string(), vec![]) + .await + .expect("阶段 1 submit_stream 应成功"); + drain_stream(&mut stream).await; + + // ===== 阶段 2:错误路径(队列耗尽)===== + // 队列中已无响应 → `chat_stream` 返回 `Err(LlmError::Other)` → + // `submit_stream` 用 `?` 立即传播(错误不进流,fail-fast)。 + // 上层 Agent 通过 `match` 或 `?` 处理 `AgentError::Llm(_)`。 + println!("\n=== 阶段 2:错误路径(队列耗尽)==="); + let mut cycle = LlmCycle::new_with_arc(dyn_provider, CycleConfig::default()); + let result = cycle + .submit_stream("第二次提问".to_string(), vec![]) + .await; + match result { + Ok(_) => panic!("阶段 2 必须失败(队列耗尽)"), + Err(e) => { + eprintln!("[expected error] {e}"); + assert!( + e.to_string().contains("预设响应已用完"), + "应包含 MockProvider 的队列耗尽提示" + ); + } + } + + println!("\n✓ streaming_events_demo 完成"); +} \ No newline at end of file diff --git a/examples/task_agent_demo.rs b/examples/task_agent_demo.rs new file mode 100644 index 0000000..6846c90 --- /dev/null +++ b/examples/task_agent_demo.rs @@ -0,0 +1,113 @@ +//! task_agent_demo —— Plan 解析、Step 状态机、错误路径。 +//! +//! 演示: +//! 1. `JsonPlanParser::parse` 解析合法 JSON 输入 +//! 2. `Plan` / `Step` 数据结构遍历 +//! 3. `StepStatus` 状态机:Pending → Running → Completed / Failed / Skipped +//! 4. `is_pending()` / `is_terminal()` 语义 +//! 5. 错误路径:非法 JSON / 空 steps / 缺字段 → `AgentError::PlanParse` +//! +//! 运行:`cargo run --example task_agent_demo` +//! +//! ## 已知技术债(v0.2 迁移指南) +//! +//! 本示例使用 `#[deprecated]` 标记的旧 wire-format 类型: +//! - `ChatResponse`、`OpenaiChatMessage`、`FinishReason` —— `OpenaiChatProvider::chat_inner()` +//! 内部转换层仍在使用(参见 `docs/10a-phase0-types-and-trait.md` §2.5.1), +//! 故结构体定义保留。 +//! - `StepStatus::Completed(ChatResponse)` —— 因为 `Step` 的"已完成"变体需携带 +//! provider 响应,目前沿用旧的 `ChatResponse`。 +//! +//! **触发迁移的条件**:v0.2 引入 IR 层的 `StepResult` / 切换为 `MessageResponse`。 +//! **迁移路径**:将本文件 `ChatResponse`/`OpenaiChatMessage`/`FinishReason` 替换为 +//! `MessageResponse`/`Message`/`StopReason`,移除顶部 `#![allow(deprecated)]`。 +//! 上层应用代码(`TaskAgent` 消费者)也可同步迁移。 + +#![allow(deprecated)] + +use agcore::agent::{AgentError, JsonPlanParser, PlanParser, Step, StepStatus}; +use agcore::llm::types::openai_message::OpenaiChatMessage; +use agcore::llm::types::shared::FinishReason; +use agcore::llm::types::{ChatResponse, Usage}; + +#[tokio::main] +async fn main() { + // 1. JsonPlanParser 解析合法 JSON + println!("=== JsonPlanParser 解析合法 JSON ==="); + let parser = JsonPlanParser; + let input = r#"{ + "steps": [ + {"description": "查询北京天气"}, + {"description": "根据天气计算穿衣建议"}, + {"description": "生成最终回复"} + ] + }"#; + let plan = parser + .parse(input, "为用户生成今日出行建议") + .await + .expect("合法 JSON 应解析成功"); + println!("Plan goal: {}", plan.goal); + println!("Plan id: {}", plan.id); + println!("Steps: {}", plan.steps.len()); + for (i, step) in plan.steps.iter().enumerate() { + println!( + " [{}] {} (pending={}, terminal={})", + i, + step.description, + step.status.is_pending(), + step.status.is_terminal() + ); + } + assert!(plan.steps.iter().all(|s| s.status.is_pending())); + + // 2. Step 状态机:Pending → Running → Completed + println!("\n=== Step 状态机:Pending → Running → Completed ==="); + let mut step = plan.steps.into_iter().next().expect("应有 step"); + println!("初始: pending={}", step.status.is_pending()); + assert!(step.status.is_pending()); + + step.status = StepStatus::Running; + println!("Running: pending={}, terminal={}", step.status.is_pending(), step.status.is_terminal()); + + step.status = StepStatus::Completed(ChatResponse { + message: OpenaiChatMessage::assistant_text("天气:晴,22°C"), + usage: Usage::from_input_output(5, 10), + stop_reason: Some(FinishReason::Stop), + }); + println!("Completed: pending={}, terminal={}", step.status.is_pending(), step.status.is_terminal()); + assert!(step.status.is_terminal()); + + // 3. 失败路径 + println!("\n=== Step 状态机:失败路径 ==="); + let mut fail_step = Step::new(0, "调用天气 API"); + fail_step.status = StepStatus::Failed(AgentError::Other("API 不可用".into())); + println!("Failed: pending={}, terminal={}", fail_step.status.is_pending(), fail_step.status.is_terminal()); + assert!(fail_step.status.is_terminal()); + + // 4. 跳过路径 + let mut skipped = Step::new(0, "可选步骤"); + skipped.status = StepStatus::Skipped; + println!("Skipped: terminal={}", skipped.status.is_terminal()); + assert!(skipped.status.is_terminal()); + + // 5. 错误路径 1:非法 JSON + println!("\n=== 错误路径:非法 JSON ==="); + let err = parser.parse("not json", "goal").await.unwrap_err(); + println!("错误: {err}"); + assert!(matches!(err, AgentError::PlanParse(_))); + + // 6. 错误路径 2:空 steps + println!("\n=== 错误路径:空 steps ==="); + let err = parser.parse(r#"{"steps": []}"#, "goal").await.unwrap_err(); + println!("错误: {err}"); + assert!(matches!(err, AgentError::PlanParse(_))); + + // 7. 错误路径 3:缺 description 字段 + println!("\n=== 错误路径:缺 description 字段 ==="); + let bad_input = r#"{"steps": [{"description": "ok"}, {"no_desc": true}]}"#; + let err = parser.parse(bad_input, "goal").await.unwrap_err(); + println!("错误: {err}"); + assert!(matches!(err, AgentError::PlanParse(_))); + + println!("\n✓ task_agent_demo 完成"); +} \ No newline at end of file From 9f5e8702a2122e2ff01232f94335e02be8b73774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Sat, 4 Jul 2026 06:21:24 +0800 Subject: [PATCH 05/14] =?UTF-8?q?chore(license):=20=E6=B7=BB=E5=8A=A0=20Ap?= =?UTF-8?q?ache-2.0=20=E6=A0=87=E5=87=86=20LICENSE=20=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 LICENSE(Apache License 2.0 全文,Copyright 2026 AG Core Contributors) - 项目由双协议改为 Apache-2.0 单协议 --- LICENSE | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..29bb042 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 AG Core Contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file From 4fdc62754c321c8b5c260d797e35dc920b526b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Sat, 4 Jul 2026 06:21:31 +0800 Subject: [PATCH 06/14] =?UTF-8?q?feat(errors):=20=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E9=9D=A2=E5=90=91=E6=9C=80=E7=BB=88=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=8F=8B=E5=A5=BD=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - memory::MemoryError 英文 → 中文 + 操作建议(namespace 拼写 / 后端存储 / 数据兼容性) - llm::LlmError 各变体补充修复建议(API key / 超时 / 上下文压缩) - tools::ToolError 各变体补充修复指引(注册工具 / 检查 Schema / 调权限 / MCP 握手) - prompt::PromptError 各变体补充修复指引(注册子模板 / 检查语法 / 调深度限制) - agent::AgentError::Config 中文化 + 提示需调用的 builder 方法名 --- src/agent/builder.rs | 9 +++++---- src/agent/error.rs | 2 +- src/llm/error.rs | 28 +++++++++++++++------------- src/memory/error.rs | 19 +++++++++++++------ src/prompt/error.rs | 14 +++++++------- src/tools/error.rs | 24 ++++++++++++------------ 6 files changed, 53 insertions(+), 43 deletions(-) diff --git a/src/agent/builder.rs b/src/agent/builder.rs index c139926..ee19329 100644 --- a/src/agent/builder.rs +++ b/src/agent/builder.rs @@ -89,17 +89,18 @@ impl AgentBuilder { /// 构造 `RuntimeBundle`,校验必填字段。 /// /// **错误**:`provider` / `tool_registry` / `hook_executor` 任一缺失则返回 - /// `AgentError::Config("missing ")`,不 panic。 + /// `AgentError::Config(...)`,提示调用 `.provider(...)` / `.tool_registry(...)` / + /// `.hook_executor(...)` 补齐。不 panic。 pub fn build(self) -> Result { let provider = self .provider - .ok_or_else(|| AgentError::Config("missing provider".into()))?; + .ok_or_else(|| AgentError::Config("缺少 LLM provider,请先调用 .provider(...)".into()))?; let tool_registry = self .tool_registry - .ok_or_else(|| AgentError::Config("missing tool_registry".into()))?; + .ok_or_else(|| AgentError::Config("缺少 tool_registry,请先调用 .tool_registry(...)(即使是空 ToolRegistry 也需要传入)".into()))?; let hook_executor = self .hook_executor - .ok_or_else(|| AgentError::Config("missing hook_executor".into()))?; + .ok_or_else(|| AgentError::Config("缺少 hook_executor,请先调用 .hook_executor(...)(空 HookExecutor 也可)".into()))?; let config = self.config.unwrap_or_default(); diff --git a/src/agent/error.rs b/src/agent/error.rs index 2eb03cb..899a892 100644 --- a/src/agent/error.rs +++ b/src/agent/error.rs @@ -129,7 +129,7 @@ mod tests { #[test] fn config_not_recoverable() { - assert!(!AgentError::Config("missing provider".into()).is_recoverable()); + assert!(!AgentError::Config("缺少 provider".into()).is_recoverable()); } #[test] diff --git a/src/llm/error.rs b/src/llm/error.rs index fe17b30..a8ffea7 100644 --- a/src/llm/error.rs +++ b/src/llm/error.rs @@ -5,33 +5,35 @@ use std::time::Duration; /// 错误按可重试性分为两类: /// - **可重试**:`RateLimit`、`Timeout`、状态码 >= 500 /// - **不可重试**:`Authentication`、`ContextLength`、状态码 4xx(除 429) +/// +/// 错误消息面向最终用户(中文),并尽量附带可操作的修复建议(如检查 API key、减少上下文)。 #[derive(thiserror::Error, Debug)] pub enum LlmError { - /// API 认证失败(如 API key 无效)。 - #[error("认证失败: {0}")] + /// API 认证失败(API key 无效、过期或权限不足)。 + #[error("LLM 认证失败: {0}。请检查环境变量中的 API key(如 OPENAI_API_KEY / ANTHROPIC_API_KEY)是否正确")] Authentication(String), - /// 请求被限流,可选地附带重试等待时间。 - #[error("限流(retry_after={retry_after:?})")] + /// 请求被限流,可选地附带重试等待时间。可重试。 + #[error("LLM 限流(服务方),建议等待 {retry_after:?} 后重试")] RateLimit { retry_after: Option }, - /// HTTP 请求失败,包含状态码和响应体。 - #[error("请求失败(status={status}): {body}")] + /// HTTP 请求失败(网络错误或非 2xx 状态码),包含状态码与响应体。 + #[error("LLM 请求失败(HTTP {status}): {body}")] Request { status: u16, body: String }, - /// 请求超时。 - #[error("请求超时(duration={duration:?})")] + /// 请求超时。可重试。 + #[error("LLM 请求超时({duration:?})。请检查网络连接,或调大 LlmCycle 超时配置")] Timeout { duration: Duration }, - /// 流式响应处理错误(预留)。 - #[error("流式响应错误: {0}")] + /// 流式响应处理错误(SSE 解析失败、流中断等)。可重试。 + #[error("LLM 流式响应错误: {0}。可重试或改用非流式接口")] Stream(String), - /// 上下文长度超限。 - #[error("上下文超限(actual={actual}, limit={limit})")] + /// 上下文长度超出模型窗口限制。 + #[error("LLM 上下文超限:当前 {actual} tokens > 模型上限 {limit} tokens。请减少消息历史、缩短 prompt,或启用 auto-compaction(llm::compact)")] ContextLength { actual: u32, limit: u32 }, /// 其他未分类的 LLM 调用失败。 #[error("LLM 调用失败: {0}")] Other(String), -} +} \ No newline at end of file diff --git a/src/memory/error.rs b/src/memory/error.rs index c7ac6d4..4471362 100644 --- a/src/memory/error.rs +++ b/src/memory/error.rs @@ -3,21 +3,28 @@ use thiserror::Error; /// 记忆系统错误枚举。 +/// +/// 错误消息面向最终用户(中文),并尽量附带可操作的修复建议(如检查环境变量、重试)。 #[derive(Debug, Error)] pub enum MemoryError { - #[error("Item not found: {0}")] + /// 按 ID 未找到指定记忆条目。可重试——通常是 namespace 拼写错误或条目已被淘汰。 + #[error("未找到记忆条目 '{0}',请检查 ID 或 namespace 是否正确")] NotFound(String), - #[error("Storage error: {0}")] + /// 底层存储失败(磁盘满、连接断开等)。一般不可恢复,建议上层记录并告警。 + #[error("存储失败: {0},请检查后端存储是否可用(磁盘 / 网络 / 权限)")] Storage(String), - #[error("Serialization error: {0}")] + /// 序列化 / 反序列化失败(数据结构变更、JSON 字段缺失等)。一般不可恢复。 + #[error("序列化失败: {0},请检查数据结构兼容性或升级记忆格式")] Serialization(String), - #[error("Invalid input: {0}")] + /// 调用方传入的参数不合法(空 ID、负数容量等)。可重试——修正参数即可。 + #[error("参数无效: {0}")] InvalidInput(String), - #[error("Retrieval error: {0}")] + /// 检索过程出错(索引重建失败、评分异常等)。可重试。 + #[error("检索失败: {0},可重试或调整 query")] RetrievalError(String), } @@ -26,4 +33,4 @@ impl MemoryError { pub fn is_recoverable(&self) -> bool { matches!(self, Self::NotFound(_) | Self::RetrievalError(_)) } -} +} \ No newline at end of file diff --git a/src/prompt/error.rs b/src/prompt/error.rs index 3afa1cb..74054a0 100644 --- a/src/prompt/error.rs +++ b/src/prompt/error.rs @@ -2,27 +2,27 @@ use thiserror::Error; #[derive(Error, Debug)] pub enum PromptError { - #[error("模板解析错误: {0}")] + #[error("模板解析错误: {0}。请检查模板语法({{var}} / {{#if}} / {{#each}})")] Parse(String), - #[error("渲染错误: 变量 '{0}' 未找到")] + #[error("渲染错误: 变量 '{0}' 未找到。请在 TemplateContext 中插入该变量")] VariableNotFound(String), - #[error("渲染错误: 引用的子模板 '{0}' 未注册")] + #[error("渲染错误: 引用的子模板 '{0}' 未注册。请先用 PromptTemplateRegistry::register 注册该子模板")] PartialNotFound(String), - #[error("渲染错误: '{0}' 不是数组,无法遍历")] + #[error("渲染错误: '{0}' 不是数组,无法遍历。请确认传入的是数组或先判空")] NotAnArray(String), - #[error("渲染递归超过最大深度限制 ({0})")] + #[error("渲染递归超过最大深度限制 ({0})。请检查是否有循环 include,或调大 MAX_DEPTH")] MaxDepthReached(u8), #[error("渲染错误: {0}")] Render(String), - #[error("消息序列校验失败: {0}")] + #[error("消息序列校验失败: {0}。请检查消息角色顺序(例如 tool 必须在 assistant tool_call 之后)")] InvalidSequence(String), - #[error("文件读取错误: {0}")] + #[error("文件读取错误: {0}。请检查模板文件路径与权限")] Io(#[from] std::io::Error), } diff --git a/src/tools/error.rs b/src/tools/error.rs index 579be63..f246be2 100644 --- a/src/tools/error.rs +++ b/src/tools/error.rs @@ -5,44 +5,44 @@ use std::sync::Arc; /// 工具调用过程中可能发生的所有错误。 #[derive(thiserror::Error, Debug, Clone)] pub enum ToolError { - /// 工具未注册。 - #[error("工具 '{0}' 未注册")] + /// 工具未注册。不可恢复——需调用方先 `registry.register(...)`。 + #[error("工具 '{0}' 未注册。请先用 ToolRegistry::register(...) 注册该工具,或检查 LLM 输出的工具名拼写")] NotFound(String), - /// 工具执行失败(可恢复——文本回传 LLM)。 - #[error("工具 '{0}' 执行失败: {1}")] + /// 工具执行失败(可恢复——文本回传 LLM 由其决定重试或放弃)。 + #[error("工具 '{0}' 执行失败: {1}。可让 LLM 调整参数后重试,或切换到备选工具")] ExecutionFailed(String, String), /// 工具参数无效(可恢复——文本回传 LLM)。 - #[error("工具 '{0}' 参数无效: {1}")] + #[error("工具 '{0}' 参数无效: {1}。请检查 LLM 输出的参数是否符合 BaseTool::parameters() 声明的 JSON Schema")] InvalidArguments(String, String), /// 权限被拒绝(不可恢复——终止循环)。 - #[error("权限被拒绝: 工具 '{0}' 需要 {1} 权限")] + #[error("权限被拒绝: 工具 '{0}' 需要 {1} 权限。请在 PermissionConfig 中显式允许,或人工确认后绕过")] PermissionDenied(String, String), /// MCP 协议错误(不可恢复)。 - #[error("MCP 协议错误: {0}")] + #[error("MCP 协议错误: {0}。请检查 MCP server 版本与本库兼容,或查看 server 日志")] McpError(String), /// MCP 未初始化(不可恢复)。 - #[error("MCP 未初始化: {0}")] + #[error("MCP 未初始化: {0}。请先调用 McpClient::initialize() 完成握手")] McpNotInitialized(String), /// MCP 超时(不可恢复)。 - #[error("MCP 超时: {0}")] + #[error("MCP 超时: {0}。请检查 MCP server 是否存活,或调大超时配置")] McpTimeout(String), /// IO 错误(不可恢复)。 - #[error("IO 错误: {0}")] + #[error("IO 错误: {0}。请检查文件路径、权限或磁盘空间")] Io(Arc), - /// 取消。 + /// 工具执行被取消。 #[error("工具执行已取消: {0}")] Cancelled(String), /// 其他未分类错误。 - #[error("其他错误: {0}")] + #[error("工具调用错误: {0}")] Other(String), } From a52588cd0fb43d00ef13ec9f66b883c90dabc8f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Sat, 4 Jul 2026 06:21:36 +0800 Subject: [PATCH 07/14] =?UTF-8?q?docs:=20=E5=AE=8C=E5=96=84=20README=20?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 8 节结构:定位 / 快速上手 / 核心模块 / 架构图 / 依赖关系 / 环境变量 / 参考项目 / 许可证 - 快速上手代码段(MockProvider)已验证在外部 crate 离线可编译运行 - 核心模块表覆盖五大模块(llm / prompt / tools / memory / agent) - ASCII 架构关系图 + Mermaid 模块依赖关系图 + 依赖规则说明 - 环境变量表列出 simple_visit 示例所需变量与推荐值 - 参考项目章节引用 4 个公开 Agent Harness 项目(OpenClaw / Hermes / OpenHuman / OpenHarness) - 许可证章节同步改为 Apache-2.0 单协议,附标准 boilerplate 并指向 LICENSE 文件 --- README.md | 245 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 244 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 57480f8..3ea8c2f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,246 @@ # AG Core -AG Core是一个用于提供构建智能体的底层工具箱,其中提供的功能主要包括基础的大模型调用的完整周期,提示词的组合与优化,记忆检索与管理、MCP与工具的调用等。 +> AG Core 是一个用于构建 AI 智能体(Agent)的 Rust 工具箱,提供 LLM 调用、提示词工程、工具系统、记忆检索、Agent 运行时等核心能力,所有模块通过 trait 抽象、可插拔、可离线测试。 + +## 这是什么? + +AG Core 不是 Agent 产品,而是 Agent 的**底层依赖库**:上层应用(TUI / Bot 网关 / 业务服务)基于 AG Core 装配出符合自身场景的 Agent。设计原则: + +- **模块化** —— 五大功能领域(LLM / Prompt / Tool / Memory / Agent)独立 crate-internal 模块,通过 trait 解耦 +- **可插拔** —— LLM Provider、记忆存储、工具注册全部通过 trait 抽象,可替换为自有实现 +- **可离线** —— 公开 `MockProvider`,所有示例与测试无需 API key 即可运行 +- **异步优先** —— 所有 IO API 均为 `async`,基于 tokio 运行时 + +适合用来: + +- 搭建支持多轮对话 + 工具调用的智能体服务 +- 接入多家 LLM(OpenAI / Anthropic / DeepSeek / Qwen 等) +- 在 Agent 中组合提示词模板、知识检索、MCP 工具 +- 在 Rust 中复用一套"业务无关"的 Agent 基础设施 + +## 快速上手 + +5 分钟上手,无需 API key(使用 `MockProvider` 预设响应)。 + +**1. 添加依赖** + +```toml +[dependencies] +agcore = "0.1" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +``` + +**2. 第一个 Agent** + +```rust,no_run +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::types::message::{ContentBlock, Message}; +use agcore::llm::types::response_v2::{MessageResponse, StopReason}; +use agcore::llm::types::Usage; +use agcore::tools::ToolRegistry; + +struct Greeter; + +impl Agent for Greeter { + fn name(&self) -> &str { "greeter" } + fn system_prompt(&self) -> Option<&str> { Some("你是一个中文助手,回答简短。") } +} + +fn text_response(text: &str) -> MessageResponse { + MessageResponse { + id: "r".into(), + model: "mock".into(), + message: Message::Assistant { + content: vec![ContentBlock::Text { text: text.into() }], + }, + usage: Usage::from_input_output(2, text.chars().count() as u32), + stop_reason: StopReason::Stop, + extra: Default::default(), + } +} + +#[tokio::main] +async fn main() { + // 1. MockProvider:预设响应,无须 API key + let provider: Arc = + Arc::new(MockProvider::new(vec![text_response("你好,我是 agcore。")])); + + // 2. 装配 RuntimeBundle(必填:provider / tool_registry / hook_executor) + let bundle = Arc::new( + AgentBuilder::new() + .provider(provider) + .tool_registry(Arc::new(ToolRegistry::new())) + .hook_executor(Arc::new(HookExecutor::new())) + .build() + .expect("装配 RuntimeBundle 失败"), + ); + + // 3. 创建会话并提交首轮 + let agent: Arc = Arc::new(Greeter); + let mut session = AgentSession::new(agent, "demo", bundle); + let resp = session.submit_turn("你好").await.expect("submit_turn 失败"); + println!("LLM: {}", resp.text()); +} +``` + +跑起来: + +```bash +cargo run +# LLM: 你好,我是 agcore。 +``` + +接真实 Provider(如 OpenAI)只需把 `MockProvider` 换成: + +```rust,no_run +use agcore::llm::provider::{create_provider, ProviderConfig, ProviderType}; + +let provider = create_provider( + ProviderType::OpenaiChat, + ProviderConfig { + base_url: "https://api.openai.com/v1".into(), + api_key: std::env::var("OPENAI_API_KEY").expect("未设置 OPENAI_API_KEY"), + model: "gpt-4o-mini".into(), + }, +).expect("创建 Provider 失败"); +``` + +更多端到端示例见 [`examples/`](./examples/) 目录(共 7 个,全部可 `cargo run --example `): + +| 示例 | 说明 | +|------|------| +| `agent_session_demo` | Agent + 会话 + SessionMemory 完整链路(MockProvider 离线) | +| `custom_tool` | 自定义工具注册、单次 / 并行调用、权限检查 | +| `prompt_composer` | 提示词模板与组合器(纯离线) | +| `task_agent_demo` | Plan 解析、Step 状态机、错误路径 | +| `conversation_memory_demo` | 对话记忆滑动窗口与隔离 | +| `knowledge_search_demo` | 知识页面关键词检索 | +| `streaming_events_demo` | LLM 流式响应事件消费(含错误路径) | + +## 核心模块 + +| 模块 | 一句话说明 | +|------|----------| +| `agcore::llm` | LLM 调用周期(`LlmProvider` trait + `LlmCycle` 重试/用量 + 流式事件 + auto-compaction + Hook + 公开 `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` 任务编排) | + +## 架构关系图 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 应用层 (TUI / Bot 网关 / 业务服务) │ +└───────────────────────────┬─────────────────────────────────┘ + │ 使用 +┌───────────────────────────▼─────────────────────────────────┐ +│ Agent Runtime (agcore::agent) │ +│ Agent / AgentBuilder / RuntimeBundle / AgentSession / │ +│ SessionMemory / Plan / Step │ +└─────┬───────────────┬───────────────┬───────────────┬───────┘ + │ │ │ │ +┌─────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ +│ LLM │ │ Prompt │ │ Tool │ │ Memory │ +│ agcore:: │ │ agcore:: │ │ agcore:: │ │ agcore:: │ +│ llm │ │ prompt │ │ tools │ │ memory │ +└─────┬─────┘ └─────────────┘ └─────┬───────┘ └──────┬──────┘ + │ │ │ + └──────────────┬────────────────┘ │ + ▼ │ + ┌─────────────────┐ │ + │ Mock Provider │◄──────────────────────┘ + │ 公开 API │ 离线测试 / 示例 + └─────────────────┘ +``` + +## 模块依赖关系 + +```mermaid +graph BT + LLM["llm
Provider / Cycle /
Hooks / Stream /
Compact / Mock"]:::core + Prompt["prompt
Template / Composer"]:::core + Tool["tools
BaseTool / Registry /
Permission / MCP"]:::core + Memory["memory
Store / Conversation /
Knowledge / Retriever"]:::core + Agent["agent
Agent / Builder /
Session / Plan"]:::core + + Prompt --> LLM + Tool --> LLM + Memory --> LLM + Agent --> LLM + Agent --> Prompt + Agent --> Tool + Agent --> Memory + + classDef core fill:#60a5fa,stroke:#2563eb,color:#fff +``` + +**依赖规则**: + +- `llm` 是叶子,被其他四个模块使用 +- `prompt` / `tools` / `memory` 互相不依赖,可独立使用 +- `agent` 是聚合层,把前四者"装配"为可用 Agent Runtime +- 上层应用只应依赖 `agent` + 必要的子模块,不应跨层直接 `use` + +## 环境变量 + +> AG Core 库本身不读环境变量。下表是 [`examples/simple_visit.rs`](./examples/simple_visit.rs) 这一真实调用示例所使用的环境变量,以及通常推荐的取值。 + +| 变量 | 必填 | 推荐值 | 说明 | +|------|------|-------|------| +| `OPENAI_API_KEY` | 用 OpenAI 时 | — | OpenAI / 兼容 Provider(DeepSeek / Qwen)的 API key | +| `OPENAI_BASE_URL` | 是 | `https://api.openai.com/v1` | OpenAI 兼容端点 base URL(DeepSeek/Qwen 用对应地址) | +| `OPENAI_MODEL` | 是 | `gpt-4o-mini` | OpenAI 模型名 | +| `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` | +| `RUST_LOG` | 否 | `agcore=info` | tracing 日志级别(其他 crate 可加 `=debug`) | + +示例(运行 `examples/simple_visit.rs` 真实调用 OpenAI): + +```bash +export OPENAI_API_KEY=sk-... +export OPENAI_BASE_URL=https://api.openai.com/v1 +export OPENAI_MODEL=gpt-4o-mini +export PROVIDER=openai +export RUST_LOG=agcore=debug +cargo run --example simple_visit +``` + +## 参考项目 + +AG Core 在 Phase 4 设计阶段调研了 4 个 2026 年公开的 AI Agent 项目(详见 [`docs/note-agent-harness-references.md`](./docs/note-agent-harness-references.md)): + +| 项目 | 类型 | 语言 | 借鉴点 | +|------|------|------|-------| +| [OpenClaw](https://github.com/openclaw/openclaw) | 消息网关 | TypeScript | 多渠道适配模式 | +| [Hermes Agent](https://github.com/NousResearch/hermes-agent) | 自主学习智能体 | Python | 实体与会话解耦 | +| [OpenHuman](https://github.com/tinyhumansai/openhuman) | 桌面助手 | Rust + Tauri | 记忆树与 Token 压缩 | +| [OpenHarness](https://github.com/HKUDS/OpenHarness) | Agent Harness 框架 | Python | 显式依赖注入容器 + 三级权限 | + +AG Core 不"抄代码",只参考架构模式。当前实现已经采纳: + +- **OpenHarness 风格** —— 显式 `RuntimeBundle` 依赖注入容器 +- **Hermes 风格** —— `Agent` trait(角色)与 `AgentSession`(会话)解耦 + +后续 v0.2+ 计划借鉴 OpenHuman 的 Memory Tree / TokenJuice 模式。 + +## 许可证 + +本项目基于 **Apache License 2.0** 授权。完整文本见 [`LICENSE`](./LICENSE)。 + +``` +Copyright 2026 AG Core Contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +``` \ No newline at end of file From 602bd43fce13b87db3c42224057d87a60bcb1f11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Sat, 4 Jul 2026 06:35:21 +0800 Subject: [PATCH 08/14] =?UTF-8?q?fix(llm,docs):=20=E4=BF=AE=E5=A4=8D=20Pha?= =?UTF-8?q?se=20C=20=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=84=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LlmError::Request 变体补可操作建议:请检查 Provider 端点地址(base_url)和网络连通性 - README.md Mermaid 依赖图删除 Agent --> Prompt 边(编译级不存在) - README.md 依赖规则文本同步:明确 agent 实际编译依赖为 llm/tools/memory,与 prompt 无直接依赖(system prompt 以 &str 传入) 参考审查:PM Director + SA Director 对 Phase C 的联合审查结论 --- README.md | 3 +-- src/llm/error.rs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3ea8c2f..f6ab58b 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,6 @@ graph BT Tool --> LLM Memory --> LLM Agent --> LLM - Agent --> Prompt Agent --> Tool Agent --> Memory @@ -184,7 +183,7 @@ graph BT - `llm` 是叶子,被其他四个模块使用 - `prompt` / `tools` / `memory` 互相不依赖,可独立使用 -- `agent` 是聚合层,把前四者"装配"为可用 Agent Runtime +- `agent` 编译期依赖 `llm` / `tools` / `memory`,与 `prompt` 无直接编译依赖(system prompt 以 `&str` 形式传入) - 上层应用只应依赖 `agent` + 必要的子模块,不应跨层直接 `use` ## 环境变量 diff --git a/src/llm/error.rs b/src/llm/error.rs index a8ffea7..cc6b40c 100644 --- a/src/llm/error.rs +++ b/src/llm/error.rs @@ -18,7 +18,7 @@ pub enum LlmError { RateLimit { retry_after: Option }, /// HTTP 请求失败(网络错误或非 2xx 状态码),包含状态码与响应体。 - #[error("LLM 请求失败(HTTP {status}): {body}")] + #[error("LLM 请求失败(HTTP {status}): {body}。请检查 Provider 端点地址(base_url)和网络连通性")] Request { status: u16, body: String }, /// 请求超时。可重试。 From 8dcd1f2482ad3ad21b4953b0bea4598a8e012e8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Sat, 4 Jul 2026 06:54:13 +0800 Subject: [PATCH 09/14] =?UTF-8?q?docs(v0.1):=20=E5=90=8C=E6=AD=A5=20roadma?= =?UTF-8?q?p=20=E5=B9=B6=E5=88=9D=E5=A7=8B=E5=8C=96=20CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/roadmap.md:更新顶部状态至 v0.1 发布完成(182 测试 / 0 警告 / 7 离线示例),末尾追加 v0.1 发布里程碑章节 - CHANGELOG.md:按 Keep a Changelog 规范记录 0.1.0 的 Added / Changed / Deprecated / Fixed / Removed 及版本基线 --- CHANGELOG.md | 91 +++++++++++++++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 28 +++++++++++++-- 2 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..aeeed9a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,91 @@ +# Changelog + +本项目所有重要变更均记录于此文件。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 + +## [0.1.0] - 2026-07-04 + +首个公开版本。涵盖 Phase 0-4c 的全部核心能力、Provider IR 重构、LlmCycle 简化,以及面向用户的 7 个离线示例。 + +### Added + +**LLM 调用周期(Phase 0)** +- 统一 IR 类型层:`Message`、`ContentBlock`、`MessageRequest`、`MessageResponse`、`ToolDefinition`、`StopReason` +- `LlmProvider` trait + 4 个 Provider 实现:OpenAI Chat Completions、Anthropic Messages、DeepSeek(OpenAI 兼容)、Qwen(OpenAI 兼容) +- `ProviderRegistry`:多 Provider 注册与发现 +- `LlmCycle`:重试策略 + 用量追踪 + 自动 tool 循环 + Auto-compaction +- `HookExecutor`:OnTurnStart / OnTurnEnd / OnPlanStepComplete 等生命周期钩子 +- `StreamEvents`:AssistantTextDelta / ToolExecutionStarted / MessageComplete 等流式事件 + +**提示词工程(Phase 1)** +- `PromptTemplate`:变量插值 + 条件渲染 +- `PromptComposer`:system/user/assistant/tool_result 消息链组合 + +**工具系统(Phase 2)** +- `ToolRegistry`:注册、发现、并行调用、超时控制 +- `BaseTool` trait(含 `ToolContext` 执行上下文) +- `McpClient`:stdio transport(StreamableHttp 已预留接口) +- `PermissionChecker`:白名单 / 黑名单 / 自定义权限 + +**记忆系统(Phase 3)** +- `MemoryStore` trait + `InMemoryStore` 默认实现 +- `ConversationMemory`:sliding window / 全量模式,集成 `llm::compact` +- `KnowledgeStore`:知识页面存储 +- `MemoryRetriever`:TextOverlap Dice 系数评分 +- `EvictionPolicy`:None / Ttl / Capacity 三种淘汰策略 + +**Agent 运行时(Phase 4a/b/c)** +- `Agent` trait:name / system_prompt / tool_definitions +- `AgentSession` + `RuntimeBundle` + `AgentBuilder`:链式构造 + 依赖注入 +- `AgentError`:Llm / Tool / Memory / HookBlocked / LimitExceeded / Config / PlanParse / Other 8 个变体 +- `TaskAgent` trait + `JsonPlanParser`:自主执行 + 外部驱动 +- `Plan` / `Step` / `StepStatus`:纯数据结构 +- `SessionMemory`:基于 `MemoryStore` 的会话级 key-value 桥接 + +**面向用户的资产** +- `agcore::llm::mock::MockProvider`:公开的 Mock Provider,支持 `chat` + `chat_stream`,无需 API key 即可运行示例 +- 7 个离线可运行示例(`cargo run --example ...`): + - `prompt_composer` — 模板变量插值 + 消息链构建 + - `custom_tool` — 工具注册 + 权限检查 + - `agent_session_demo` — AgentBuilder → AgentSession → submit_turn 完整链路 + - `task_agent_demo` — JsonPlanParser → Plan → Step 状态机 + - `conversation_memory_demo` — SessionMemory 命名空间隔离 + - `knowledge_search_demo` — KnowledgeStore + 停用词过滤 + - `streaming_events_demo` — 流式事件消费 + +### Changed + +- **`LlmCycle` 切换到 IR 消息类型**:内部消息从 `OpenaiChatMessage` 切到统一 `Message` 类型,移除 Phase 0 的桥接转换层(方案:`docs/10c-phase2-llm-cycle-simplify.md`) +- **Provider IR 重构**:`LlmProvider` trait 签名同步切到 `MessageRequest` / `MessageResponse`;新增 Anthropic / DeepSeek / Qwen Provider(方案:`docs/10-llm-provider-refinement.md`、`docs/10b-phase1-provider-adaptation.md`) +- **`AgentBuilder` 扩展 `session_memory_backend`**:Phase 4c 接入会话级记忆 +- **错误消息面向最终用户友好化**:`AgentError` / `LlmError` / `ToolError` / `MemoryError` / `PromptError` 全部改写为带可操作建议的友好消息 +- **`composer.rs` IR 迁移**:`PromptComposer` 内部从 `OpenaiChatMessage` 切到 `Message` / `ContentBlock`,与新类型系统保持一致 +- **`knowledge.rs` 锁修复**:`std::sync::Mutex` → `tokio::sync::Mutex`,避免 MutexGuard 跨 `.await` 持有 + +### Deprecated + +- `ChatResponse` — 自 0.1.0 起标记为 deprecated,请改用 `MessageResponse` +- `ToolDefinition` — 自 0.1.0 起标记为 deprecated(仍直接对应 OpenAI wire-format);v0.2 将引入 IR 工具类型 + +### Fixed + +- 修复 Provider IR 重构后 `session.rs` / `cycle.rs` 测试模块的导入缺失回归 +- 修复 `knowledge.rs::search()` 中 MutexGuard 跨 `.await` 持有的潜在阻塞 +- 清零全部 clippy 警告(`#[allow(dead_code)]` 已在最低必要范围使用) + +### Removed + +- 移除 Phase 0 的 `OpenaiChatMessage ↔ Message` 桥接层(LlmCycle 简化后已无外部调用方) + +--- + +## 版本基线 + +| 指标 | 数值 | +|------|------| +| `cargo build --all-targets` | ✅ 通过 | +| `cargo test --all-targets` | ✅ 182 passed / 0 failed | +| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 | +| 离线示例 | ✅ 7 个全部 `cargo run` 退出码 0 | +| 许可证 | Apache-2.0 | + +[0.1.0]: https://github.com/anomalyco/agcore/releases/tag/v0.1.0 \ No newline at end of file diff --git a/docs/roadmap.md b/docs/roadmap.md index 6a38530..187004e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,13 +1,13 @@ # AG Core Roadmap > 定稿日期:2026-05-11 -> 最后更新:2026-06-11(Phase 4c 编码实施完成) +> 最后更新:2026-07-04(v0.1 发布完成) ## 愿景 AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。 -**当前状态**:Phase 0 基础设施已全部完成,Phase 1 提示词工程已全部完成,Phase 2 工具系统已全部完成,Phase 3 记忆系统已全部完成,Phase 4a 核心胶水层已全部完成,Phase 4b 任务执行已全部完成,Phase 4c 会话级记忆已全部完成(116 个测试通过,0 警告)。 +**当前状态**:Phase 0-4c 全部完成;Provider IR 重构(统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen Provider)已完成;LlmCycle 简化(IR 消息类型切换 + 桥接层移除)已完成;v0.1 发布就绪(**182 个测试通过、0 clippy 警告、7 个离线示例可运行**)。 --- @@ -338,3 +338,27 @@ graph BT - ✅ Phase 4a Core Glue — 全部交付物已完成 - ✅ Phase 4b Task Execution — 全部交付物已完成 - ✅ Phase 4c Session Memory — 全部交付物已完成 +- ✅ Provider IR 重构 — 统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen 适配(方案:`docs/10-llm-provider-refinement.md`、`docs/10a-phase0-types-and-trait.md`、`docs/10b-phase1-provider-adaptation.md`) +- ✅ LlmCycle 简化 — IR 消息类型切换 + Phase 0 桥接层移除(方案:`docs/10c-phase2-llm-cycle-simplify.md`) +- ✅ v0.1 Release — 技术债扫清、MockProvider 公开化、7 个离线示例、README + 错误消息友好化、Roadmap 同步、CHANGELOG 初始化(计划:`docs/11-v0.1-release-plan.md`) + +--- + +## v0.1 发布里程碑(2026-07-04) + +**质量基线**: + +| 指标 | 数值 | +|------|------| +| `cargo build --all-targets` | ✅ 通过 | +| `cargo test --all-targets` | ✅ **182 passed / 0 failed** | +| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 | +| 离线示例(`cargo run --example`) | ✅ 7 个全部 exit 0 | + +**关键交付**: +1. **Provider IR 重构** — 统一 `Message` / `ContentBlock` / `MessageRequest` / `MessageResponse` 类型层;4 个 Provider 适配(OpenAI Chat / Anthropic Messages / DeepSeek / Qwen);`LlmProvider` trait 签名同步切换 +2. **LlmCycle 简化** — `LlmCycle` 内部消息类型切到 IR 层;移除 Phase 0 的 `OpenaiChatMessage ↔ Message` 桥接;测试从 116 → 182(含 provider 测试) +3. **`MockProvider` 公开化** — `agcore::llm::mock::MockProvider` 支持 `chat` + `chat_stream`,无需 API key 即可运行示例 +4. **7 个离线示例** — `prompt_composer` / `custom_tool` / `agent_session_demo` / `task_agent_demo` / `conversation_memory_demo` / `knowledge_search_demo` / `streaming_events_demo` +5. **错误消息友好化** — `AgentError` / `LlmError` / `ToolError` / `MemoryError` / `PromptError` 全部面向最终用户改写(给出可操作的建议) +6. **文档完整** — README 完整版(快速上手 + 架构图 + 环境变量)、Apache-2.0 LICENSE From 3c1a3ee62e2c118fdfe3eb71a257417c5594df1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Sat, 4 Jul 2026 07:07:01 +0800 Subject: [PATCH 10/14] =?UTF-8?q?docs:=20=E4=BF=AE=E6=AD=A3=20v0.1=20relea?= =?UTF-8?q?se=20URL=20=E5=88=B0=E5=AE=9E=E9=99=85=20remote?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG.md:91 的对比链接从 github.com/anomalyco/agcore 改为实际 remote git.archgrid.xyz/xt/agcore(Gitea 1.26.1), 避免 tag 发布后 CHANGELOG 链接 404 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aeeed9a..f820c95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,4 +88,4 @@ | 离线示例 | ✅ 7 个全部 `cargo run` 退出码 0 | | 许可证 | Apache-2.0 | -[0.1.0]: https://github.com/anomalyco/agcore/releases/tag/v0.1.0 \ No newline at end of file +[0.1.0]: https://git.archgrid.xyz/xt/agcore/releases/tag/v0.1.0 \ No newline at end of file From fba78f5f3362342b2e777609900f5a2d8d632d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Sat, 4 Jul 2026 08:11:09 +0800 Subject: [PATCH 11/14] =?UTF-8?q?docs(roadmap):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E8=B7=AF=E7=BA=BF=E5=9B=BE=E4=B8=BA=20v0.2=20=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E5=B0=B1=E7=BB=AA=E8=A7=84=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 v0.1.0 发布状态,将 v0.2+ 扩展项重 组为 12 项基础功能和 ContextSlot 上下文管 理,明确 v0.3+ 展望及边界范围 --- docs/roadmap.md | 165 ++++++++++++++++++++++++++++++------------------ 1 file changed, 102 insertions(+), 63 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 187004e..ded6e73 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,13 +1,13 @@ # AG Core Roadmap > 定稿日期:2026-05-11 -> 最后更新:2026-07-04(v0.1 发布完成) +> 最后更新:2026-07-04 ## 愿景 AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。 -**当前状态**:Phase 0-4c 全部完成;Provider IR 重构(统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen Provider)已完成;LlmCycle 简化(IR 消息类型切换 + 桥接层移除)已完成;v0.1 发布就绪(**182 个测试通过、0 clippy 警告、7 个离线示例可运行**)。 +**当前状态**:v0.1.0 已发布(2026-07-04)。Phase 0-4c 全部完成,Provider IR 重构 + LlmCycle 简化 + 7 个离线示例已交付。v0.2.0 规划已确定,主题为「生产就绪(Production-Ready Core)」。 --- @@ -240,95 +240,133 @@ graph BT --- -## 扩展计划(v0.2+) +## v0.2.0 — 生产就绪(Production-Ready Core) -> 以下功能在已完成的 phase 中已实现基础能力或在 Phase 4 阶段明确了边界,后续可按维度增量扩展。 -> 设计参考:见 `docs/note-agent-harness-references.md`(OpenClaw / Hermes / OpenHuman / OpenHarness 横向对比)。 -> OpenCode 借鉴:见 `docs/note-opencode-agent-switching.md`(Agent 切换 + System Prompt 拼接机制)。 +**目标**:解决 Rust Agent 工具箱从"能跑"到"能被人依赖"的鸿沟。持久化、配置层、上下文管理三大块补齐后,开发者可在 30 分钟内写出生产可用的 Agent 服务。 -### 已有扩展项(沿用) +**交付物**: -| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 | -|-------|---------|------|--------|------| -| Prompt Optimizer | `prompt` | 提示词自动优化 | P3 | 待实现 | -| 流式接口优化 | `llm/stream` | 流式响应解析与事件化 | P0 | ✅ 已完成基础实现 | +### 12 项基础功能 -### v0.2+ 新增扩展项 +#### P0 — 必须交付 -> 以下为基于 Phase 4 设计讨论确定的 v0.2+ 候选扩展方向,按维度分组。 -> 标注为"v0.2 待评估"表示在 Phase 4 完成后再决定是否启动。 +| # | 功能 | 模块 | 方案要点 | +|---|------|------|---------| +| 1 | SqliteStore | `memory` | `rusqlite` + `bundled` feature,`MemoryStore` 的 SQLite 实现,进程重启数据不丢 | +| 2 | ProviderConfig 扩展 + `from_env()` | `llm` | 补全 `timeout_secs` / `max_retries` 字段;`AG_LLM_*` 环境变量辅助函数 | +| 3 | ToolDefinition IR 正式化 | `tools` | 移除 deprecated OpenAI wire 格式,替换为自定义 `ToolDef` 结构体 | +| 4 | API 稳定性管理 | `*` | 公开枚举加 `#[non_exhaustive]`;CHANGELOG 记录 Breaking Changes;废弃 API 用 `#[deprecated]` 标记 | +| 5 | Quick Start + 端到端示例 | `examples/` | 30 行 `main.rs` 快速开始;一个"SQLite 持久化 + Provider + 工具调用 + 多轮对话"的可运行示例(`cargo run --example`) | -#### Multi-Agent / 协同 +#### P1 — 重要但不阻塞 -| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 | -|-------|---------|------|--------|------| -| Multi-Agent 协同(Swarm) | `agent` | 子 Agent 委派、并行子任务、结果聚合 | P2 | v0.2 待评估 | +| # | 功能 | 模块 | 方案要点 | +|---|------|------|---------| +| 6 | Ollama Provider | `llm/provider` | OpenAI Compat,本地 LLM 支持,实现量极小 | +| 7 | VectorRetriever trait | `memory` | 语义检索 trait 抽象(`index` / `search`),不绑定后端实现 | +| 8 | 流式 `submit_turn_stream` | `agent` | `AgentSession` 新增 `submit_turn_stream()`,返回 `Stream` | +| 9 | 测试补强 | `*` | wiremock Provider roundtrip 测试;多线程并发写入 MemoryStore 测试 | -#### 技能(Skills) +#### P2 — 有时间再做 -| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 | -|-------|---------|------|--------|------| -| Markdown 技能按需加载 | `agent` / `prompt` | 兼容 `SKILL.md` 格式(Hermes / OpenHarness 风格),按 prompt 上下文动态加载 | P2 | v0.2 待评估 | +| # | 功能 | 模块 | 备注 | +|---|------|------|------| +| 10 | MCP StreamableHttp | `tools` | 当前仅预留枚举变体 | +| 11 | Gemini Provider | `llm/provider` | 协议差异大,实现成本较高 | +| 12 | 文件系统 MemoryStore 后端 | `memory` | JSON/JSONL 轻量持久化 | -#### 记忆(Memory) +### ContextSlot 上下文管理 -| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 | -|-------|---------|------|--------|------| -| 多通道检索(hybrid) | `memory/retriever` | 在 TextOverlap 之上叠加向量检索通道 | P2 | v0.2 待评估 | -| KnowledgeGraph 深度记忆 | `memory` | 实体-关系图、`note-knowledge-graph-design.md` 已记录设计 | P3 | v0.2 待评估 | -| TokenJuice 智能压缩 | `memory` / `llm/compact` | 借鉴 OpenHuman TokenJuice,对工具结果做语义压缩而非字节截断 | P3 | v0.2 待评估 | +**模块归属**:`src/llm/context.rs`(与 `compact.rs` 同级) -#### 交互层(TUI / Gateway) +**核心概念**:`ContextSlot` 是一段带策略配置的消息列表,以 `slot_id` 为 namespace 独立持久化到 `MemoryStore`。支持三种模式、三种来源和派生关联(记录 `parent_id`)。 -| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 | -|-------|---------|------|--------|------| -| TUI / 多平台 Gateway | 应用层 | OpenClaw / Hermes 风格的消息平台桥接(Feishu / Telegram / Discord 等) | P3 | v0.2+ 应用层 | +**核心类型**: -#### 训练基础设施 +```rust +pub struct ContextSlot { id, session_id, config, messages, store } +pub struct SlotConfig { mode: SlotMode, source: SlotSource, budget, compact } +pub enum SlotMode { + Full, // 完整对话历史 + Focused(FocusedConfig), // 聚焦:保持 LLM 注意力 + Readonly, // 只读参考上下文 +} +pub struct FocusedConfig { keep_system, recent_turns, inject_summary } +pub enum SlotSource { + New, // 全新空槽,独立持久化 + Derived { parent_id, strategy: DeriveStrategy }, // 从父 slot 派生 + Static(Vec), // 预置消息,不持久化 +} +pub enum DeriveStrategy { Full, Focused(FocusedConfig) } +pub struct ContextBudget { system, history, tools, tool_results, reserve } +``` -| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 | -|-------|---------|------|--------|------| -| RL 轨迹导出 | `agent` | ShareGPT 格式轨迹、Atropos 集成(Hermes 风格) | P3 | v0.3+ 探索 | +**持久化 Key 命名**: +- `slot_msg:{session_id}:{slot_id}:{index}` → 消息内容 +- `slot_meta:{session_id}:{slot_id}` → `SlotMeta`(含 `parent_id`) +- `slot_rel:{session_id}:{child_id}:parent` → `"{parent_id}"` -#### 安全治理 +**`AgentSession` 扩展**: +- `create_slot(id, config)` — 创建新 slot +- `switch_slot(id)` — 切换当前 slot +- `list_slots()` — 列出所有 slot +- `derive_slot(id, parent_id, strategy)` — 从父 slot 派生 -| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 | -|-------|---------|------|--------|------| -| Human-in-the-loop 审批 | `agent` / `tools/permission` | 高危工具执行前的异步审批回调(OpenHarness `permission_prompt` 模式) | P2 | v0.2 待评估 | +**与 `ConversationMemory` 的关系**:保留不废除。`ConversationMemory` 继续服务传统对话场景。 -#### 流式 / 实时 +**v0.2 不做**: +- ❌ `slot.fork()` / `merge()` — 分支方法推迟到 v0.3+ +- ❌ `inject_summary` 自动生成 — v0.2 仅消费端(从 `SessionMemory` 读取),生成在 v0.3+ +- ❌ 血缘关系图遍历 — 只存 `parent_id`,不做查询 -| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 | -|-------|---------|------|--------|------| -| 流式 `submit_turn` | `agent/session` | Phase 4 v1 只暴露非流式 `submit_turn()`;v0.2 包装 `LlmCycle::submit_stream` 暴露流式入口 | P2 | v0.2 待评估 | +**依赖**:Phase 0(MemoryStore trait)、Phase 3(MemoryStore 持久化) +**优先级**:P1 -#### Agent 切换 / Prompt 动态(OpenCode 借鉴) +--- -| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 | -|-------|---------|------|--------|------| -| Agent 身份切换(角色轮换) | `agent` | 借鉴 OpenCode Tab 键切换 build/plan:同一 `AgentSession` 持有可热替换的 `Agent` 引用,切换时不重置消息历史,在末尾追加 `synthetic: true` 的状态变更消息。详见 `docs/note-opencode-agent-switching.md` §4 | P2 | v0.2 待评估 | -| System Prompt 多层动态拼接 | `agent/session` | 借鉴 OpenCode `request.ts:58-66`:拆分 `base_prompt + agent_prompt + env_context` 三层,`AgentSession::submit_turn` 每轮重算(不缓存),便于按 agent 类型动态切换 | P2 | v0.2 待评估 | -| **多 Context 切换** | `agent` | **Phase 4c 的 SessionMemory 数据结构已预留信息桥接通道,v0.2+ 在其上包装 `ContextManager` 实现完整的多 context 切换:创建/销毁/切换 context、通过 SessionMemory 桥接关键信息。详见 `docs/note-context-switch-design.md`** | P2 | v0.2 待评估 | +## v0.3+ 展望 + +### 已规划的功能 + +| 功能 | 说明 | 预计版本 | +|------|------|---------| +| ContextSlot 分支(fork/merge) | 在决策点 fork 出子上下文,分支独立演进,可合并/丢弃 | v0.3 | +| 摘要自动生成 | Hook 驱动,`OnTurnEnd` 自动将对话摘要写入 `SessionMemory`,`inject_summary` 消费端已在 v0.2 就绪 | v0.3 | +| 知识图谱 | 实体-关系图,`docs/note-knowledge-graph-design.md` 已记录设计 | v0.3+ | +| Multi-Agent 协同(Swarm) | 子 Agent 委派、并行子任务、结果聚合 | v0.4+ | +| 精确 tokenizer 计数 | 绑定具体模型的 tokenizer 计数,替代当前的字符估算 | v0.3+ | +| 血缘关系图遍历 | 以 `parent_id` 为基础,提供 slot 血缘链查询 | v0.3+ | +| Markdown 技能按需加载 | 兼容 `SKILL.md` 格式,按 prompt 上下文动态加载 | v0.3+ | +| TokenJuice 语义压缩 | 对工具结果做语义压缩而非字节截断 | v0.3+ | +| Human-in-the-loop 审批 | 高危工具执行前的异步审批回调 | v0.3+ | +| RL 轨迹导出 | ShareGPT 格式轨迹、Atropos 集成 | v0.4+ | + +### 明确不做(agcore 范围外) + +| 功能 | 原因 | +|------|------| +| TUI / 多平台 Gateway | 应用层职责(Feishu / Telegram / Discord 桥接) | +| 配置自动加载(config/figment) | 配置来源策略应由上游应用决定,agcore 不定义配置格式 | +| 提示词自动优化 | 属于智能层,不应内建于 core 库 | --- ## 风险与建议 -1. **Phase 0 已完成**:LLM 调用周期基础设施已全部实现,可以支撑后续模块开发 -2. **并行可能性**:Phase 0 和 Phase 1 可并行开展(无相互依赖),可加速早期交付 -3. **MCP 协议复杂性**:MCP 涉及协议握手、session 管理、长期连接,建议预留充足时间调研协议细节 -4. **Scope 蔓延风险**:当前 specs 只有 1 份文档,建议每个模块上线前都产出对应 spec,避免边实现边设计 -5. **Phase 4 抽象化边界**:AG Core 定位为"支持库"而非"Agent 产品",Phase 4(4a/4b/4c)需严格控制范围——只暴露 trait + 最小 reference impl,业务循环(多轮 turn 编排、对话记忆自动回写、Task 拆解策略)留给上层应用。`SessionMemory`(Phase 4c)提供信息桥接通道但不实现 context 切换逻辑。多 context 切换管理延后至 v0.2+。详细设计决策见 `docs/7-agent-runtime.md` -6. **参考项目语言差异**:OpenClaw / Hermes / OpenHarness 均为 Python/TypeScript 实现,OpenHuman 虽是 Rust + Tauri 但定位是桌面应用。借鉴时**只取架构模式**,不照搬具体实现(如 Pydantic 工具校验、SQLite Memory Tree、Node+Python 双进程等) +1. **持久化依赖**:`rusqlite` + `bundled` 零外部依赖编译,但 SQLite 不适配所有场景(分布式/高并发写)。`MemoryStore` trait 的抽象层允许下游自行实现 Redis / PostgreSQL 后端 +2. **ContextSlot 心智负担**:`ContextSlot` 引入了一等抽象的复杂度。建议通过 `AgentBuilder` 默认创建 `"default"` slot,让简单场景无感使用 +3. **向量检索生态**:`VectorRetriever` trait-only 不绑定实现,需社区贡献或用户自行适配 pgvector / qdrant / lancedb +4. **Scope 蔓延**:agcore 定位为"支持库"而非"Agent 产品",始终以 trait + reference impl 为边界,业务循环留给上层 +5. **API 稳定性**:v0.2 引入 `#[non_exhaustive]` 和 `#[deprecated]` 机制,但不承诺 SemVer 稳定——仍在快速迭代期 --- ## 下一步行动 -1. **Phase 4c 已完成**:Phase 4a + 4b + 4c 已交付(116 测试通过,0 clippy 警告)。可启动 v0.2+ 扩展评估(如多 Context 切换、Multi-Agent 协同等) -2. **Context 切换备忘**:`docs/note-context-switch-design.md` 记录了多 context 切换方案讨论,作为 v0.2+ 扩展项的输入 -3. **参考项目调研沉淀**:已完成 OpenClaw / Hermes / OpenHuman / OpenHarness 横向调研,结果沉淀至 `docs/note-agent-harness-references.md`,作为 v0.2+ 扩展项的输入 -4. **Phase 3 备用设计就绪**:`docs/note-knowledge-graph-design.md` 记录了 KnowledgeGraph、高级评分、RecallBased 淘汰等设计,v0.2+ 记忆扩展可直接参考 +1. **v0.2 开发启动**:按 P0 → P1 → P2 顺序推进,P0 五项必须全部交付 +2. **ContextSlot 方案文档**:输出正式方案文档到 `docs/`,记录 SlotConfig / SlotSource / DeriveStrategy 等设计决策 +3. **示例先行**:每个 P0 功能先编写 `examples/` 中的可运行示例,验证通过后再合入库代码 +4. **测试覆盖**:SqliteStore 并发测试 + wiremock Provider roundtrip 测试 + ContextSlot 隔离/切换/派生测试 **已完成 / 进行中阶段**: - ✅ Phase 0 Foundation — 全部交付物已完成 @@ -338,9 +376,10 @@ graph BT - ✅ Phase 4a Core Glue — 全部交付物已完成 - ✅ Phase 4b Task Execution — 全部交付物已完成 - ✅ Phase 4c Session Memory — 全部交付物已完成 -- ✅ Provider IR 重构 — 统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen 适配(方案:`docs/10-llm-provider-refinement.md`、`docs/10a-phase0-types-and-trait.md`、`docs/10b-phase1-provider-adaptation.md`) -- ✅ LlmCycle 简化 — IR 消息类型切换 + Phase 0 桥接层移除(方案:`docs/10c-phase2-llm-cycle-simplify.md`) -- ✅ v0.1 Release — 技术债扫清、MockProvider 公开化、7 个离线示例、README + 错误消息友好化、Roadmap 同步、CHANGELOG 初始化(计划:`docs/11-v0.1-release-plan.md`) +- ✅ Provider IR 重构 — 统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen 适配 +- ✅ LlmCycle 简化 — IR 消息类型切换 + Phase 0 桥接层移除 +- ✅ v0.1 Release — 技术债扫清、MockProvider 公开化、7 个离线示例、README + 错误消息友好化、CHANGELOG 初始化 +- 📋 **v0.2 规划完成** — 生产就绪(Production-Ready Core),ContextSlot 上下文管理,12 项基础功能 --- From 6315f2d008b059eae1dfcd6874076368f5a666e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Sat, 4 Jul 2026 10:19:54 +0800 Subject: [PATCH 12/14] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20opencode=20?= =?UTF-8?q?=E5=AD=90=E4=BB=A3=E7=90=86=E8=B0=83=E5=BA=A6=E5=88=86=E5=8F=91?= =?UTF-8?q?=E4=B8=8E=E5=90=88=E5=B9=B6=E6=9C=BA=E5=88=B6=E8=B0=83=E7=A0=94?= =?UTF-8?q?=E7=AC=94=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/note-opencode-subagent-dispatch.md | 344 ++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 docs/note-opencode-subagent-dispatch.md diff --git a/docs/note-opencode-subagent-dispatch.md b/docs/note-opencode-subagent-dispatch.md new file mode 100644 index 0000000..c6ae133 --- /dev/null +++ b/docs/note-opencode-subagent-dispatch.md @@ -0,0 +1,344 @@ +# 笔记:opencode 子代理调度、分发与合并及工作流推进 + +> 基于 `/Users/midnite/Samples/opencode` 源码调研,2026-07-04 + +--- + +## 一、整体架构 + +``` +LLM(主 Agent) + │ + ├── 调用 Task tool(tool call) + │ ↓ + │ TaskTool.execute() ← packages/opencode/src/tool/task.ts + │ │ + │ ├── agent.get() ← 查找 Agent 定义(agent.ts) + │ ├── deriveSubagentPermission() ← 权限合并(subagent-permissions.ts) + │ ├── sessions.create() ← 创建子 session + │ │ + │ ├── [前台] background.wait() + background.waitForPromotion() race + │ │ ↓ 完成 + │ │ renderOutput() → XML 标签返回 + │ │ + │ └── [后台] background.start() → notify() 异步注入结果 + │ + └── 会话循环(runLoop) ← prompt.ts + │ + ├── 检测 subtask type part → handleSubtask() + ├── 检测 compaction → compaction.process() + └── 正常流程 → LLM.stream() → processor.handleEvent() +``` + +--- + +## 二、子代理调度(Dispatch) + +### 2.1 三种触发入口 + +| 入口 | 触发方式 | 调用链路 | +|------|---------|---------| +| A — LLM 自主 | LLM 调用 `task` tool | 系统提示词中注入了 Task tool 描述 + `describeTask()` 输出子代理列表 → LLM 决策 | +| B — `subtask` part | 消息中有 `type: "subtask"` 的 part | `handleSubtask()` 直接执行 TaskTool,不走 LLM | +| C — `agent` part | 消息中有 `type: "agent"` 的 part | 转为"调用 task tool 带 subagent: XXX"的提示词,引导 LLM | + +### 2.2 TaskTool.execute() 完整流程(task.ts) + +``` +execute(params, ctx): + 1. background 开关检查(需 experimental flag) + 2. ctx.ask() 权限询问 + 3. agent.get(subagent_type) 查找子代理定义 + 4. task_id 存在 → sessions.get(task_id) 恢复已有子 session + task_id 不存在 → sessions.create() 创建新子 session + 5. deriveSubagentSessionPermission() 合并权限 + 6. 添加默认 deny 规则(todowrite / task) + 7. 确定 model(继承或子代理自定义) + 8. 执行 runTask() → ops.resolvePromptParts() + ops.prompt() + 9. 结果格式化为 XML ← renderOutput() +``` + +### 2.3 关键:子 session 创建(task.ts lines 121-158) + +```typescript +// 权限继承 +const childPermission = deriveSubagentSessionPermission({ + parentSessionPermission: parent.permission ?? [], + subagent: next, +}) + +// 默认 deny 规则 +const childToolDenies = [ + // 子代理自己的 permission 没允许 todowrite → 默认 deny + ...(next.permission.some(r => r.permission === "todowrite") ? [] + : [{ permission: "todowrite", pattern: "*", action: "deny" }]), + // 子代理自己的 permission 没允许 task → 默认 deny(防嵌套) + ...(next.permission.some(r => r.permission === "task") ? [] + : [{ permission: "task", pattern: "*", action: "deny" }]), + // 主 agent 专有工具也不给子代理 + ...(cfg.experimental?.primary_tools?.map(p => ({ permission: p, ... })) ?? []), +] +``` + +--- + +## 三、通信格式:Tool Call / Tool Result + +### 3.1 父→子:Task tool 参数 + +``` +{ + subagent_type: "explore" | "general" | ..., + description: "简短描述(3-5词)", + prompt: "子代理的完整任务描述", + task_id?: "恢复已有子 session 时使用", + command?: "触发该调用的 CLI 命令(可选)", + background?: true // 后台模式(需 experimental flag) +} +``` + +### 3.2 子→父:XML 包装的纯文本(renderOutput) + +```xml + + 任务简述 + + 子 agent 输出的完整文本内容... + + +``` + +错误时: + +```xml + + 任务失败 + + Error: 具体错误信息... + + +``` + +### 3.3 传递给 LLM 的方式 + +**前台模式**: +``` +TaskTool.execute() 返回 { output: "..." } + ↓ +AI SDK 将其转为 tool result,存入数据库 tool part + ↓ +下一轮 LLM 调用时,tool result 作为消息历史的一部分传入 + ↓ +LLM 看到 XML,自行解析使用 +``` + +**后台模式**: +``` +TaskTool.execute() 立即返回 ... + ↓ +子 agent 完成后 → background.wait() 触发 → inject() + ↓ +向父 session 注入合成 text part(synthetic: true) + 携带 ... 结果 + ↓ +父 LLM 在下一轮循环中看到该消息 +``` + +--- + +## 四、分发与合并(Distribution & Merge) + +### 4.1 并行分发 + +- **无专用分发层**。依赖 LLM 在单条消息中发出多个 tool call +- `task.txt` 引导 LLM:*"Launch multiple agents concurrently whenever possible"* +- 底层通过 Effect.ts 的 `Effect.forkIn(scope, { startImmediately: true })` 实现同一消息内多 tool call 并发 +- **子 agent 之间完全隔离**,无直接通信 + +### 4.2 结果合并 + +**无专用合并逻辑。** 合并完全通过 LLM 的上下文理解完成: + +- 前台:tool result 自然进入消息历史,LLM 下一轮读取 +- CLI 命令:额外注入 "Summarize the task tool output above and continue with your task." 引导 LLM 总结 +- LLM 自主调用:无额外引导,LLM 自行决定如何使用 + +### 4.3 前台/后台切换机制(task.ts lines 303-333) + +```typescript +// 前台执行 +return yield* Effect.raceFirst( + background.wait({ id: nextSession.id }), // 等完成 + background.waitForPromotion(nextSession.id), // 等 promote 到后台 +) +``` + +当用户将前台任务 promote 到后台时,`waitForPromotion` 先返回(标记 `metadata.background = true`),TaskTool 转而返回后台模式的输出。 + +### 4.4 后台作业引擎(core/background-job.ts) + +纯内存、非持久化注册表。使用 Effect.ts 的 `SynchronizedRef` 做并发控制。 + +| 操作 | 行为 | +|------|------| +| `start()` | 创建 job,fork run effect,返回 info | +| `extend()` | 追加顺序执行的 run(通过 `Deferred` 链式等待前一个完成) | +| `wait()` | `Deferred.await(done)`,可选 timeout | +| `waitForPromotion()` | 等待 `promoted` Deferred 或检测 `background` 标记 | +| `promote()` | 标记 `background = true`,触发 `onPromote` callback | +| `cancel()` | 设置 `cancelled`,close scope(中断所有子 fork) | + +--- + +## 五、工作流推进(Workflow Progression) + +### 5.1 核心循环(prompt.ts → runLoop) + +``` +runLoop(sessionID): + while true: + 1. MessageV2.filterCompactedEffect() 获取消息 + 2. MessageV2.latest() 取最近 user/assistant/tasks + 3. 检查 finish 状态 + - 不是 tool-calls 且有 finish → break(退出循环) + 4. 取 tasks(subtask / compaction 队列) + - subtask → handleSubtask() → continue + - compaction → compaction.process() → continue/break + 5. 检查 overflow → 自动创建 compaction task → continue + 6. 构建 assistant message + 7. SessionProcessor.create() 创建 handle + 8. SessionTools.resolve() 解析所有工具 + 9. 构建 system prompt(环境信息 + skills + MCP + instructions) + 10. handle.process() — 启动 LLM stream + 11. 检查 result: + - "compact" → 返回给外层触发 compaction + - "stop" → break + - "continue" → 继续循环 +``` + +### 5.2 SessionProcessor 事件处理(processor.ts) + +| Stream 事件 | 处理逻辑 | +|------------|---------| +| `reasoning-start/delta/end` | 创建 reasoning part → 增量追加 → 最终持久化 | +| `tool-input-start/delta/end` | 创建/更新 tool part(pending 状态) | +| `tool-call` | 标记 running → 设置 input → **doom loop 检测** | +| `tool-result` | `completeToolCall()` → 持久化结果 + 附件 | +| `tool-error` | `failToolCall()` → 标记错误 | +| `provider-error` | 抛出异常 → 触发重试 | +| `text-start/delta/end` | 流式文本 → `updatePartDelta()` **增量持久化** | +| `step-start` | 创建快照(snapshot) | +| `step-finish` | 生成 patch diff → 更新 usage/tokens → **overflow 检测** → 触发 summary | +| `finish` | stream 结束 | + +### 5.3 Doom Loop 检测(processor.ts lines 351-377) + +连续 3 次**完全相同的 tool call**(相同名称 + 相同输入)触发权限询问: + +```typescript +const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD) // DOOM_LOOP_THRESHOLD = 3 +if (recentParts.length === DOOM_LOOP_THRESHOLD && + recentParts.every(part => + part.type === "tool" && + part.tool === value.name && + part.state.status !== "pending" && + JSON.stringify(part.state.input) === JSON.stringify(input) + )) { + yield* permission.ask({ permission: "doom_loop", ... }) +} +``` + +### 5.4 Compaction 工作流 + +两种触发方式: + +| 触发条件 | 行为 | +|---------|------| +| step-finish 检测到 `isOverflow()` + `auto: true` | 创建 compaction task → 下一轮循环执行 → 压缩后 continue | +| step-finish 检测到 `isOverflow()` + `auto: false` | 标记 `assistantMessage.error` → idle 等待用户干预 | + +Compaction 使用专门的 `compaction` agent(hidden, mode=primary, `*=deny`)执行。 +压缩后的消息标记 `compacted: true`,后续通过 `MessageV2.filterCompactedEffect()` 过滤。 + +### 5.5 重试机制(processor.ts lines 658-672) + +```typescript +Effect.retry( + SessionRetry.policy({ + provider: input.model.providerID, + parse, // 错误解析(区分可重试/不可重试) + set: (info) => status.set(sessionID, { type: "retry", ... }), + }), +) +``` + +遇 provider 错误自动重试,LLM stream 完成后 `Effect.ensuring(cleanup)` 保证资源释放。 + +--- + +## 六、六种内置 Agent + +| 名称 | Mode | Hidden | 用途 | 核心权限特征 | +|------|------|--------|------|-------------| +| `build` | primary | 否 | 默认 agent,全部工具 | question/plan_enter=allow | +| `plan` | primary | 否 | 计划模式,禁用编辑 | edit=deny(除 plans), task(general)=deny | +| `general` | subagent | 否 | 通用子代理 | todowrite=deny(默认禁止改 todo) | +| `explore` | subagent | 否 | 只读代码探索 | `*=deny`,仅 read/grep/glob/bash/webfetch/websearch | +| `compaction` | primary | 是 | 会话压缩(自动) | `*=deny` | +| `title` | primary | 是 | 生成会话标题 | `*=deny`(step=1 时异步 fork) | +| `summary` | primary | 是 | 生成消息摘要 | `*=deny`(每个 step-finish 时异步 fork) | + +用户可通过 `config.agent` 自定义 agent(支持 `mode: "all"`),也可通过 `agent.generate` 让 LLM 辅助生成。 + +--- + +## 七、权限模型总结 + +``` +父 session permission + │ + ├── 仅继承 deny 规则 + external_directory 规则 ← subagent-permissions.ts + │ (父 agent 的 allow 规则不传播到子代理) + │ + ├── 子代理自身 permission(来自 agent 定义) + │ + ├── 默认 deny: + │ - todowrite(除非子代理明确允许) + │ - task(除非子代理明确允许,默认防嵌套) + │ + └── 主 agent 专有工具 deny(来自 config.experimental.primary_tools) +``` + +子代理的 session 权限 = **父 deny + 父 external_directory + 自身 permission - 默认 deny - primary_tools deny**。 + +--- + +## 八、关键设计决策 + +| 决策 | 意图 | 效果/局限 | +|------|------|----------| +| 结果以 XML 纯文本嵌入上下文 | 简单、LLM 可直接理解 | LLM 自行解析 XML;大结果可能被截断 | +| 无专用 merge 逻辑 | 简洁,不引入额外抽象 | 依赖 LLM 的理解能力处理返回结果 | +| 默认禁止子代理嵌套 task | 防止无限递归 | 限制了多级分解场景 | +| 同一消息多 tool call 并发 | 利用 LLM 并行能力 | 子 agent 隔离,无法协作 | +| Effect.ts 贯穿全程 | 类型安全、结构化并发 | 学习曲线陡峭 | +| session 作为隔离边界 | 天然权限/消息隔离 | 每个子 session 独立数据库记录,开销较大 | +| 后台引擎纯内存 | 有意识取舍(注释说明) | 进程重启丢失状态 | + +--- + +## 九、参考源码路径 + +| 文件 | 角色 | +|------|------| +| `packages/opencode/src/tool/task.ts` | Task tool 核心实现(调度入口) | +| `packages/opencode/src/tool/task.txt` | Task tool 的 LLM 使用说明 | +| `packages/opencode/src/agent/agent.ts` | Agent 定义注册中心 | +| `packages/opencode/src/agent/subagent-permissions.ts` | 子代理权限推导 | +| `packages/opencode/src/tool/registry.ts` | 工具注册 + `describeTask()` 列出可用子代理 | +| `packages/opencode/src/session/prompt.ts` | 会话循环 + `handleSubtask()` + 提示词构建 | +| `packages/opencode/src/session/processor.ts` | LLM stream 事件处理器 | +| `packages/opencode/src/session/tools.ts` | Tool ↔ AI SDK 桥接 | +| `packages/opencode/src/session/system.ts` | 系统提示词生成(含 Task tool 说明) | +| `packages/opencode/src/background/job.ts` | 后台作业包装层 | +| `packages/core/src/background-job.ts` | 后台作业核心引擎(内存注册表) | From 76f3235ed7c178333a655fcbf9348f855d97ffc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Sat, 4 Jul 2026 10:32:24 +0800 Subject: [PATCH 13/14] =?UTF-8?q?docs(roadmap):=20=E6=9B=B4=E6=96=B0=20v0.?= =?UTF-8?q?2=20=E8=A7=84=E5=88=92=E4=B8=BA=208=20=E4=B8=AA=E5=A2=9E?= =?UTF-8?q?=E9=87=8F=20Phase=20=E5=B9=B6=E7=BB=86=E5=8C=96=E5=AE=9E?= =?UTF-8?q?=E6=96=BD=E6=AD=A5=E9=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/roadmap.md | 206 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 198 insertions(+), 8 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index ded6e73..71c8236 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -7,7 +7,7 @@ AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。 -**当前状态**:v0.1.0 已发布(2026-07-04)。Phase 0-4c 全部完成,Provider IR 重构 + LlmCycle 简化 + 7 个离线示例已交付。v0.2.0 规划已确定,主题为「生产就绪(Production-Ready Core)」。 +**当前状态**:v0.1.0 已发布(2026-07-04)。Phase 0-4c 全部完成,Provider IR 重构 + LlmCycle 简化 + 7 个离线示例已交付。v0.2.0 已细分为 8 个增量 Phase(Phase 5-12),本周开发启动。 --- @@ -244,9 +244,9 @@ graph BT **目标**:解决 Rust Agent 工具箱从"能跑"到"能被人依赖"的鸿沟。持久化、配置层、上下文管理三大块补齐后,开发者可在 30 分钟内写出生产可用的 Agent 服务。 -**交付物**: +**总体规模**:8 个增量 Phase(Phase 5-12),17 个可验证 Step。 -### 12 项基础功能 +### 功能清单 #### P0 — 必须交付 @@ -324,6 +324,196 @@ pub struct ContextBudget { system, history, tools, tool_results, reserve } --- +### v0.2.0 实施计划 — 8 个增量 Phase + +> **编号说明**:Phase 5-12 接续 v0.1 的 Phase 0-4c,按开发顺序排列。 + +#### Phase 5: 热身准备(Warmup) + +**目标**:快速交付三个互不依赖的独立改动,建立交付节奏。 + +| Step | 内容 | 文件范围 | 验证标准 | +|------|------|---------|---------| +| **5.1** | `ProviderConfig` 扩展:补 `timeout_secs`(def=30) + `max_retries`(def=3);新增 `ProviderConfig::from_env(prefix)` | `llm/provider.rs` + 各 Provider `new()` 构造函数 | `cargo test` + `from_env()` 单元测试 | +| **5.2** | `OllamaProvider`:基于 `GenericOpenaiProvider` 包装,改 base_url 为 `http://localhost:11434`;`ProviderType` 新增 `Ollama` | `llm/provider/provider.rs` + `llm/provider/ollama.rs`(新增) | `cargo build` — 纯类型级验证 | +| **5.3** | 公开枚举 `#[non_exhaustive]` 前置标记:`ProviderType` / `StopReason` / `FinishReason` / `EvictionPolicy` / `SlotMode`(预置) | 各枚举定义处 | 编译通过 + `cargo clippy` 0 警告 | + +**依赖**:无(三个 Step 互不冲突) +**优先级**:P0(5.1)+ P1(5.2)+ P0 前置(5.3) +**为何独立成 Phase**:三个改动零文件重叠,可以并行推进。它们是后续所有 Phase 的"门把手"——先做完热身再进入核心工作。 + +--- + +#### Phase 6: ToolDefinition IR 正式化 + +**目标**:引入 `ToolDef` 新类型,替换已标记 `#[deprecated]` 的 `ToolDefinition`(`OpenaiToolDefinition` 别名)。 + +**这是 v0.2 技术风险最高的 Phase**,影响 4 个模块约 8 个文件。通过 5 个 Step 逐文件切割确保每步可编译。 + +| Step | 内容 | 验证标准 | +|------|------|---------| +| **6.1** | `types/tool.rs` 新增 `ToolDef` 结构体 + `From for OpenaiToolDefinition` + 反向 `From` | 单元测试 roundtrip | +| **6.2** | `types/mod.rs` 切别名 `pub type ToolDefinition = ToolDef`;`MessageRequest.tools` 改 `Vec` | `cargo build` 编译断点 | +| **6.3** | `cycle.rs` 4 个方法签名 + `registry.rs` `definitions()` 签名更新 | `cargo build` | +| **6.4** | Provider 适配层(openai.rs / anthropic.rs / openai_compat.rs):`build_request()` 内做 `ToolDef → wire-format` 转换 | `cargo test` 每个 provider 测试 | +| **6.5** | 所有测试/示例中 `ToolDefinition` → `ToolDef` 修复;移除旧 `#[deprecated]` alias | `cargo test --all-targets` 全绿 | + +**边界切割技巧**: +- Step 6.1 → 6.2 之间是安全 checkpoint:新类型存在但旧代码照常编译 +- Provider 层不改序列化逻辑,只加一层 `From` 转换 +- 当前代码中 `ToolDefinition` 已是 `#[deprecated(since = "0.1.0")]`,用户已有迁移预期 + +**依赖**:无(仅与 Phase 5.3 有枚举兼容关系) +**优先级**:P0 + +--- + +#### Phase 7: SqliteStore 持久化 + +**目标**:实现 `MemoryStore` 的 SQLite 后端,进程重启数据不丢。 + +**与 Phase 6 无耦合,可重叠开发。** + +| Step | 内容 | 文件 | 验证标准 | +|------|------|-----|---------| +| **7.1** | 新增 `memory/store/sqlite.rs`:`Mutex` + `spawn_blocking`,实现 `save/get/delete/list` + prefix 过滤 | `memory/store/sqlite.rs` + `Cargo.toml`(add `rusqlite`) | 单元测试 CRUD + prefix 查询 | +| **7.2** | WAL 模式 + 并发安全 + 集成测试(`tokio::spawn` 10 个并发 task) | `sqlite.rs` 扩展 | 并发写入 100 轮无 race | + +**设计决策**: +- 用 `Mutex` 而非连接池(ponytail:一个连接够用就不加 r2d2) +- WAL 模式:`PRAGMA journal_mode=WAL` 解决读写锁 + +**依赖**:`MemoryStore` trait(v0.1 Phase 3 已就绪) +**优先级**:P0 + +--- + +#### Phase 8: MVP 集成出口(v0.2.0-rc.1 候选) + +**目标**:P0 五项全部交付。开发者 clone 仓库后 10 分钟跑起持久化 Agent。 + +| Step | 内容 | 验证标准 | +|------|------|---------| +| **8.1** | API 稳定性扫尾:`#[deprecated]` 整理 + CHANGELOG v0.2 + 公开类型回顾 | 人工 review + `cargo doc` 无 warning | +| **8.2** | Quick Start 示例(30 行 `main.rs`):MockProvider + EchoTool + 一次 `submit_turn` | `cargo run --example quick_start` exit 0 | +| **8.3** | 端到端示例:SqliteStore + Ollama/OpenAI(from_env) + 自定义 Tool + 多轮对话 | `cargo run --example end_to_end`(Mock fallback,无需 API key)| + +**Phase 8 完成后可打 `v0.2.0-rc.1` 标签**。 + +**依赖**:Phase 5(ProviderConfig from_env)+ Phase 6(ToolDef)+ Phase 7(SqliteStore) +**优先级**:P0 + +--- + +#### Phase 9: 流式体验增强 + +**目标**:Agent 会话支持流式输出,开发者看到实时 token。 + +| Step | 内容 | 文件 | 验证标准 | +|------|------|-----|---------| +| **9.1** | `AgentSession::submit_turn_stream(user_input) -> impl Stream` | `agent/session.rs` | 单元测试验证流事件序列:`TextDelta → ... → MessageComplete` | + +**注意**:tool 自动循环时流中插入 `ToolExecutionStarted` 事件,用户端 UI 显示"正在调用工具..."。 + +**依赖**:Phase 6(ToolDef)+ `LlmProvider.chat_stream`(v0.1 已有) +**优先级**:P1 + +--- + +#### Phase 10: ContextSlot 上下文管理 + +**目标**:支持多上下文分区管理,Agent 可在不同 slot 之间切换。 + +| Step | 内容 | 验证标准 | +|------|------|---------| +| **10.1** | `src/llm/context.rs`:`ContextSlot` + `SlotConfig` / `SlotMode` / `SlotSource` / `ContextBudget` 核心类型 | `cargo build` | +| **10.2** | ContextSlot 持久化:基于 `MemoryStore` trait(不绑定 SqliteStore)实现 save/load/list + slot 命名空间 key 策略 | 单元测试:slot 创建/写入/读取/隔离(不串数据) | +| **10.3** | `AgentSession` 扩展:`create_slot` / `switch_slot` / `list_slots` / `derive_slot` + `AgentBuilder` 默认创建 `"default"` slot | 集成测试 + 新示例 `context_slot_demo` | + +**如何保证简单场景无感**:`AgentBuilder::build()` 内部检查,如果用户没手动 `create_slot`,自动创建 `"default"` slot → `submit_turn` 默认写到 default slot。 + +**依赖**:Phase 7(SqliteStore 作为推荐持久化后端;`MemoryStore` trait 即可) +**优先级**:P1 + +--- + +#### Phase 11: 测试与检索补强 + +**目标**:补全测试覆盖 + 语义检索抽象。 + +| Step | 内容 | 验证标准 | +|------|------|---------| +| **11.1** | `VectorRetriever` trait:`index(id, embeddings)` + `search(query, k)` | 编译 + mock 测试 | +| **11.2** | wiremock Provider roundtrip 测试:模拟 OpenAI/Anthropic HTTP 端点 | `cargo test` 新增 10+ roundtrip 测试 | +| **11.3** | 并发测试补强:InMemoryStore + SqliteStore 多线程写入验证 | 跑 100 轮无 race | + +**依赖**:无(可随时做) +**优先级**:P1 + +--- + +#### Phase 12: P2 锦上添花(可选) + +**目标**:时间允许时按优先级交付。 + +| 优先级 | 功能 | 实现量估计 | 备注 | +|--------|------|-----------|------| +| **12.1** | 文件系统 MemoryStore(JSON/JSONL) | ~80 行 | 最简单,适合练手 | +| **12.2** | MCP StreamableHttp 传输 | ~150 行 | 协议还在演进 | +| **12.3** | Gemini Provider | ~300 行 | 协议差异大,建议推迟到 v0.3 | + +**依赖**:无(独立交付) + +--- + +### v0.2.0 Phase 依赖关系图 + +```mermaid +graph BT + P5["Phase 5
热身准备"]:::warmup + P6["Phase 6
ToolDefinition IR"]:::core + P7["Phase 7
SqliteStore"]:::core + P8["Phase 8
MVP 出口 (rc.1)"]:::mvp + P9["Phase 9
流式体验增强"]:::p1 + P10["Phase 10
ContextSlot"]:::p1 + P11["Phase 11
测试与检索"]:::p1 + P12["Phase 12
P2 锦上添花"]:::p2 + + P8 --> P5 + P8 --> P6 + P8 --> P7 + + P9 --> P6 + + P10 --> P7 + P10 --> P8 + + P11 -.-> P7 + + classDef warmup fill:#e2e8f0,stroke:#94a3b8 + classDef core fill:#fbbf24,stroke:#d97706 + classDef mvp fill:#4ade80,stroke:#16a34a + classDef p1 fill:#93c5fd,stroke:#2563eb + classDef p2 fill:#c4b5fd,stroke:#7c3aed +``` + +--- + +### 关键里程碑 + +| 里程碑 | Phase 完成条件 | 可验证指标 | +|--------|---------------|-----------| +| **M1** | Phase 5 | 热身三项完成:`from_env()` 可用 / Ollama 类型存在 / `#[non_exhaustive]` 就位 | +| **M2** | Phase 6 | `ToolDef` 全量切换,`cargo test --all-targets` 全绿 | +| **M3** | Phase 7 | SqliteStore CRUD + 并发测试通过,进程重启数据不丢 | +| **M4** | **Phase 8 (rc.1)** | P0 五项全部交付,`cargo run --example quick_start` 跑通 | +| **M5** | Phase 9 | `submit_turn_stream` 流式事件序列验证通过 | +| **M6** | Phase 10 | ContextSlot 创建/切换/派生集成测试通过 | +| **M7** | Phase 11 | wiremock + 并发测试补强,测试总量 200+ | +| **M8** | Phase 12(可选) | P2 功能按需交付 | + +--- + ## v0.3+ 展望 ### 已规划的功能 @@ -363,10 +553,10 @@ pub struct ContextBudget { system, history, tools, tool_results, reserve } ## 下一步行动 -1. **v0.2 开发启动**:按 P0 → P1 → P2 顺序推进,P0 五项必须全部交付 -2. **ContextSlot 方案文档**:输出正式方案文档到 `docs/`,记录 SlotConfig / SlotSource / DeriveStrategy 等设计决策 -3. **示例先行**:每个 P0 功能先编写 `examples/` 中的可运行示例,验证通过后再合入库代码 -4. **测试覆盖**:SqliteStore 并发测试 + wiremock Provider roundtrip 测试 + ContextSlot 隔离/切换/派生测试 +1. **Phase 5 启动**:ProviderConfig from_env + Ollama Provider + #[non_exhaustive] 前置,三个 Step 并行推进 +2. **Phase 6 方案准备**:ToolDef 结构体定义 + 兼容转换,出实施笔记(实施时直接走代码评审) +3. **示例先行**:每完成一个 Phase 立即更新对应示例,验证通过后再合入 +4. **里程碑追踪**:以 Phase 8(MVP 出口)为 v0.2.0-rc.1 节点,逐 Phase 验收 **已完成 / 进行中阶段**: - ✅ Phase 0 Foundation — 全部交付物已完成 @@ -379,7 +569,7 @@ pub struct ContextBudget { system, history, tools, tool_results, reserve } - ✅ Provider IR 重构 — 统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen 适配 - ✅ LlmCycle 简化 — IR 消息类型切换 + Phase 0 桥接层移除 - ✅ v0.1 Release — 技术债扫清、MockProvider 公开化、7 个离线示例、README + 错误消息友好化、CHANGELOG 初始化 -- 📋 **v0.2 规划完成** — 生产就绪(Production-Ready Core),ContextSlot 上下文管理,12 项基础功能 +- 📋 **v0.2 规划细化完成** — 8 个增量 Phase(Phase 5-12),17 个可验证 Step,覆盖 P0-P2 全部 12 项功能 + ContextSlot --- From 3bd135ec980c209197a242196c4df4eaff700657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Sun, 5 Jul 2026 07:32:19 +0800 Subject: [PATCH 14/14] =?UTF-8?q?docs(roadmap):=20=E6=B7=BB=E5=8A=A0=20Pha?= =?UTF-8?q?se=205=20warmup=20=E5=AE=9E=E6=96=BD=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确 Step 5.2 Ollama Provider、Step 5.3 non_exhaustive 前置标记、 Step 5.1 ProviderConfig 扩展三个独立 Step 的执行顺序与交付物 --- docs/12-phase5-warmup-implementation.md | 522 ++++++++++++++++++++++++ 1 file changed, 522 insertions(+) create mode 100644 docs/12-phase5-warmup-implementation.md diff --git a/docs/12-phase5-warmup-implementation.md b/docs/12-phase5-warmup-implementation.md new file mode 100644 index 0000000..44d85db --- /dev/null +++ b/docs/12-phase5-warmup-implementation.md @@ -0,0 +1,522 @@ +# Phase 5:热身准备 — 实施方案 + +## 1. 背景与目标 + +Phase 5 是 v0.2.0 发布周期的**热身准备阶段**,包含三个互不依赖的 Step,为后续 Phase 6-12 的端到端集成提供基础设施。 + +**核心目标**: +- 为 Phase 8(端到端示例)提供零 API key 的运行路径(Ollama) +- 为公共枚举的向后兼容性加上编译期护栏(`#[non_exhaustive]`) +- 为 Provider 构造提供统一的超时与重试配置入口(`ProviderConfig` 扩展) + +三个 Step 之间**无依赖关系**,但出于实现效率考虑,按 **5.2 → 5.3 → 5.1** 顺序执行。理由:5.2 先新增 `Ollama` 枚举变体,5.3 再加 `#[non_exhaustive]`,避免枚举标记后添加变体需要在外部 crate 加 `_ =>` 兜底分支的困扰。 + +## 2. 需求分析 + +### Step 5.2 — Ollama Provider + +| 维度 | 内容 | +|------|------| +| **需求** | 新增 `OllamaProvider`,newtype 包装 `GenericOpenaiProvider`,默认连接本地 Ollama 实例 | +| **优先级** | P0 — 为 Phase 8 端到端示例提供无需 API key 的运行路径 | +| **预期交付物** | `src/llm/provider/ollama.rs` 新建文件;`ProviderType` 新增 `Ollama` 变体 | +| **代码量** | ~55 行 | + +### Step 5.3 — `#[non_exhaustive]` 前置标记 + +| 维度 | 内容 | +|------|------| +| **需求** | 为 4 个公共枚举添加 `#[non_exhaustive]` 属性,避免后续新增变体时破坏下游 match | +| **优先级** | P1 — 编译期兼容性保障 | +| **预期交付物** | 修改 4 个枚举定义,各加一行属性 | +| **代码量** | ~4 行 | + +### Step 5.1 — ProviderConfig 扩展 + +| 维度 | 内容 | +|------|------| +| **需求** | `ProviderConfig` 新增 `timeout_secs` 和 `max_retries` 字段;实现 `Default`、`from_env()` 构造;timeout 传导到各 Provider HTTP Client | +| **优先级** | P0 — 与 Roadmap 一致,Phase 8(MVP 出口)依赖 from_env | +| **预期交付物** | `ProviderConfig` 扩展;`create_provider()` 超时注入;`from_env()` + 单元测试 | +| **代码量** | ~60 行 + 测试 | + +## 3. 方案设计 + +### 3.1 Step 5.2 — Ollama Provider(先执行) + +#### 改动文件清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `src/llm/provider/ollama.rs` | **新建** | OllamaProvider newtype 包装 | +| `src/llm/provider.rs` | 修改 | `ProviderType` 新增 `Ollama` 变体;`FromStr` 加解析;`create_provider()` 加分支 | +| `src/llm/provider/mod.rs` 或其他模块注册文件 | 修改(如需要) | 注册 `pub mod ollama` | + +#### 关键代码 + +**`src/llm/provider/ollama.rs`**(新建): + +```rust +//! Ollama Provider —— OpenAI-compatible 协议的 newtype 包装,零 API key。 +//! +//! 默认 base_url = `http://localhost:11434/v1`,空 api_key 也可工作。 +//! 实现方式同 DeepSeekProvider / QwenProvider,共享 GenericOpenaiProvider 的 HTTP/SSE/转换逻辑。 + +use reqwest::Client; +use std::pin::Pin; + +use async_trait::async_trait; +use futures_core::Stream; + +use super::openai::GenericOpenaiProvider; +use super::{LlmProvider, ProviderCapabilities}; +use crate::llm::error::LlmError; +use crate::llm::types::request_v2::MessageRequest; +use crate::llm::types::response_v2::{MessageResponse, StreamEvent}; + +pub struct OllamaProvider(pub GenericOpenaiProvider); + +impl OllamaProvider { + pub fn new(base_url: String, api_key: String, model: String) -> Self { + let url = if base_url.is_empty() { + "http://localhost:11434/v1".to_string() + } else { + base_url + }; + Self(GenericOpenaiProvider::new_with_name( + url, + api_key, + model, + "ollama", + )) + } + + /// 替换默认 HTTP Client(用于 timeout 注入等场景)。 + /// 与 `OpenaiChatProvider::with_client` 和 `DeepSeekProvider::with_client` 一致。 + pub fn with_client(self, client: Client) -> Self { + Self(self.0.with_client(client)) + } +} + +#[async_trait] +impl LlmProvider for OllamaProvider { + async fn chat(&self, request: MessageRequest) -> Result { + self.0.chat(request).await + } + + async fn chat_stream( + &self, + request: MessageRequest, + ) -> Result> + Send>>, LlmError> { + self.0.chat_stream(request).await + } + + fn capabilities(&self) -> ProviderCapabilities { + let mut caps = self.0.capabilities(); + caps.provider_name = "ollama"; + caps + } +} +``` + +**`src/llm/provider.rs`** 的修改: + +```rust +// ProviderType 新增变体 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderType { + OpenaiChat, + OpenaiResponse, + Anthropic, + DeepSeek, + Qwen, + /// Ollama(本地),默认 base_url = `http://localhost:11434/v1`。 + Ollama, +} + +// FromStr 加解析 +fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + // ... 已有条目 ... + "ollama" => Ok(ProviderType::Ollama), + _ => Err(format!("未知的 Provider 类型: {s}")), + } +} + +// create_provider() 加分支 +// Step 5.2 阶段仅展示基本构造。Step 5.1(ProviderConfig 扩展) +// 执行到此分支时,将同步补充 with_client 链式调用注入 timeout: +// +// let client = Client::builder() +// .timeout(Duration::from_secs(config.timeout_secs)) +// .build()?; +// Ok(Box::new( +// ollama::OllamaProvider::new(config.base_url, config.api_key, config.model) +// .with_client(client), +// )) +ProviderType::Ollama => Ok(Box::new(ollama::OllamaProvider::new( + config.base_url, + config.api_key, + config.model, +))), +``` + +#### 集成方式 + +OllamaProvider 的 newtype 包装模式与 `DeepSeekProvider`、`QwenProvider` 完全一致,`LlmProvider` trait 委托给 `self.0`。`capabilities().provider_name` 返回 `"ollama"`。 + +### 3.2 Step 5.3 — `#[non_exhaustive]` 前置标记 + +#### 改动文件清单 + +| 文件 | 行号 | 枚举 | 操作 | +|------|------|------|------| +| `src/llm/provider.rs` | ~21 | `ProviderType` | 加 `#[non_exhaustive]` | +| `src/llm/types/response_v2.rs` | ~22 | `StopReason` | 加 `#[non_exhaustive]` | +| `src/llm/types/shared.rs` | ~16 | `FinishReason` | 加 `#[non_exhaustive]` | +| `src/memory/store.rs` | ~35 | `EvictionPolicy` | 加 `#[non_exhaustive]` | + +**排除清单**:`SlotMode`。 + +**决策理由**:`SlotMode` 枚举在 Phase 10(`src/llm/context.rs`)中才实际定义,Phase 5 尚不存在此类型。`#[non_exhaustive]` 无法标注不存在的枚举,因此排除标注。Roadmap(v0.2.0 §Phase 5 Step 5.3)列出的 `SlotMode`(预置) 推迟到 Phase 10 实现时一并添加。 + +#### 关键代码 + +每个枚举在 `derive` 上方或下方加一行属性: + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum ProviderType { + // ... +} +``` + +#### 影响分析 + +- `#[non_exhaustive]` 是纯编译期属性,不影响运行时行为 +- 同一 crate 内的 exhaustive match 不受影响(同 crate 可穷举) +- 下游 crate 的 match 必须加 `_ =>` 兜底分支,这是期望行为——确保未来新增变体时不会 silent break +- **单向门**:此步骤一旦通过 `v0.2.0` 发布到公共 API 后,**不可回退**。回退意味着移除 `#[non_exhaustive]`,可能破坏已添加 `_ =>` 的下游代码。因此必须在发布前完成并确认所有枚举变体正确 + +### 3.3 Step 5.1 — ProviderConfig 扩展(最后执行) + +#### 改动文件清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `src/llm/provider.rs` | 修改 | `ProviderConfig` 加字段;加 `impl Default`;加 `from_env()`;`create_provider` 注入 timeout | +| `src/llm/provider/openai.rs` | 修改 | `GenericOpenaiProvider` 新增 `timeout_secs` 字段;`new_with_name` 接受 timeout 参数;`map_reqwest_error` 参数化 | +| `src/llm/provider/anthropic.rs` | 修改 | 新增 `timeout_secs` 字段;`new()` 接受 timeout 参数;`map_reqwest_error` 参数化 | +| `src/llm/provider/anthropic.rs` | 修改 | 新增 `with_timeout()` 方法(返回 `Result`) | +| `src/llm/provider/openai_compat.rs` | 修改 | `DeepSeekProvider` 和 `QwenProvider` 新增公开 `with_client()` 方法 | +| `src/llm/provider/ollama.rs` | 修改 | `OllamaProvider` 新增公开 `with_client()` 方法 | +| `Cargo.toml` | 修改 | 加 `temp_env` dev-dependency | +| 测试文件(`provider.rs` 内联或独立) | 新增 | `from_env` 单元测试 + timeout 传导集成测试 | + +#### 数据结构 + +```rust +/// Provider 构造参数 —— 通用 base_url + api_key + model + timeout/retry 配置。 +pub struct ProviderConfig { + pub base_url: String, + pub api_key: String, + pub model: String, + /// 请求超时秒数(默认 30)。应用于 Provider 的 HTTP Client 级别。 + pub timeout_secs: u64, + /// 最大重试次数(默认 3)。当前此字段仅由 `from_env()` 采集, + /// 实际重试逻辑由 `CycleConfig.retry.max_retries` 控制。 + /// 未来可合并到统一的 retry 配置。 + pub max_retries: u32, +} + +impl Default for ProviderConfig { + fn default() -> Self { + Self { + base_url: String::new(), + api_key: String::new(), + model: String::new(), + timeout_secs: 30, + max_retries: 3, + } + } +} + +impl ProviderConfig { + /// 从环境变量构造 ProviderConfig。 + /// + /// 必填变量: + /// - `{prefix}_BASE_URL` + /// - `{prefix}_API_KEY` + /// - `{prefix}_MODEL` + /// + /// 可选变量(有默认值): + /// - `{prefix}_TIMEOUT_SECS`(默认 30) + /// - `{prefix}_MAX_RETRIES`(默认 3) + pub fn from_env(prefix: &str) -> Result { + let base_url = std::env::var(format!("{prefix}_BASE_URL")) + .map_err(|_| format!("{prefix}_BASE_URL 环境变量未设置"))?; + let api_key = std::env::var(format!("{prefix}_API_KEY")) + .map_err(|_| format!("{prefix}_API_KEY 环境变量未设置"))?; + let model = std::env::var(format!("{prefix}_MODEL")) + .map_err(|_| format!("{prefix}_MODEL 环境变量未设置"))?; + let timeout_secs = match std::env::var(format!("{prefix}_TIMEOUT_SECS")) { + Ok(v) => v.parse().unwrap_or_else(|_| { + tracing::warn!("{prefix}_TIMEOUT_SECS='{v}' 解析失败,使用默认值 30"); + 30 + }), + Err(_) => 30, + }; + let max_retries = match std::env::var(format!("{prefix}_MAX_RETRIES")) { + Ok(v) => v.parse().unwrap_or_else(|_| { + tracing::warn!("{prefix}_MAX_RETRIES='{v}' 解析失败,使用默认值 3"); + 3 + }), + Err(_) => 3, + }; + + // ponytail: max_retries 当前仅采集,不传入 Provider。 + // 实际重试由 CycleConfig.retry.max_retries 控制。 + // 此 warn 在应用启动时通常只触发一次,多次调用 from_env 时 + // 重复输出的风险低。如有噪声,可改用 std::sync::Once 控制。 + if max_retries != 3 { + tracing::warn!( + "ProviderConfig.max_retries={} 已采集但当前未生效;\ + 重试次数由 CycleConfig.retry.max_retries 控制", + max_retries, + ); + } + + Ok(Self { + base_url, + api_key, + model, + timeout_secs, + max_retries, + }) + } +} +``` + +#### Timeout 传导模式 + +在 `create_provider()` 中,对基于 `GenericOpenaiProvider` 的 Provider(OpenAI / DeepSeek / Qwen / Ollama),通过同一模式注入 timeout:构造带 timeout 的 `Client` 后调用 `with_client(client)`。 + +所有 OpenAI-compatible 分支新增的 `with_client()` 公开方法: + +| Provider | 方法 | 位置 | +|----------|------|------| +| `OpenaiChatProvider` | 已有 `with_client(Client) -> Self` | `openai.rs` | +| `DeepSeekProvider` | 新增 `with_client(Client) -> Self` | `openai_compat.rs` | +| `QwenProvider` | 新增 `with_client(Client) -> Self` | `openai_compat.rs` | +| `OllamaProvider` | 新增 `with_client(Client) -> Self` | `ollama.rs`(新建文件) | + +**关于 `new_with_client` 的说明**:`DeepSeekProvider` 和 `QwenProvider` 当前已有测试用的 `new_with_client(base_url, api_key, model, client)` 方法(通过 `inner.http_client = client` 直接写字段)。新增 `with_client` 后,`new_with_client` 应重构为 `Self::new(base_url, api_key, model).with_client(client)` 代理,统一走公开 API 路径。 + +代码示例(以 DeepSeek 为例,OpenAI/Qwen/Ollama 模式完全一致): + +```rust +ProviderType::DeepSeek => { + let client = Client::builder() + .timeout(Duration::from_secs(config.timeout_secs)) + .build() + .map_err(|e| LlmError::Other(format!("创建 HTTP 客户端失败: {e}")))?; + Ok(Box::new( + openai_compat::DeepSeekProvider::new( + config.base_url, + config.api_key, + config.model, + ) + .with_client(client), + )) +} +``` + +Anthropic 由于需要保留 `default_headers`,使用独立的 `with_timeout` 模式: + +AnthropicProvider 新增 `with_timeout` 方法: + +```rust +impl AnthropicProvider { + /// 替换默认 HTTP Client 的超时配置。 + /// + /// ⚠️ 副作用:此方法**完全重建** `http_client`,调用后原有通过 `with_client` + /// 注入的 Client 将被替换。headers 逻辑与 `new()` 中的构造保持一致。 + pub fn with_timeout(mut self, secs: u64) -> Result { + // ponytail: 重建 http_client 时保留已有默认 headers(x-api-key / anthropic-version)。 + // 如后续 AnthropicProvider 的 headers 变为动态,此方法需同步更新。 + let key_header = HeaderValue::from_str(&self.api_key) + .map_err(|_| LlmError::Other("Anthropic API key 包含无效的 HTTP 头部字符".into()))?; + let version_header = HeaderValue::from_static("2023-06-01"); + + self.http_client = Client::builder() + .timeout(Duration::from_secs(secs)) + .default_headers({ + let mut headers = HeaderMap::new(); + headers.insert("x-api-key", key_header); + headers.insert("anthropic-version", version_header); + headers + }) + .build() + .map_err(|e| LlmError::Other(format!("创建 Anthropic HTTP 客户端失败: {e}")))?; + Ok(self) + } +} +``` + +#### `map_reqwest_error` 中的硬编码超时修复 + +`openai.rs` 和 `anthropic.rs` 中的 `map_reqwest_error` 辅助函数当前在超时错误中返回硬编码的 `Duration::from_secs(120)`: + +```rust +// 现状 —— 硬编码 120s,与可配置 timeout 脱节 +LlmError::Timeout { duration: Duration::from_secs(120) } +``` + +**修复方式**:采用**方案 A**——在 Provider struct 中存储 `timeout_secs` 字段,`map_reqwest_error` 读取该字段的值而非硬编码 120s。 + +```rust +// 修复后 —— 参数化,从 Provider 存储的 timeout_secs 读取 +// GenericOpenaiProvider 新增 timeout_secs 字段: +pub struct GenericOpenaiProvider { + http_client: Client, + base_url: String, + api_key: String, + model: String, + provider_name: &'static str, + extra_headers: Vec<(String, String)>, + timeout_secs: u64, // ← 新增,由 new_with_name 的参数传入 +} + +// map_reqwest_error 使用 self.timeout_secs 而非硬编码 120: +LlmError::Timeout { duration: Duration::from_secs(self.timeout_secs) } +``` + +**方案 B(从 reqwest::Client 提取 timeout)已被否决**:`reqwest::Client` 不提供 timeout getter,无法从已构造的 client 中反向读取超时配置。 + +如果漏掉此修复,用户设置 `AG_LLM_TIMEOUT_SECS=60` 后超时,错误消息仍显示 "LLM 请求超时(120s)",与实际配置不符。 + +--- + +#### max_retries 说明 + +`ProviderConfig.max_retries` 当前仅由 `from_env()` 采集存储,**实际重试操作由 `CycleConfig.retry.max_retries` 控制**。两者之间的关系通过文档注释声明: + +```rust +/// 最大重试次数(默认 3)。当前此字段仅由 `from_env()` 采集, +/// 实际重试逻辑由 `CycleConfig.retry.max_retries` 控制。 +/// 未来 Phase 6+ 可统一合并此字段到 CycleConfig。 +``` + +#### 测试设计 + +使用 `temp_env` 在单元测试中隔离环境变量: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_config_from_env_requires_all_vars() { + // 未设置任何变量时应返回 Err + let result = ProviderConfig::from_env("TEST_PROVIDER"); + assert!(result.is_err()); + } + + #[test] + fn provider_config_from_env_uses_defaults() { + temp_env::with_vars([ + ("TEST_PROVIDER_BASE_URL", Some("http://localhost:11434/v1")), + ("TEST_PROVIDER_API_KEY", Some("")), + ("TEST_PROVIDER_MODEL", Some("llama3")), + ], || { + let config = ProviderConfig::from_env("TEST_PROVIDER").unwrap(); + assert_eq!(config.timeout_secs, 30); + assert_eq!(config.max_retries, 3); + }); + } + + #[test] + fn provider_config_from_env_reads_custom_timeout() { + temp_env::with_vars([ + ("TEST_PROVIDER_BASE_URL", Some("http://x")), + ("TEST_PROVIDER_API_KEY", Some("k")), + ("TEST_PROVIDER_MODEL", Some("m")), + ("TEST_PROVIDER_TIMEOUT_SECS", Some("60")), + ("TEST_PROVIDER_MAX_RETRIES", Some("5")), + ], || { + let config = ProviderConfig::from_env("TEST_PROVIDER").unwrap(); + assert_eq!(config.timeout_secs, 60); + assert_eq!(config.max_retries, 5); + }); + } +} +``` + +## 4. 实现计划 + +### Step 5.2 — Ollama Provider(~55 行) + +| 步骤 | 操作 | 验证 | +|------|------|------| +| 1 | 创建 `src/llm/provider/ollama.rs`,实现 `OllamaProvider` newtype | 编译通过 | +| 2 | 在 `provider.rs` 注册 `pub mod ollama` | 编译通过 | +| 3 | `ProviderType` 新增 `Ollama` 变体 | 编译通过 | +| 4 | `FromStr` 加 `"ollama"` 解析 | 编译通过 | +| 5 | `create_provider()` 加 `Ollama =>` 分支 | 编译通过 | +| 6 | 运行 `cargo build` | 无错误 | + +### Step 5.3 — `#[non_exhaustive]` 前置标记(~4 行) + +| 步骤 | 操作 | 验证 | +|------|------|------| +| 1 | `ProviderType`(`provider.rs`)加 `#[non_exhaustive]` | 编译通过 | +| 2 | `StopReason`(`response_v2.rs`)加 `#[non_exhaustive]` | 编译通过 | +| 3 | `FinishReason`(`shared.rs`)加 `#[non_exhaustive]` | 编译通过 | +| 4 | `EvictionPolicy`(`memory/store.rs`)加 `#[non_exhaustive]` | 编译通过 | +| 5 | 运行 `cargo build --all-targets` | 无 warning | + +### Step 5.1 — ProviderConfig 扩展(~60 行 + 测试) + +| 步骤 | 操作 | 验证 | +|------|------|------| +| 1 | `ProviderConfig` 加 `timeout_secs` / `max_retries` 字段 | 编译通过 | +| 2 | 实现 `impl Default for ProviderConfig` | 编译通过 | +| 3 | 实现 `ProviderConfig::from_env()` | 编译通过 | +| 4 | `GenericOpenaiProvider` 和 `AnthropicProvider` 新增 `timeout_secs` 字段,`new_with_name`/`new()` 接受 timeout 参数 | 编译通过 | +| 5 | `map_reqwest_error` 在各 Provider 中改为从 `self.timeout_secs` 读取,移除硬编码 120s | 编译通过 | +| 6 | `create_provider()` 中各分支注入 timeout(OpenAI-compatible 用 `Client::builder().timeout()` + `with_client`;Anthropic 用 `with_timeout()`) | 编译通过 | +| 7 | `DeepSeekProvider`/`QwenProvider` 的 `new_with_client` 重构为 `Self::new(...).with_client(client)` 代理 | 测试通过 | +| 8 | `Cargo.toml` 添加 `temp_env` dev-dependency | `cargo build` 通过 | +| 8 | 添加 `from_env` 单元测试 + timeout 传导集成测试 | `cargo test` 通过 | +| 9 | 完整验证 | 见第 6 节 | + +## 5. 风险评估 + +| 风险 | 影响 | 概率 | 缓解措施 | +|------|------|------|----------| +| `create_provider()` 中 `Client::builder().build()` 返回 `Result`,当前代码使用 `.expect()`,改为 `map_err` 转为 `LlmError` 后需确保所有分支正确转换 | 编译期强制处理,遗漏分支直接报错 | 低 | `create_provider` 返回 `Result, LlmError>`,`map_err` 天然适配。新增的 timeout 注入路径逐一检查 | +| `AnthropicProvider` 的 `default_headers` 在 `with_timeout` 中重建时与 `new()` 中的 headers 不一致 | Anthropic 认证失败 | 低 | `with_timeout` 方法复制 `new()` 中的 headers 构造逻辑。通过已有测试验证认证通过 | +| Ollama 实际运行时行为差异:版本兼容性、API 路径、模型名等 | 运行时才能发现 | 中 | Phase 5 仅做类型级验证(`cargo build`),Phase 8 端到端测试时通过 Ollama mock 或真实实例验证 | +| `max_retries` 存储了却未实际使用,造成困惑 | 开发者误以为已生效 | 中 | 通过文档注释明确声明 `max_retries` 当前仅采集,实际重试由 `CycleConfig.retry.max_retries` 控制 | +| `temp_env` 测试在多线程并发测试中互相污染环境变量 | 偶发测试失败 | 中(Rust 默认单线程测试用 `--test-threads=1` 可避免) | 将 `from_env` 测试控制在同一测试文件,避免并行执行。必要时在 CI 中确保 `--test-threads=1` | + +## 6. 验收标准 + +以下条件**全部满足**方可认为 Phase 5 完成: + +- [ ] `cargo build --all-targets` 通过,无错误 +- [ ] `cargo test --all-targets` 通过,新增测试覆盖 `from_env` 的必填/选填/默认值场景 +- [ ] `cargo clippy --all-targets -- -D warnings` 通过,无任何 warning +- [ ] `cargo doc --no-deps -D warnings` 通过,所有公共 API 有文档注释(`///`) +- [ ] 新增文件:1(`ollama.rs`) +- [ ] 修改文件:9(`provider.rs`、`openai.rs`、`anthropic.rs`、`openai_compat.rs`、`response_v2.rs`、`shared.rs`、`store.rs`、`Cargo.toml`、测试文件) +- [ ] 净代码增量:~160 行 +- [ ] `ProviderType` 新增 `Ollama` 变体,`"ollama"` 字符串可解析 +- [ ] 4 个公共枚举带有 `#[non_exhaustive]` 属性 +- [ ] `ProviderConfig` 可从环境变量构造(`from_env()`),含默认值 +- [ ] timeout 值已传导到 `create_provider()` 中各 Provider 的 HTTP Client 配置 +- [ ] timeout 传导验证通过至少一个端到端 wiremock 集成测试(模拟 HTTP 服务在超时后返回 408,验证 Provider 返回 `LlmError::Timeout`) +- [ ] `DeepSeekProvider`、`QwenProvider`、`OllamaProvider` 均有公开 `with_client()` 方法,可在 `create_provider` 中注入 timeout Client +- [ ] `map_reqwest_error` 中不再硬编码 `Duration::from_secs(120)`,改为参数化读取