8 Commits
Author SHA1 Message Date
徐涛 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
21 changed files with 1852 additions and 91 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
```
+26 -2
View File
@@ -1,13 +1,13 @@
# AG Core Roadmap
> 定稿日期:2026-05-11
> 最后更新:2026-06-11Phase 4c 编码实施完成)
> 最后更新:2026-07-04v0.1 发布完成)
## 愿景
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
**当前状态**Phase 0 基础设施已全部完成,Phase 1 提示词工程已全部完成,Phase 2 工具系统已全部完成,Phase 3 记忆系统已全部完成,Phase 4a 核心胶水层已全部完成,Phase 4b 任务执行已全部完成,Phase 4c 会话级记忆已全部完成(116 个测试通过,0 警告)。
**当前状态**Phase 0-4c 全部完成;Provider IR 重构(统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen Provider)已完成;LlmCycle 简化(IR 消息类型切换 + 桥接层移除)已完成;v0.1 发布就绪(**182 个测试通过、0 clippy 警告、7 个离线示例可运行**)。
---
@@ -338,3 +338,27 @@ graph BT
- ✅ Phase 4a Core Glue — 全部交付物已完成
- ✅ Phase 4b Task Execution — 全部交付物已完成
- ✅ Phase 4c Session Memory — 全部交付物已完成
- ✅ Provider IR 重构 — 统一类型系统 + OpenAI/Anthropic/DeepSeek/Qwen 适配(方案:`docs/10-llm-provider-refinement.md``docs/10a-phase0-types-and-trait.md``docs/10b-phase1-provider-adaptation.md`
- ✅ LlmCycle 简化 — IR 消息类型切换 + Phase 0 桥接层移除(方案:`docs/10c-phase2-llm-cycle-simplify.md`
- ✅ v0.1 Release — 技术债扫清、MockProvider 公开化、7 个离线示例、README + 错误消息友好化、Roadmap 同步、CHANGELOG 初始化(计划:`docs/11-v0.1-release-plan.md`
---
## v0.1 发布里程碑(2026-07-04
**质量基线**
| 指标 | 数值 |
|------|------|
| `cargo build --all-targets` | ✅ 通过 |
| `cargo test --all-targets` | ✅ **182 passed / 0 failed** |
| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 |
| 离线示例(`cargo run --example` | ✅ 7 个全部 exit 0 |
**关键交付**
1. **Provider IR 重构** — 统一 `Message` / `ContentBlock` / `MessageRequest` / `MessageResponse` 类型层;4 个 Provider 适配(OpenAI Chat / Anthropic Messages / DeepSeek / Qwen);`LlmProvider` trait 签名同步切换
2. **LlmCycle 简化**`LlmCycle` 内部消息类型切到 IR 层;移除 Phase 0 的 `OpenaiChatMessage ↔ Message` 桥接;测试从 116 → 182(含 provider 测试)
3. **`MockProvider` 公开化** — `agcore::llm::mock::MockProvider` 支持 `chat` + `chat_stream`,无需 API key 即可运行示例
4. **7 个离线示例**`prompt_composer` / `custom_tool` / `agent_session_demo` / `task_agent_demo` / `conversation_memory_demo` / `knowledge_search_demo` / `streaming_events_demo`
5. **错误消息友好化**`AgentError` / `LlmError` / `ToolError` / `MemoryError` / `PromptError` 全部面向最终用户改写(给出可操作的建议)
6. **文档完整** — README 完整版(快速上手 + 架构图 + 环境变量)、Apache-2.0 LICENSE
+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 完成");
}
+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]
+2 -44
View File
@@ -175,16 +175,12 @@ impl AgentSession {
mod tests {
use super::*;
use crate::agent::builder::AgentBuilder;
use crate::llm::error::LlmError;
use crate::llm::hooks::{Hook, HookContext, HookExecutor, HookResult};
use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};
use crate::llm::mock::MockProvider;
use crate::llm::types::message::ContentBlock;
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::response_v2::{MessageResponse, StopReason, StreamEvent};
use crate::llm::types::response_v2::{MessageResponse, StopReason};
use crate::tools::ToolRegistry;
use async_trait::async_trait;
use futures_core::Stream;
use std::pin::Pin;
use std::sync::atomic::{AtomicU32, Ordering};
/// 计数 hook —— 每被调用一次 +1。
@@ -208,44 +204,6 @@ mod tests {
}
}
/// MockProvider:按调用顺序返回预设响应。
struct MockProvider {
responses: std::sync::Mutex<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>,
+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 -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),
}
}
+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);
}
}
+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(_))
}
}
}
+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};
+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),
}
+12 -12
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),
}