16 Commits
Author SHA1 Message Date
徐涛 9e476e79bb merge: 合并 release/v0.1 全部修改(4 个 commit 含 v0.2 路线图与 Phase 5 实施方案) 2026-07-05 07:32:23 +08:00
徐涛 3bd135ec98 docs(roadmap): 添加 Phase 5 warmup 实施方案
明确 Step 5.2 Ollama Provider、Step 5.3 non_exhaustive 前置标记、
Step 5.1 ProviderConfig 扩展三个独立 Step 的执行顺序与交付物
2026-07-05 07:32:19 +08:00
徐涛 76f3235ed7 docs(roadmap): 更新 v0.2 规划为 8 个增量 Phase 并细化实施步骤 2026-07-04 10:32:24 +08:00
徐涛 6315f2d008 docs: 添加 opencode 子代理调度分发与合并机制调研笔记 2026-07-04 10:19:54 +08:00
徐涛 fba78f5f33 docs(roadmap): 更新路线图为 v0.2 生产就绪规划
同步 v0.1.0 发布状态,将 v0.2+ 扩展项重
组为 12 项基础功能和 ContextSlot 上下文管
理,明确 v0.3+ 展望及边界范围
2026-07-04 08:11:09 +08:00
徐涛 3c1a3ee62e docs: 修正 v0.1 release URL 到实际 remote
CHANGELOG.md:91 的对比链接从 github.com/anomalyco/agcore
改为实际 remote git.archgrid.xyz/xt/agcore(Gitea 1.26.1),
避免 tag 发布后 CHANGELOG 链接 404
2026-07-04 07:07:01 +08:00
徐涛 8dcd1f2482 docs(v0.1): 同步 roadmap 并初始化 CHANGELOG
- docs/roadmap.md:更新顶部状态至 v0.1 发布完成(182 测试 / 0 警告 / 7 离线示例),末尾追加 v0.1 发布里程碑章节
- CHANGELOG.md:按 Keep a Changelog 规范记录 0.1.0 的 Added / Changed / Deprecated / Fixed / Removed 及版本基线
2026-07-04 06:54:13 +08:00
徐涛 602bd43fce fix(llm,docs): 修复 Phase C 审查发现的两个警告
- 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 的联合审查结论
2026-07-04 06:35:21 +08:00
徐涛 a52588cd0f docs: 完善 README 完整版
- 8 节结构:定位 / 快速上手 / 核心模块 / 架构图 / 依赖关系 / 环境变量 / 参考项目 / 许可证
- 快速上手代码段(MockProvider)已验证在外部 crate 离线可编译运行
- 核心模块表覆盖五大模块(llm / prompt / tools / memory / agent)
- ASCII 架构关系图 + Mermaid 模块依赖关系图 + 依赖规则说明
- 环境变量表列出 simple_visit 示例所需变量与推荐值
- 参考项目章节引用 4 个公开 Agent Harness 项目(OpenClaw / Hermes / OpenHuman / OpenHarness)
- 许可证章节同步改为 Apache-2.0 单协议,附标准 boilerplate 并指向 LICENSE 文件
2026-07-04 06:21:36 +08:00
徐涛 4fdc62754c feat(errors): 错误消息面向最终用户友好化
- memory::MemoryError 英文 → 中文 + 操作建议(namespace 拼写 / 后端存储 / 数据兼容性)
- llm::LlmError 各变体补充修复建议(API key / 超时 / 上下文压缩)
- tools::ToolError 各变体补充修复指引(注册工具 / 检查 Schema / 调权限 / MCP 握手)
- prompt::PromptError 各变体补充修复指引(注册子模板 / 检查语法 / 调深度限制)
- agent::AgentError::Config 中文化 + 提示需调用的 builder 方法名
2026-07-04 06:21:31 +08:00
徐涛 9f5e8702a2 chore(license): 添加 Apache-2.0 标准 LICENSE 文件
- 新增 LICENSE(Apache License 2.0 全文,Copyright 2026 AG Core Contributors)
- 项目由双协议改为 Apache-2.0 单协议
2026-07-04 06:21:24 +08:00
徐涛 2d0d5c1592 feat(examples): 新增 Phase B 7 个离线可运行示例
新增 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 事件消费 + 队列耗尽错误路径
2026-07-03 15:50:51 +08:00
徐涛 7b2d2db322 feat(llm): 公开 MockProvider 并修复测试与 API 一致性
- 新增 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 对齐公开路径
2026-07-03 15:50:39 +08:00
徐涛 c084c57e2c refactor(core): 扫清 v0.1 Phase A 技术债
- 修复测试编译回归:补全 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 抑制、测试代码清理
2026-07-03 15:19:37 +08:00
徐涛 c2c0d498ee docs(v0.1): 添加发布实施计划文档 2026-07-03 14:13:30 +08:00
徐涛 e54edbc037 feat(agent): 移除未使用的导入并简化条件表达式 2026-07-03 13:55:24 +08:00
40 changed files with 3495 additions and 351 deletions
+91
View File
@@ -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、DeepSeekOpenAI 兼容)、QwenOpenAI 兼容)
- `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 transportStreamableHttp 已预留接口)
- `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` traitname / 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://git.archgrid.xyz/xt/agcore/releases/tag/v0.1.0
+201
View File
@@ -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.
+243 -1
View File
@@ -1,3 +1,245 @@
# 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 运行时
适合用来:
- 搭建支持多轮对话 + 工具调用的智能体服务
- 接入多家 LLMOpenAI / 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<dyn LlmProvider> =
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<dyn Agent> = 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 <name>`):
| 示例 | 说明 |
|------|------|
| `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["<b>llm</b><br/>Provider / Cycle /<br/>Hooks / Stream /<br/>Compact / Mock"]:::core
Prompt["<b>prompt</b><br/>Template / Composer"]:::core
Tool["<b>tools</b><br/>BaseTool / Registry /<br/>Permission / MCP"]:::core
Memory["<b>memory</b><br/>Store / Conversation /<br/>Knowledge / Retriever"]:::core
Agent["<b>agent</b><br/>Agent / Builder /<br/>Session / Plan"]:::core
Prompt --> LLM
Tool --> LLM
Memory --> LLM
Agent --> LLM
Agent --> Tool
Agent --> Memory
classDef core fill:#60a5fa,stroke:#2563eb,color:#fff
```
**依赖规则**
- `llm` 是叶子,被其他四个模块使用
- `prompt` / `tools` / `memory` 互相不依赖,可独立使用
- `agent` 编译期依赖 `llm` / `tools` / `memory`,与 `prompt` 无直接编译依赖(system prompt 以 `&str` 形式传入)
- 上层应用只应依赖 `agent` + 必要的子模块,不应跨层直接 `use`
## 环境变量
> AG Core 库本身不读环境变量。下表是 [`examples/simple_visit.rs`](./examples/simple_visit.rs) 这一真实调用示例所使用的环境变量,以及通常推荐的取值。
| 变量 | 必填 | 推荐值 | 说明 |
|------|------|-------|------|
| `OPENAI_API_KEY` | 用 OpenAI 时 | — | OpenAI / 兼容 ProviderDeepSeek / Qwen)的 API key |
| `OPENAI_BASE_URL` | 是 | `https://api.openai.com/v1` | OpenAI 兼容端点 base URLDeepSeek/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
```
+340
View File
@@ -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 个 errorsession.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<OpenaiChatMessage>``Vec<Message>`
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<OpenaiChatMessage>``Vec<Message>`
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<Vec<PageIndexEntry>>``tokio::sync::Mutex<Vec<PageIndexEntry>>`
**影响范围**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 A4clippy 警告清零(~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 B0MockProvider 公开化(~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<MessageResponse>) -> Self;
pub fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError>;
pub fn chat_stream(&self, request: MessageRequest)
-> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + 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 B0MockProvider 需支持 `chat_stream` |
**验收条件**:额外 3 个示例均可 `cargo run` 成功退出
---
### Phase C — 开发者体验 + 文档(Week 2 后半,与 Phase B 后段并行)
#### Task C1README 完整版(~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 D1CI 示例验证集成(~0.5d
- 确认 `cargo build` 无新增警告
- 确认 `cargo test` 全部通过(退出码 0
- 确认 `cargo test --examples` 全部通过
- 确认 `cargo clippy --lib -p agcore` 0 警告
#### Task D2Roadmap 更新(~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 D4CHANGELOG 初始化(~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` 测试计数和状态与代码一致
+522
View File
@@ -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 8MVP 出口)依赖 from_env |
| **预期交付物** | `ProviderConfig` 扩展;`create_provider()` 超时注入;`from_env()` + 单元测试 |
| **代码量** | ~60 行 + 测试 |
## 3. 方案设计
### 3.1 Step 5.2 — Ollama Provider(先执行)
#### 改动文件清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `src/llm/provider/ollama.rs` | **新建** | OllamaProvider newtype 包装 |
| `src/llm/provider.rs` | 修改 | `ProviderType` 新增 `Ollama` 变体;`FromStr` 加解析;`create_provider()` 加分支 |
| `src/llm/provider/mod.rs` 或其他模块注册文件 | 修改(如需要) | 注册 `pub mod ollama` |
#### 关键代码
**`src/llm/provider/ollama.rs`**(新建):
```rust
//! Ollama Provider —— OpenAI-compatible 协议的 newtype 包装,零 API key。
//!
//! 默认 base_url = `http://localhost:11434/v1`,空 api_key 也可工作。
//! 实现方式同 DeepSeekProvider / QwenProvider,共享 GenericOpenaiProvider 的 HTTP/SSE/转换逻辑。
use reqwest::Client;
use std::pin::Pin;
use async_trait::async_trait;
use futures_core::Stream;
use super::openai::GenericOpenaiProvider;
use super::{LlmProvider, ProviderCapabilities};
use crate::llm::error::LlmError;
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::response_v2::{MessageResponse, StreamEvent};
pub struct OllamaProvider(pub GenericOpenaiProvider);
impl OllamaProvider {
pub fn new(base_url: String, api_key: String, model: String) -> Self {
let url = if base_url.is_empty() {
"http://localhost:11434/v1".to_string()
} else {
base_url
};
Self(GenericOpenaiProvider::new_with_name(
url,
api_key,
model,
"ollama",
))
}
/// 替换默认 HTTP Client(用于 timeout 注入等场景)。
/// 与 `OpenaiChatProvider::with_client` 和 `DeepSeekProvider::with_client` 一致。
pub fn with_client(self, client: Client) -> Self {
Self(self.0.with_client(client))
}
}
#[async_trait]
impl LlmProvider for OllamaProvider {
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError> {
self.0.chat(request).await
}
async fn chat_stream(
&self,
request: MessageRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
self.0.chat_stream(request).await
}
fn capabilities(&self) -> ProviderCapabilities {
let mut caps = self.0.capabilities();
caps.provider_name = "ollama";
caps
}
}
```
**`src/llm/provider.rs`** 的修改:
```rust
// ProviderType 新增变体
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderType {
OpenaiChat,
OpenaiResponse,
Anthropic,
DeepSeek,
Qwen,
/// Ollama(本地),默认 base_url = `http://localhost:11434/v1`。
Ollama,
}
// FromStr 加解析
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
// ... 已有条目 ...
"ollama" => Ok(ProviderType::Ollama),
_ => Err(format!("未知的 Provider 类型: {s}")),
}
}
// create_provider() 加分支
// Step 5.2 阶段仅展示基本构造。Step 5.1ProviderConfig 扩展)
// 执行到此分支时,将同步补充 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]` 无法标注不存在的枚举,因此排除标注。Roadmapv0.2.0 §Phase 5 Step 5.3)列出的 `SlotMode`(预置) 推迟到 Phase 10 实现时一并添加。
#### 关键代码
每个枚举在 `derive` 上方或下方加一行属性:
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ProviderType {
// ...
}
```
#### 影响分析
- `#[non_exhaustive]` 是纯编译期属性,不影响运行时行为
- 同一 crate 内的 exhaustive match 不受影响(同 crate 可穷举)
- 下游 crate 的 match 必须加 `_ =>` 兜底分支,这是期望行为——确保未来新增变体时不会 silent break
- **单向门**:此步骤一旦通过 `v0.2.0` 发布到公共 API 后,**不可回退**。回退意味着移除 `#[non_exhaustive]`,可能破坏已添加 `_ =>` 的下游代码。因此必须在发布前完成并确认所有枚举变体正确
### 3.3 Step 5.1 — ProviderConfig 扩展(最后执行)
#### 改动文件清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `src/llm/provider.rs` | 修改 | `ProviderConfig` 加字段;加 `impl Default`;加 `from_env()``create_provider` 注入 timeout |
| `src/llm/provider/openai.rs` | 修改 | `GenericOpenaiProvider` 新增 `timeout_secs` 字段;`new_with_name` 接受 timeout 参数;`map_reqwest_error` 参数化 |
| `src/llm/provider/anthropic.rs` | 修改 | 新增 `timeout_secs` 字段;`new()` 接受 timeout 参数;`map_reqwest_error` 参数化 |
| `src/llm/provider/anthropic.rs` | 修改 | 新增 `with_timeout()` 方法(返回 `Result<Self, LlmError>` |
| `src/llm/provider/openai_compat.rs` | 修改 | `DeepSeekProvider``QwenProvider` 新增公开 `with_client()` 方法 |
| `src/llm/provider/ollama.rs` | 修改 | `OllamaProvider` 新增公开 `with_client()` 方法 |
| `Cargo.toml` | 修改 | 加 `temp_env` dev-dependency |
| 测试文件(`provider.rs` 内联或独立) | 新增 | `from_env` 单元测试 + timeout 传导集成测试 |
#### 数据结构
```rust
/// Provider 构造参数 —— 通用 base_url + api_key + model + timeout/retry 配置。
pub struct ProviderConfig {
pub base_url: String,
pub api_key: String,
pub model: String,
/// 请求超时秒数(默认 30)。应用于 Provider 的 HTTP Client 级别。
pub timeout_secs: u64,
/// 最大重试次数(默认 3)。当前此字段仅由 `from_env()` 采集,
/// 实际重试逻辑由 `CycleConfig.retry.max_retries` 控制。
/// 未来可合并到统一的 retry 配置。
pub max_retries: u32,
}
impl Default for ProviderConfig {
fn default() -> Self {
Self {
base_url: String::new(),
api_key: String::new(),
model: String::new(),
timeout_secs: 30,
max_retries: 3,
}
}
}
impl ProviderConfig {
/// 从环境变量构造 ProviderConfig。
///
/// 必填变量:
/// - `{prefix}_BASE_URL`
/// - `{prefix}_API_KEY`
/// - `{prefix}_MODEL`
///
/// 可选变量(有默认值):
/// - `{prefix}_TIMEOUT_SECS`(默认 30
/// - `{prefix}_MAX_RETRIES`(默认 3
pub fn from_env(prefix: &str) -> Result<Self, String> {
let base_url = std::env::var(format!("{prefix}_BASE_URL"))
.map_err(|_| format!("{prefix}_BASE_URL 环境变量未设置"))?;
let api_key = std::env::var(format!("{prefix}_API_KEY"))
.map_err(|_| format!("{prefix}_API_KEY 环境变量未设置"))?;
let model = std::env::var(format!("{prefix}_MODEL"))
.map_err(|_| format!("{prefix}_MODEL 环境变量未设置"))?;
let timeout_secs = match std::env::var(format!("{prefix}_TIMEOUT_SECS")) {
Ok(v) => v.parse().unwrap_or_else(|_| {
tracing::warn!("{prefix}_TIMEOUT_SECS='{v}' 解析失败,使用默认值 30");
30
}),
Err(_) => 30,
};
let max_retries = match std::env::var(format!("{prefix}_MAX_RETRIES")) {
Ok(v) => v.parse().unwrap_or_else(|_| {
tracing::warn!("{prefix}_MAX_RETRIES='{v}' 解析失败,使用默认值 3");
3
}),
Err(_) => 3,
};
// ponytail: max_retries 当前仅采集,不传入 Provider。
// 实际重试由 CycleConfig.retry.max_retries 控制。
// 此 warn 在应用启动时通常只触发一次,多次调用 from_env 时
// 重复输出的风险低。如有噪声,可改用 std::sync::Once 控制。
if max_retries != 3 {
tracing::warn!(
"ProviderConfig.max_retries={} 已采集但当前未生效;\
重试次数由 CycleConfig.retry.max_retries 控制",
max_retries,
);
}
Ok(Self {
base_url,
api_key,
model,
timeout_secs,
max_retries,
})
}
}
```
#### Timeout 传导模式
`create_provider()` 中,对基于 `GenericOpenaiProvider` 的 ProviderOpenAI / DeepSeek / Qwen / Ollama),通过同一模式注入 timeout:构造带 timeout 的 `Client` 后调用 `with_client(client)`
所有 OpenAI-compatible 分支新增的 `with_client()` 公开方法:
| Provider | 方法 | 位置 |
|----------|------|------|
| `OpenaiChatProvider` | 已有 `with_client(Client) -> Self` | `openai.rs` |
| `DeepSeekProvider` | 新增 `with_client(Client) -> Self` | `openai_compat.rs` |
| `QwenProvider` | 新增 `with_client(Client) -> Self` | `openai_compat.rs` |
| `OllamaProvider` | 新增 `with_client(Client) -> Self` | `ollama.rs`(新建文件) |
**关于 `new_with_client` 的说明**`DeepSeekProvider``QwenProvider` 当前已有测试用的 `new_with_client(base_url, api_key, model, client)` 方法(通过 `inner.http_client = client` 直接写字段)。新增 `with_client` 后,`new_with_client` 应重构为 `Self::new(base_url, api_key, model).with_client(client)` 代理,统一走公开 API 路径。
代码示例(以 DeepSeek 为例,OpenAI/Qwen/Ollama 模式完全一致):
```rust
ProviderType::DeepSeek => {
let client = Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.build()
.map_err(|e| LlmError::Other(format!("创建 HTTP 客户端失败: {e}")))?;
Ok(Box::new(
openai_compat::DeepSeekProvider::new(
config.base_url,
config.api_key,
config.model,
)
.with_client(client),
))
}
```
Anthropic 由于需要保留 `default_headers`,使用独立的 `with_timeout` 模式:
AnthropicProvider 新增 `with_timeout` 方法:
```rust
impl AnthropicProvider {
/// 替换默认 HTTP Client 的超时配置。
///
/// ⚠️ 副作用:此方法**完全重建** `http_client`,调用后原有通过 `with_client`
/// 注入的 Client 将被替换。headers 逻辑与 `new()` 中的构造保持一致。
pub fn with_timeout(mut self, secs: u64) -> Result<Self, LlmError> {
// ponytail: 重建 http_client 时保留已有默认 headersx-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()` 中各分支注入 timeoutOpenAI-compatible 用 `Client::builder().timeout()` + `with_client`Anthropic 用 `with_timeout()` | 编译通过 |
| 7 | `DeepSeekProvider``QwenProvider``new_with_client` 重构为 `Self::new(...).with_client(client)` 代理 | 测试通过 |
| 8 | `Cargo.toml` 添加 `temp_env` dev-dependency | `cargo build` 通过 |
| 8 | 添加 `from_env` 单元测试 + timeout 传导集成测试 | `cargo test` 通过 |
| 9 | 完整验证 | 见第 6 节 |
## 5. 风险评估
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|----------|
| `create_provider()``Client::builder().build()` 返回 `Result`,当前代码使用 `.expect()`,改为 `map_err` 转为 `LlmError` 后需确保所有分支正确转换 | 编译期强制处理,遗漏分支直接报错 | 低 | `create_provider` 返回 `Result<Box<dyn LlmProvider>, LlmError>``map_err` 天然适配。新增的 timeout 注入路径逐一检查 |
| `AnthropicProvider``default_headers``with_timeout` 中重建时与 `new()` 中的 headers 不一致 | Anthropic 认证失败 | 低 | `with_timeout` 方法复制 `new()` 中的 headers 构造逻辑。通过已有测试验证认证通过 |
| Ollama 实际运行时行为差异:版本兼容性、API 路径、模型名等 | 运行时才能发现 | 中 | Phase 5 仅做类型级验证(`cargo build`),Phase 8 端到端测试时通过 Ollama mock 或真实实例验证 |
| `max_retries` 存储了却未实际使用,造成困惑 | 开发者误以为已生效 | 中 | 通过文档注释明确声明 `max_retries` 当前仅采集,实际重试由 `CycleConfig.retry.max_retries` 控制 |
| `temp_env` 测试在多线程并发测试中互相污染环境变量 | 偶发测试失败 | 中(Rust 默认单线程测试用 `--test-threads=1` 可避免) | 将 `from_env` 测试控制在同一测试文件,避免并行执行。必要时在 CI 中确保 `--test-threads=1` |
## 6. 验收标准
以下条件**全部满足**方可认为 Phase 5 完成:
- [ ] `cargo build --all-targets` 通过,无错误
- [ ] `cargo test --all-targets` 通过,新增测试覆盖 `from_env` 的必填/选填/默认值场景
- [ ] `cargo clippy --all-targets -- -D warnings` 通过,无任何 warning
- [ ] `cargo doc --no-deps -D warnings` 通过,所有公共 API 有文档注释(`///`
- [ ] 新增文件:1`ollama.rs`
- [ ] 修改文件:9`provider.rs``openai.rs``anthropic.rs``openai_compat.rs``response_v2.rs``shared.rs``store.rs``Cargo.toml`、测试文件)
- [ ] 净代码增量:~160 行
- [ ] `ProviderType` 新增 `Ollama` 变体,`"ollama"` 字符串可解析
- [ ] 4 个公共枚举带有 `#[non_exhaustive]` 属性
- [ ] `ProviderConfig` 可从环境变量构造(`from_env()`),含默认值
- [ ] timeout 值已传导到 `create_provider()` 中各 Provider 的 HTTP Client 配置
- [ ] timeout 传导验证通过至少一个端到端 wiremock 集成测试(模拟 HTTP 服务在超时后返回 408,验证 Provider 返回 `LlmError::Timeout`
- [ ] `DeepSeekProvider``QwenProvider``OllamaProvider` 均有公开 `with_client()` 方法,可在 `create_provider` 中注入 timeout Client
- [ ] `map_reqwest_error` 中不再硬编码 `Duration::from_secs(120)`,改为参数化读取
+344
View File
@@ -0,0 +1,344 @@
# 笔记:opencode 子代理调度、分发与合并及工作流推进
> 基于 `/Users/midnite/Samples/opencode` 源码调研,2026-07-04
---
## 一、整体架构
```
LLM(主 Agent
├── 调用 Task tooltool call
│ ↓
│ TaskTool.execute() ← packages/opencode/src/tool/task.ts
│ │
│ ├── agent.get() ← 查找 Agent 定义(agent.ts
│ ├── deriveSubagentPermission() ← 权限合并(subagent-permissions.ts
│ ├── sessions.create() ← 创建子 session
│ │
│ ├── [前台] background.wait() + background.waitForPromotion() race
│ │ ↓ 完成
│ │ renderOutput() → XML <task> 标签返回
│ │
│ └── [后台] background.start() → notify() 异步注入结果
└── 会话循环(runLoop) ← prompt.ts
├── 检测 subtask type part → handleSubtask()
├── 检测 compaction → compaction.process()
└── 正常流程 → LLM.stream() → processor.handleEvent()
```
---
## 二、子代理调度(Dispatch
### 2.1 三种触发入口
| 入口 | 触发方式 | 调用链路 |
|------|---------|---------|
| A — LLM 自主 | LLM 调用 `task` tool | 系统提示词中注入了 Task tool 描述 + `describeTask()` 输出子代理列表 → LLM 决策 |
| B — `subtask` part | 消息中有 `type: "subtask"` 的 part | `handleSubtask()` 直接执行 TaskTool,不走 LLM |
| C — `agent` part | 消息中有 `type: "agent"` 的 part | 转为"调用 task tool 带 subagent: XXX"的提示词,引导 LLM |
### 2.2 TaskTool.execute() 完整流程(task.ts
```
execute(params, ctx):
1. background 开关检查(需 experimental flag
2. ctx.ask() 权限询问
3. agent.get(subagent_type) 查找子代理定义
4. task_id 存在 → sessions.get(task_id) 恢复已有子 session
task_id 不存在 → sessions.create() 创建新子 session
5. deriveSubagentSessionPermission() 合并权限
6. 添加默认 deny 规则(todowrite / task
7. 确定 model(继承或子代理自定义)
8. 执行 runTask() → ops.resolvePromptParts() + ops.prompt()
9. 结果格式化为 XML ← renderOutput()
```
### 2.3 关键:子 session 创建(task.ts lines 121-158
```typescript
// 权限继承
const childPermission = deriveSubagentSessionPermission({
parentSessionPermission: parent.permission ?? [],
subagent: next,
})
// 默认 deny 规则
const childToolDenies = [
// 子代理自己的 permission 没允许 todowrite → 默认 deny
...(next.permission.some(r => r.permission === "todowrite") ? []
: [{ permission: "todowrite", pattern: "*", action: "deny" }]),
// 子代理自己的 permission 没允许 task → 默认 deny(防嵌套)
...(next.permission.some(r => r.permission === "task") ? []
: [{ permission: "task", pattern: "*", action: "deny" }]),
// 主 agent 专有工具也不给子代理
...(cfg.experimental?.primary_tools?.map(p => ({ permission: p, ... })) ?? []),
]
```
---
## 三、通信格式:Tool Call / Tool Result
### 3.1 父→子:Task tool 参数
```
{
subagent_type: "explore" | "general" | ...,
description: "简短描述(3-5词)",
prompt: "子代理的完整任务描述",
task_id?: "恢复已有子 session 时使用",
command?: "触发该调用的 CLI 命令(可选)",
background?: true // 后台模式(需 experimental flag
}
```
### 3.2 子→父:XML 包装的纯文本(renderOutput
```xml
<task id="ses_xxxxx" state="completed">
<summary>任务简述</summary>
<task_result>
子 agent 输出的完整文本内容...
</task_result>
</task>
```
错误时:
```xml
<task id="ses_xxxxx" state="error">
<summary>任务失败</summary>
<task_error>
Error: 具体错误信息...
</task_error>
</task>
```
### 3.3 传递给 LLM 的方式
**前台模式**
```
TaskTool.execute() 返回 { output: "<task>...</task>" }
AI SDK 将其转为 tool result,存入数据库 tool part
下一轮 LLM 调用时,tool result 作为消息历史的一部分传入
LLM 看到 XML,自行解析使用
```
**后台模式**
```
TaskTool.execute() 立即返回 <task state="running">...
子 agent 完成后 → background.wait() 触发 → inject()
向父 session 注入合成 text partsynthetic: true
携带 <task state="completed">... 结果
父 LLM 在下一轮循环中看到该消息
```
---
## 四、分发与合并(Distribution & Merge
### 4.1 并行分发
- **无专用分发层**。依赖 LLM 在单条消息中发出多个 tool call
- `task.txt` 引导 LLM*"Launch multiple agents concurrently whenever possible"*
- 底层通过 Effect.ts 的 `Effect.forkIn(scope, { startImmediately: true })` 实现同一消息内多 tool call 并发
- **子 agent 之间完全隔离**,无直接通信
### 4.2 结果合并
**无专用合并逻辑。** 合并完全通过 LLM 的上下文理解完成:
- 前台:tool result 自然进入消息历史,LLM 下一轮读取
- CLI 命令:额外注入 "Summarize the task tool output above and continue with your task." 引导 LLM 总结
- LLM 自主调用:无额外引导,LLM 自行决定如何使用
### 4.3 前台/后台切换机制(task.ts lines 303-333
```typescript
// 前台执行
return yield* Effect.raceFirst(
background.wait({ id: nextSession.id }), // 等完成
background.waitForPromotion(nextSession.id), // 等 promote 到后台
)
```
当用户将前台任务 promote 到后台时,`waitForPromotion` 先返回(标记 `metadata.background = true`),TaskTool 转而返回后台模式的输出。
### 4.4 后台作业引擎(core/background-job.ts
纯内存、非持久化注册表。使用 Effect.ts 的 `SynchronizedRef` 做并发控制。
| 操作 | 行为 |
|------|------|
| `start()` | 创建 jobfork 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. 取 taskssubtask / 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 partpending 状态) |
| `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` agenthidden, 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` | 后台作业核心引擎(内存注册表) |
+313 -60
View File
@@ -1,13 +1,13 @@
# AG Core Roadmap
> 定稿日期:2026-05-11
> 最后更新:2026-06-11Phase 4c 编码实施完成)
> 最后更新:2026-07-04
## 愿景
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
**当前状态**Phase 0 基础设施已全部完成,Phase 1 提示词工程已全部完成,Phase 2 工具系统已全部完成,Phase 3 记忆系统已全部完成,Phase 4a 核心胶水层已全部完成,Phase 4b 任务执行已全部完成,Phase 4c 会话级记忆已全部完成(116 个测试通过,0 警告)
**当前状态**v0.1.0 已发布(2026-07-04)。Phase 0-4c 全部完成,Provider IR 重构 + LlmCycle 简化 + 7 个离线示例已交付。v0.2.0 已细分为 8 个增量 PhasePhase 5-12),本周开发启动
---
@@ -240,95 +240,323 @@ 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 服务
### 已有扩展项(沿用)
**总体规模**8 个增量 PhasePhase 5-12),17 个可验证 Step。
| 扩展项 | 所在模块 | 说明 | 优先级 | 状态 |
|-------|---------|------|--------|------|
| Prompt Optimizer | `prompt` | 提示词自动优化 | P3 | 待实现 |
| 流式接口优化 | `llm/stream` | 流式响应解析与事件化 | P0 | ✅ 已完成基础实现 |
### 功能清单
### 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<Item = StreamEvent>` |
| 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<Message>), // 预置消息,不持久化
}
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 0MemoryStore trait)、Phase 3MemoryStore 持久化)
**优先级**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.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 互不冲突)
**优先级**P05.1+ P15.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<ToolDef> for OpenaiToolDefinition` + 反向 `From` | 单元测试 roundtrip |
| **6.2** | `types/mod.rs` 切别名 `pub type ToolDefinition = ToolDef``MessageRequest.tools``Vec<ToolDef>` | `cargo build` 编译断点 |
| **6.3** | `cycle.rs` 4 个方法签名 + `registry.rs` `definitions()` 签名更新 | `cargo build` |
| **6.4** | Provider 适配层(openai.rs / anthropic.rs / openai_compat.rs):`build_request()` 内做 `ToolDef → wire-format` 转换 | `cargo test` 每个 provider 测试 |
| **6.5** | 所有测试/示例中 `ToolDefinition``ToolDef` 修复;移除旧 `#[deprecated]` alias | `cargo test --all-targets` 全绿 |
**边界切割技巧**
- Step 6.1 → 6.2 之间是安全 checkpoint:新类型存在但旧代码照常编译
- Provider 层不改序列化逻辑,只加一层 `From` 转换
- 当前代码中 `ToolDefinition` 已是 `#[deprecated(since = "0.1.0")]`,用户已有迁移预期
**依赖**:无(仅与 Phase 5.3 有枚举兼容关系)
**优先级**P0
---
#### Phase 7: SqliteStore 持久化
**目标**:实现 `MemoryStore` 的 SQLite 后端,进程重启数据不丢。
**与 Phase 6 无耦合,可重叠开发。**
| Step | 内容 | 文件 | 验证标准 |
|------|------|-----|---------|
| **7.1** | 新增 `memory/store/sqlite.rs``Mutex<Connection>` + `spawn_blocking`,实现 `save/get/delete/list` + prefix 过滤 | `memory/store/sqlite.rs` + `Cargo.toml`add `rusqlite` | 单元测试 CRUD + prefix 查询 |
| **7.2** | WAL 模式 + 并发安全 + 集成测试(`tokio::spawn` 10 个并发 task | `sqlite.rs` 扩展 | 并发写入 100 轮无 race |
**设计决策**
-`Mutex<Connection>` 而非连接池(ponytail:一个连接够用就不加 r2d2)
- WAL 模式:`PRAGMA journal_mode=WAL` 解决读写锁
**依赖**`MemoryStore` traitv0.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 5ProviderConfig from_env+ Phase 6ToolDef+ Phase 7SqliteStore
**优先级**P0
---
#### Phase 9: 流式体验增强
**目标**:Agent 会话支持流式输出,开发者看到实时 token。
| Step | 内容 | 文件 | 验证标准 |
|------|------|-----|---------|
| **9.1** | `AgentSession::submit_turn_stream(user_input) -> impl Stream<Item=StreamEvent>` | `agent/session.rs` | 单元测试验证流事件序列:`TextDelta → ... → MessageComplete` |
**注意**tool 自动循环时流中插入 `ToolExecutionStarted` 事件,用户端 UI 显示"正在调用工具..."。
**依赖**Phase 6ToolDef+ `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 7SqliteStore 作为推荐持久化后端;`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** | 文件系统 MemoryStoreJSON/JSONL | ~80 行 | 最简单,适合练手 |
| **12.2** | MCP StreamableHttp 传输 | ~150 行 | 协议还在演进 |
| **12.3** | Gemini Provider | ~300 行 | 协议差异大,建议推迟到 v0.3 |
**依赖**:无(独立交付)
---
### v0.2.0 Phase 依赖关系图
```mermaid
graph BT
P5["Phase 5<br/>热身准备"]:::warmup
P6["Phase 6<br/>ToolDefinition IR"]:::core
P7["Phase 7<br/>SqliteStore"]:::core
P8["Phase 8<br/>MVP 出口 (rc.1)"]:::mvp
P9["Phase 9<br/>流式体验增强"]:::p1
P10["Phase 10<br/>ContextSlot"]:::p1
P11["Phase 11<br/>测试与检索"]:::p1
P12["Phase 12<br/>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+ 展望
### 已规划的功能
| 功能 | 说明 | 预计版本 |
|------|------|---------|
| 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 44a/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. **Phase 5 启动**ProviderConfig from_env + Ollama Provider + #[non_exhaustive] 前置,三个 Step 并行推进
2. **Phase 6 方案准备**:ToolDef 结构体定义 + 兼容转换,出实施笔记(实施时直接走代码评审)
3. **示例先行**完成一个 Phase 立即更新对应示例,验证通过后再合
4. **里程碑追踪**:以 Phase 8MVP 出口)为 v0.2.0-rc.1 节点,逐 Phase 验收
**已完成 / 进行中阶段**
- ✅ Phase 0 Foundation — 全部交付物已完成
@@ -338,3 +566,28 @@ graph BT
- ✅ Phase 4a Core Glue — 全部交付物已完成
- ✅ Phase 4b Task Execution — 全部交付物已完成
- ✅ Phase 4c Session Memory — 全部交付物已完成
- ✅ Provider IR 重构 — 统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen 适配
- ✅ LlmCycle 简化 — IR 消息类型切换 + Phase 0 桥接层移除
- ✅ v0.1 Release — 技术债扫清、MockProvider 公开化、7 个离线示例、README + 错误消息友好化、CHANGELOG 初始化
- 📋 **v0.2 规划细化完成** — 8 个增量 PhasePhase 5-12),17 个可验证 Step,覆盖 P0-P2 全部 12 项功能 + ContextSlot
---
## 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
+130
View File
@@ -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<dyn Agent> = 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 完成");
}
+143
View File
@@ -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 完成");
}
+169
View File
@@ -0,0 +1,169 @@
//! custom_tool —— 自定义工具注册、单次 / 并行调用、权限检查。
//!
//! 演示:
//! 1. 实现 `BaseTool` traitWeatherTool + 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<Permission> {
vec![Permission::Network]
}
async fn execute(
&self,
args: Value,
_ctx: &ToolContext<'_>,
) -> Result<Value, ToolError> {
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<Permission> {
vec![Permission::Delete]
}
async fn execute(
&self,
_args: Value,
_ctx: &ToolContext<'_>,
) -> Result<Value, ToolError> {
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. 单次 invoketool_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_all30s 超时)===");
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=== 权限检查(默认 PermissionConfigdenied = [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 完成");
}
+149
View File
@@ -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<dyn MemoryStore> = 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<dyn MemoryStore> = 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<dyn MemoryStore> = 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 完成");
}
+108
View File
@@ -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<Message> = Vec::new();
match validate_messages(&empty) {
Ok(()) => println!("空消息合法"),
Err(e) => println!("空消息按预期报错: {e}"),
}
println!("\n✓ prompt_composer 完成");
}
+117
View File
@@ -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<Item = StreamEvent> + 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<dyn LlmProvider> = 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 完成");
}
+113
View File
@@ -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 完成");
}
+2
View File
@@ -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;
+5 -4
View File
@@ -89,17 +89,18 @@ impl AgentBuilder {
/// 构造 `RuntimeBundle`,校验必填字段。
///
/// **错误**`provider` / `tool_registry` / `hook_executor` 任一缺失则返回
/// `AgentError::Config("missing <field>")`,不 panic。
/// `AgentError::Config(...)`,提示调用 `.provider(...)` / `.tool_registry(...)` /
/// `.hook_executor(...)` 补齐。不 panic。
pub fn build(self) -> Result<RuntimeBundle, AgentError> {
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();
+1 -1
View File
@@ -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]
+4 -46
View File
@@ -15,8 +15,7 @@ use crate::agent::runtime::RuntimeBundle;
use crate::agent::session_memory::SessionMemory;
use crate::llm::cycle::{CostTracker, CycleConfig, LlmCycle};
use crate::llm::hooks::{HookContext, HookEvent};
use crate::llm::types::message::{ContentBlock, Message};
use crate::llm::types::openai_message::OpenaiChatMessage;
use crate::llm::types::message::Message;
use crate::llm::types::response_v2::MessageResponse;
use crate::memory::store::InMemoryStore;
@@ -176,15 +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::types::request_v2::MessageRequest;
use crate::llm::types::response_v2::{MessageResponse, StopReason, StreamEvent};
use crate::llm::mock::MockProvider;
use crate::llm::types::message::ContentBlock;
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<Vec<MessageResponse>>,
}
impl MockProvider {
fn new(responses: Vec<MessageResponse>) -> Self {
Self {
responses: std::sync::Mutex::new(responses),
}
}
}
#[async_trait]
impl LlmProvider for MockProvider {
async fn chat(&self, _request: MessageRequest) -> Result<MessageResponse, LlmError> {
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<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
{
unimplemented!()
}
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
provider_name: "mock",
supported_models: None,
features: ProviderFeatures::default(),
}
}
}
struct StubAgent {
name: String,
prompt: Option<String>,
+2
View File
@@ -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,
+1
View File
@@ -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;
+15 -21
View File
@@ -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 }
@@ -178,8 +177,8 @@ pub fn content_to_blocks(field: &ContentField) -> Vec<ContentBlock> {
// ponytail: 简化处理 —— URL 直接通过,data URI 拆出
// data:<mime>;base64,<b64> → ImageSource { data: b64, mime, is_url: false }。
let url = &image_url.url;
if let Some(rest) = url.strip_prefix("data:") {
if let Some((mime, b64)) = rest.split_once(";base64,") {
if let Some(rest) = url.strip_prefix("data:")
&& let Some((mime, b64)) = rest.split_once(";base64,") {
return Some(ContentBlock::Image {
source: crate::llm::types::message::ImageSource {
data: b64.to_string(),
@@ -188,7 +187,6 @@ pub fn content_to_blocks(field: &ContentField) -> Vec<ContentBlock> {
},
});
}
}
Some(ContentBlock::Image {
source: crate::llm::types::message::ImageSource {
data: url.clone(),
@@ -255,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]
@@ -365,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"),
}
+10 -8
View File
@@ -16,11 +16,12 @@ use crate::llm::compact::{should_compact, microcompact, CompactConfig, CompactSt
use crate::llm::cycle::retry::should_retry;
use crate::llm::error::LlmError;
use crate::llm::hooks::{HookContext, HookExecutor};
use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};
use crate::llm::provider::LlmProvider;
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<dyn LlmProvider>` 的独占所有权)。
///
@@ -175,13 +177,10 @@ impl LlmCycle {
messages: Vec<Message>,
tools: Vec<ToolDefinition>,
) -> Result<MessageResponse, LlmError> {
let ir_messages: Vec<Message> = messages;
let ir_tools: Vec<ToolDefinition> = 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}"#)]),
+15 -13
View File
@@ -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<Duration> },
/// HTTP 请求失败,包含状态码响应体。
#[error("请求失败(status={status}): {body}")]
/// HTTP 请求失败(网络错误或非 2xx 状态码),包含状态码响应体。
#[error("LLM 请求失败(HTTP {status}: {body}。请检查 Provider 端点地址(base_url)和网络连通性")]
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-compactionllm::compact")]
ContextLength { actual: u32, limit: u32 },
/// 其他未分类的 LLM 调用失败。
#[error("LLM 调用失败: {0}")]
Other(String),
}
}
+1
View File
@@ -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
+305
View File
@@ -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<dyn LlmProvider> = 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<Vec<MessageResponse>>` 存储队列;`chat` / `chat_stream` 均弹出队首响应。
/// 当队列耗尽时返回 `LlmError::Other("MockProvider: 预设响应已用完")`。
pub struct MockProvider {
responses: Mutex<Vec<MessageResponse>>,
}
impl MockProvider {
/// 用预设响应列表创建。
pub fn new(responses: Vec<MessageResponse>) -> Self {
Self {
responses: Mutex::new(responses),
}
}
/// 创建空队列的 Provider(后续可用 `extend` 注入响应)。
pub fn empty() -> Self {
Self::new(Vec::new())
}
/// 追加更多响应到队列末尾。
pub fn extend(&self, responses: Vec<MessageResponse>) {
self.responses.lock().unwrap().extend(responses);
}
/// 队列中剩余响应数量。
pub fn remaining(&self) -> usize {
self.responses.lock().unwrap().len()
}
fn pop(&self) -> Result<MessageResponse, LlmError> {
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<MessageResponse, LlmError> {
self.pop()
}
async fn chat_stream(
&self,
_request: MessageRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>
{
let response = self.pop()?;
// 提前 clone 出在 stream 闭包中需要的字段;最后 yield 时 move response。
let id = response.id.clone();
let model = response.model.clone();
let usage = response.usage;
let blocks: Vec<ContentBlock> = 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);
}
}
+6 -3
View File
@@ -21,7 +21,7 @@ use tracing::{debug, error, info, warn};
use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};
use crate::llm::error::LlmError;
use crate::llm::types::message::{ContentBlock, ContentBlockType, Message};
use crate::llm::types::request_v2::{MessageRequest, ThinkingConfig};
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::response_v2::{
MessageResponse, PartialMessageResponse, PartialUsage, StopReason, StreamEvent,
};
@@ -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,
}
@@ -193,7 +194,7 @@ impl AnthropicProvider {
.json(&body)
.send()
.await
.map_err(|e| Self::map_reqwest_error(e))?;
.map_err(Self::map_reqwest_error)?;
let status = response.status();
if !status.is_success() {
@@ -229,7 +230,7 @@ impl AnthropicProvider {
.json(&body)
.send()
.await
.map_err(|e| Self::map_reqwest_error(e))?;
.map_err(Self::map_reqwest_error)?;
let status = response.status();
if !status.is_success() {
@@ -586,6 +587,7 @@ enum AnthropicDelta {
struct AnthropicMessageDeltaInner {
stop_reason: Option<String>,
#[serde(default)]
#[allow(dead_code)]
stop_sequence: Option<String>,
}
@@ -599,6 +601,7 @@ pub struct AnthropicSseStream {
chunks: Pin<Box<dyn Stream<Item = Result<Bytes, LlmError>> + Send>>,
buffer: String,
partial: PartialMessageResponse,
#[allow(dead_code)]
next_block_index: u32,
saw_terminal: bool,
}
+3 -3
View File
@@ -22,7 +22,7 @@ use serde_json::Value;
use tracing::{debug, error, info};
use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};
use crate::llm::convert::{blocks_to_content, content_to_blocks, from_openai, to_openai};
use crate::llm::convert::{from_openai, to_openai};
use crate::llm::error::LlmError;
use crate::llm::types::message::{ContentBlock, ContentBlockType, Message};
use crate::llm::types::openai_message::{ContentField, OpenaiChatMessage};
@@ -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;
};
+1 -1
View File
@@ -23,7 +23,7 @@ use crate::llm::types::old_stream::LegacyStreamEvent;
use crate::llm::types::response_v2::MessageResponse;
use crate::llm::types::response_v2::StopReason;
use crate::llm::types::usage::Usage;
use crate::llm::types::{FinishReason, OpenaiChatChunk, OpenaiToolCall};
use crate::llm::types::{OpenaiChatChunk, OpenaiToolCall};
// 唯一的对外 `StreamEvent` 定义(高精度 IR 事件,来自 `response_v2`)。
//
+4
View File
@@ -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<FinishReason>,
}
#[allow(deprecated)]
impl From<OpenaiChatResponse> for ChatResponse {
fn from(response: OpenaiChatResponse) -> Self {
let message = response
@@ -63,6 +65,7 @@ impl From<OpenaiChatResponse> for ChatResponse {
}
}
#[allow(deprecated)]
impl From<ChatResponse> for OpenaiChatChunk {
fn from(response: ChatResponse) -> Self {
let delta = Delta::from(response.message.clone());
@@ -95,4 +98,5 @@ impl From<ChatResponse> for OpenaiChatChunk {
}
/// 工具定义别名(无新类型冲突,保留)。
#[deprecated(since = "0.1.0", note = "ToolDefinition 仍直接对应 OpenAI wire-format;未来 v0.2 引入 IR 工具类型后会再次更新")]
pub type ToolDefinition = OpenaiToolDefinition;
+2 -6
View File
@@ -12,7 +12,9 @@ pub struct StreamOptions {
}
#[derive(Debug, Clone)]
#[derive(Default)]
pub enum ToolChoice {
#[default]
None,
Auto,
Required,
@@ -20,12 +22,6 @@ pub enum ToolChoice {
AllowedTools { tool_names: Vec<String> },
}
impl Default for ToolChoice {
fn default() -> Self {
// Default 行为由调用方显式选择;此处选择不暴露任何工具 —— 不暴露比错暴露安全。
ToolChoice::None
}
}
impl Serialize for ToolChoice {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+4 -5
View File
@@ -174,8 +174,8 @@ impl ConversationMemory {
}
}
if let Some(ref compact_config) = self.config.compact_config {
if should_compact(&self.messages, compact_config, &self.compact_state) {
if let Some(ref compact_config) = self.config.compact_config
&& should_compact(&self.messages, compact_config, &self.compact_state) {
let keep_recent = compact_config.keep_recent;
let freed = microcompact(&mut self.messages, keep_recent);
if freed > 0 {
@@ -184,7 +184,6 @@ impl ConversationMemory {
let _ = self.compact_state.record_failure();
}
}
}
}
}
@@ -237,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();
}
@@ -254,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();
}
+13 -6
View File
@@ -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(_))
}
}
}
+28 -21
View File
@@ -19,7 +19,7 @@ pub const KNOWLEDGE_PREFIX: &str = "knowledge_";
/// 同时维护一个 `Vec<PageIndexEntry>` 索引以加速列表遍历。
pub struct KnowledgeStore {
store: Arc<dyn MemoryStore>,
index: std::sync::Mutex<Vec<PageIndexEntry>>,
index: tokio::sync::Mutex<Vec<PageIndexEntry>>,
}
impl KnowledgeStore {
@@ -27,7 +27,7 @@ impl KnowledgeStore {
pub fn new(store: Arc<dyn MemoryStore>) -> 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,24 +120,31 @@ impl KnowledgeStore {
return Ok(Vec::new());
}
let needle = query.to_lowercase();
// 锁内仅 clone 匹配的 entry id,避免异步 get_page() 持有 index 锁。
let ids: Vec<String> = {
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))
{
if 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<PageIndexEntry> {
self.index.lock().unwrap().clone()
pub async fn get_index(&self) -> Vec<PageIndexEntry> {
self.index.lock().await.clone()
}
}
@@ -224,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);
}
@@ -235,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);
}
}
+1 -3
View File
@@ -113,9 +113,7 @@ fn extract_keywords(query: &str, stop_words: &HashSet<String>) -> Vec<String> {
.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)
+1 -1
View File
@@ -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};
+63 -128
View File
@@ -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<OpenaiChatMessage>,
messages: Vec<Message>,
}
impl PromptComposer {
@@ -16,7 +15,7 @@ impl PromptComposer {
}
/// 从已有的消息列表初始化。
pub fn from_messages(messages: Vec<OpenaiChatMessage>) -> Self {
pub fn from_messages(messages: Vec<Message>) -> Self {
Self { messages }
}
@@ -24,34 +23,32 @@ impl PromptComposer {
/// 添加一条纯文本 system 消息。
pub fn system(mut self, text: impl Into<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> 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<String>, content: impl Into<String>) -> 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<Self, PromptError> {
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<Self, PromptError> {
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<Self, PromptError> {
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<Self, PromptError> {
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<String>,
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<OpenaiContentPart>) -> Self {
self.push_message(OpenaiChatMessage::User {
content: ContentField::Array(parts),
name: None,
});
/// 批量添加 ContentBlock 作为 user 消息。
pub fn user_contents(mut self, blocks: Vec<ContentBlock>) -> Self {
self.push_message(Message::User { content: blocks });
self
}
/// 批量添加 ContentPart 作为 system 消息。
pub fn system_contents(mut self, parts: Vec<OpenaiContentPart>) -> Self {
self.push_message(OpenaiChatMessage::System {
content: ContentField::Array(parts),
name: None,
});
/// 批量添加 ContentBlock 作为 system 消息。
pub fn system_contents(mut self, blocks: Vec<ContentBlock>) -> Self {
self.push_message(Message::System { content: blocks });
self
}
/// 批量添加 ContentPart 作为 assistant 消息。
pub fn assistant_contents(mut self, parts: Vec<OpenaiContentPart>) -> 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<ContentBlock>) -> Self {
self.push_message(Message::Assistant { content: blocks });
self
}
/// 批量添加 ContentPart 作为 developer 消息。
pub fn developer_contents(mut self, parts: Vec<OpenaiContentPart>) -> Self {
self.push_message(OpenaiChatMessage::Developer {
content: ContentField::Array(parts),
name: None,
});
self
}
// ===== 角色标识 =====
/// 为上一条添加的消息设置 `name` 字段。
pub fn with_name(mut self, name: impl Into<String>) -> 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<ContentBlock>) -> Self {
self.push_message(Message::System { content: blocks });
self
}
// ===== 构建 =====
/// 构建最终的消息列表。
pub fn build(self) -> Vec<OpenaiChatMessage> {
pub fn build(self) -> Vec<Message> {
self.messages
}
/// 构建并直接创建 ChatRequest(需搭配 model 参数)。
/// 返回的 `OpenaiChatRequest` 中 `tools`、`temperature`、`max_tokens` 等字段均为 `None`
/// 可通过结构体更新语法补全:`OpenaiChatRequest { tools: Some(...), ..req }`。
pub fn build_request(self, model: impl Into<String>) -> 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<OpenaiChatMessage> = vec![];
let msgs: Vec<Message> = vec![];
assert!(validate_messages(&msgs).is_err());
}
+7 -7
View File
@@ -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),
}
+13 -13
View File
@@ -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<std::io::Error>),
/// 取消。
/// 工具执行被取消。
#[error("工具执行已取消: {0}")]
Cancelled(String),
/// 其他未分类错误。
#[error("其他错误: {0}")]
#[error("工具调用错误: {0}")]
Other(String),
}
@@ -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());
}
+3
View File
@@ -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<String>, transport: McpTransport) -> Self {
@@ -540,6 +542,7 @@ enum McpClientHandle {
}
#[async_trait]
#[allow(deprecated)]
impl BaseTool for McpToolAdapter {
fn name(&self) -> &str {
&self.name
+2
View File
@@ -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 {