Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c1a3ee62e | ||
|
|
8dcd1f2482 | ||
|
|
602bd43fce | ||
|
|
a52588cd0f | ||
|
|
4fdc62754c | ||
|
|
9f5e8702a2 | ||
|
|
2d0d5c1592 | ||
|
|
7b2d2db322 | ||
|
|
c084c57e2c | ||
|
|
c2c0d498ee | ||
|
|
e54edbc037 |
@@ -0,0 +1,91 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
本项目所有重要变更均记录于此文件。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-07-04
|
||||||
|
|
||||||
|
首个公开版本。涵盖 Phase 0-4c 的全部核心能力、Provider IR 重构、LlmCycle 简化,以及面向用户的 7 个离线示例。
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
**LLM 调用周期(Phase 0)**
|
||||||
|
- 统一 IR 类型层:`Message`、`ContentBlock`、`MessageRequest`、`MessageResponse`、`ToolDefinition`、`StopReason`
|
||||||
|
- `LlmProvider` trait + 4 个 Provider 实现:OpenAI Chat Completions、Anthropic Messages、DeepSeek(OpenAI 兼容)、Qwen(OpenAI 兼容)
|
||||||
|
- `ProviderRegistry`:多 Provider 注册与发现
|
||||||
|
- `LlmCycle`:重试策略 + 用量追踪 + 自动 tool 循环 + Auto-compaction
|
||||||
|
- `HookExecutor`:OnTurnStart / OnTurnEnd / OnPlanStepComplete 等生命周期钩子
|
||||||
|
- `StreamEvents`:AssistantTextDelta / ToolExecutionStarted / MessageComplete 等流式事件
|
||||||
|
|
||||||
|
**提示词工程(Phase 1)**
|
||||||
|
- `PromptTemplate`:变量插值 + 条件渲染
|
||||||
|
- `PromptComposer`:system/user/assistant/tool_result 消息链组合
|
||||||
|
|
||||||
|
**工具系统(Phase 2)**
|
||||||
|
- `ToolRegistry`:注册、发现、并行调用、超时控制
|
||||||
|
- `BaseTool` trait(含 `ToolContext` 执行上下文)
|
||||||
|
- `McpClient`:stdio transport(StreamableHttp 已预留接口)
|
||||||
|
- `PermissionChecker`:白名单 / 黑名单 / 自定义权限
|
||||||
|
|
||||||
|
**记忆系统(Phase 3)**
|
||||||
|
- `MemoryStore` trait + `InMemoryStore` 默认实现
|
||||||
|
- `ConversationMemory`:sliding window / 全量模式,集成 `llm::compact`
|
||||||
|
- `KnowledgeStore`:知识页面存储
|
||||||
|
- `MemoryRetriever`:TextOverlap Dice 系数评分
|
||||||
|
- `EvictionPolicy`:None / Ttl / Capacity 三种淘汰策略
|
||||||
|
|
||||||
|
**Agent 运行时(Phase 4a/b/c)**
|
||||||
|
- `Agent` trait:name / system_prompt / tool_definitions
|
||||||
|
- `AgentSession` + `RuntimeBundle` + `AgentBuilder`:链式构造 + 依赖注入
|
||||||
|
- `AgentError`:Llm / Tool / Memory / HookBlocked / LimitExceeded / Config / PlanParse / Other 8 个变体
|
||||||
|
- `TaskAgent` trait + `JsonPlanParser`:自主执行 + 外部驱动
|
||||||
|
- `Plan` / `Step` / `StepStatus`:纯数据结构
|
||||||
|
- `SessionMemory`:基于 `MemoryStore` 的会话级 key-value 桥接
|
||||||
|
|
||||||
|
**面向用户的资产**
|
||||||
|
- `agcore::llm::mock::MockProvider`:公开的 Mock Provider,支持 `chat` + `chat_stream`,无需 API key 即可运行示例
|
||||||
|
- 7 个离线可运行示例(`cargo run --example ...`):
|
||||||
|
- `prompt_composer` — 模板变量插值 + 消息链构建
|
||||||
|
- `custom_tool` — 工具注册 + 权限检查
|
||||||
|
- `agent_session_demo` — AgentBuilder → AgentSession → submit_turn 完整链路
|
||||||
|
- `task_agent_demo` — JsonPlanParser → Plan → Step 状态机
|
||||||
|
- `conversation_memory_demo` — SessionMemory 命名空间隔离
|
||||||
|
- `knowledge_search_demo` — KnowledgeStore + 停用词过滤
|
||||||
|
- `streaming_events_demo` — 流式事件消费
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`LlmCycle` 切换到 IR 消息类型**:内部消息从 `OpenaiChatMessage` 切到统一 `Message` 类型,移除 Phase 0 的桥接转换层(方案:`docs/10c-phase2-llm-cycle-simplify.md`)
|
||||||
|
- **Provider IR 重构**:`LlmProvider` trait 签名同步切到 `MessageRequest` / `MessageResponse`;新增 Anthropic / DeepSeek / Qwen Provider(方案:`docs/10-llm-provider-refinement.md`、`docs/10b-phase1-provider-adaptation.md`)
|
||||||
|
- **`AgentBuilder` 扩展 `session_memory_backend`**:Phase 4c 接入会话级记忆
|
||||||
|
- **错误消息面向最终用户友好化**:`AgentError` / `LlmError` / `ToolError` / `MemoryError` / `PromptError` 全部改写为带可操作建议的友好消息
|
||||||
|
- **`composer.rs` IR 迁移**:`PromptComposer` 内部从 `OpenaiChatMessage` 切到 `Message` / `ContentBlock`,与新类型系统保持一致
|
||||||
|
- **`knowledge.rs` 锁修复**:`std::sync::Mutex` → `tokio::sync::Mutex`,避免 MutexGuard 跨 `.await` 持有
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- `ChatResponse` — 自 0.1.0 起标记为 deprecated,请改用 `MessageResponse`
|
||||||
|
- `ToolDefinition` — 自 0.1.0 起标记为 deprecated(仍直接对应 OpenAI wire-format);v0.2 将引入 IR 工具类型
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 修复 Provider IR 重构后 `session.rs` / `cycle.rs` 测试模块的导入缺失回归
|
||||||
|
- 修复 `knowledge.rs::search()` 中 MutexGuard 跨 `.await` 持有的潜在阻塞
|
||||||
|
- 清零全部 clippy 警告(`#[allow(dead_code)]` 已在最低必要范围使用)
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- 移除 Phase 0 的 `OpenaiChatMessage ↔ Message` 桥接层(LlmCycle 简化后已无外部调用方)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本基线
|
||||||
|
|
||||||
|
| 指标 | 数值 |
|
||||||
|
|------|------|
|
||||||
|
| `cargo build --all-targets` | ✅ 通过 |
|
||||||
|
| `cargo test --all-targets` | ✅ 182 passed / 0 failed |
|
||||||
|
| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 |
|
||||||
|
| 离线示例 | ✅ 7 个全部 `cargo run` 退出码 0 |
|
||||||
|
| 许可证 | Apache-2.0 |
|
||||||
|
|
||||||
|
[0.1.0]: https://git.archgrid.xyz/xt/agcore/releases/tag/v0.1.0
|
||||||
@@ -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.
|
||||||
@@ -1,3 +1,245 @@
|
|||||||
# AG Core
|
# AG Core
|
||||||
|
|
||||||
AG Core是一个用于提供构建智能体的底层工具箱,其中提供的功能主要包括基础的大模型调用的完整周期,提示词的组合与优化,记忆检索与管理、MCP与工具的调用等。
|
> AG Core 是一个用于构建 AI 智能体(Agent)的 Rust 工具箱,提供 LLM 调用、提示词工程、工具系统、记忆检索、Agent 运行时等核心能力,所有模块通过 trait 抽象、可插拔、可离线测试。
|
||||||
|
|
||||||
|
## 这是什么?
|
||||||
|
|
||||||
|
AG Core 不是 Agent 产品,而是 Agent 的**底层依赖库**:上层应用(TUI / Bot 网关 / 业务服务)基于 AG Core 装配出符合自身场景的 Agent。设计原则:
|
||||||
|
|
||||||
|
- **模块化** —— 五大功能领域(LLM / Prompt / Tool / Memory / Agent)独立 crate-internal 模块,通过 trait 解耦
|
||||||
|
- **可插拔** —— LLM Provider、记忆存储、工具注册全部通过 trait 抽象,可替换为自有实现
|
||||||
|
- **可离线** —— 公开 `MockProvider`,所有示例与测试无需 API key 即可运行
|
||||||
|
- **异步优先** —— 所有 IO API 均为 `async`,基于 tokio 运行时
|
||||||
|
|
||||||
|
适合用来:
|
||||||
|
|
||||||
|
- 搭建支持多轮对话 + 工具调用的智能体服务
|
||||||
|
- 接入多家 LLM(OpenAI / Anthropic / DeepSeek / Qwen 等)
|
||||||
|
- 在 Agent 中组合提示词模板、知识检索、MCP 工具
|
||||||
|
- 在 Rust 中复用一套"业务无关"的 Agent 基础设施
|
||||||
|
|
||||||
|
## 快速上手
|
||||||
|
|
||||||
|
5 分钟上手,无需 API key(使用 `MockProvider` 预设响应)。
|
||||||
|
|
||||||
|
**1. 添加依赖**
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
agcore = "0.1"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 第一个 Agent**
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use agcore::agent::{Agent, AgentBuilder, AgentSession};
|
||||||
|
use agcore::llm::hooks::HookExecutor;
|
||||||
|
use agcore::llm::mock::MockProvider;
|
||||||
|
use agcore::llm::provider::LlmProvider;
|
||||||
|
use agcore::llm::types::message::{ContentBlock, Message};
|
||||||
|
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
|
||||||
|
use agcore::llm::types::Usage;
|
||||||
|
use agcore::tools::ToolRegistry;
|
||||||
|
|
||||||
|
struct Greeter;
|
||||||
|
|
||||||
|
impl Agent for Greeter {
|
||||||
|
fn name(&self) -> &str { "greeter" }
|
||||||
|
fn system_prompt(&self) -> Option<&str> { Some("你是一个中文助手,回答简短。") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_response(text: &str) -> MessageResponse {
|
||||||
|
MessageResponse {
|
||||||
|
id: "r".into(),
|
||||||
|
model: "mock".into(),
|
||||||
|
message: Message::Assistant {
|
||||||
|
content: vec![ContentBlock::Text { text: text.into() }],
|
||||||
|
},
|
||||||
|
usage: Usage::from_input_output(2, text.chars().count() as u32),
|
||||||
|
stop_reason: StopReason::Stop,
|
||||||
|
extra: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// 1. MockProvider:预设响应,无须 API key
|
||||||
|
let provider: Arc<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 / 兼容 Provider(DeepSeek / Qwen)的 API key |
|
||||||
|
| `OPENAI_BASE_URL` | 是 | `https://api.openai.com/v1` | OpenAI 兼容端点 base URL(DeepSeek/Qwen 用对应地址) |
|
||||||
|
| `OPENAI_MODEL` | 是 | `gpt-4o-mini` | OpenAI 模型名 |
|
||||||
|
| `ANTHROPIC_API_KEY` | 用 Anthropic 时 | — | Anthropic Claude API key |
|
||||||
|
| `ANTHROPIC_BASE_URL` | 是 | `https://api.anthropic.com` | Anthropic 兼容端点 base URL |
|
||||||
|
| `ANTHROPIC_MODEL` | 是 | `claude-3-5-sonnet-latest` | Claude 模型名 |
|
||||||
|
| `PROVIDER` | 否 | `openai` | Provider 类型:`openai` / `openai-response` / `anthropic` / `deepseek` / `qwen` |
|
||||||
|
| `RUST_LOG` | 否 | `agcore=info` | tracing 日志级别(其他 crate 可加 `=debug`) |
|
||||||
|
|
||||||
|
示例(运行 `examples/simple_visit.rs` 真实调用 OpenAI):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export OPENAI_API_KEY=sk-...
|
||||||
|
export OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
export OPENAI_MODEL=gpt-4o-mini
|
||||||
|
export PROVIDER=openai
|
||||||
|
export RUST_LOG=agcore=debug
|
||||||
|
cargo run --example simple_visit
|
||||||
|
```
|
||||||
|
|
||||||
|
## 参考项目
|
||||||
|
|
||||||
|
AG Core 在 Phase 4 设计阶段调研了 4 个 2026 年公开的 AI Agent 项目(详见 [`docs/note-agent-harness-references.md`](./docs/note-agent-harness-references.md)):
|
||||||
|
|
||||||
|
| 项目 | 类型 | 语言 | 借鉴点 |
|
||||||
|
|------|------|------|-------|
|
||||||
|
| [OpenClaw](https://github.com/openclaw/openclaw) | 消息网关 | TypeScript | 多渠道适配模式 |
|
||||||
|
| [Hermes Agent](https://github.com/NousResearch/hermes-agent) | 自主学习智能体 | Python | 实体与会话解耦 |
|
||||||
|
| [OpenHuman](https://github.com/tinyhumansai/openhuman) | 桌面助手 | Rust + Tauri | 记忆树与 Token 压缩 |
|
||||||
|
| [OpenHarness](https://github.com/HKUDS/OpenHarness) | Agent Harness 框架 | Python | 显式依赖注入容器 + 三级权限 |
|
||||||
|
|
||||||
|
AG Core 不"抄代码",只参考架构模式。当前实现已经采纳:
|
||||||
|
|
||||||
|
- **OpenHarness 风格** —— 显式 `RuntimeBundle` 依赖注入容器
|
||||||
|
- **Hermes 风格** —— `Agent` trait(角色)与 `AgentSession`(会话)解耦
|
||||||
|
|
||||||
|
后续 v0.2+ 计划借鉴 OpenHuman 的 Memory Tree / TokenJuice 模式。
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
本项目基于 **Apache License 2.0** 授权。完整文本见 [`LICENSE`](./LICENSE)。
|
||||||
|
|
||||||
|
```
|
||||||
|
Copyright 2026 AG Core Contributors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
```
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
# v0.1 发布实施计划
|
||||||
|
|
||||||
|
> 状态:待实施
|
||||||
|
> 关联文档:`docs/roadmap.md`、`docs/8-examples-plan.md`、`docs/10c-phase2-llm-cycle-simplify.md`
|
||||||
|
|
||||||
|
## 背景与目标
|
||||||
|
|
||||||
|
AG Core 已完成 Phase 0-4c 全部 7 个 Phase 的核心功能,以及 Provider IR 重构(新类型系统 + Anthropic/DeepSeek/Qwen Provider)+ LlmCycle 简化(IR 消息类型切换 + 桥接层移除)。
|
||||||
|
|
||||||
|
**当前基线**:
|
||||||
|
- `cargo build` — ✅ 通过(5 个 warning)
|
||||||
|
- `cargo test` — ❌ 编译失败(4 个 error:session.rs/cycle.rs 测试模块中 `ContentBlock`/`ProviderCapabilities`/`ProviderFeatures` 导入缺失,均为 Provider IR 重构后未同步的回归)
|
||||||
|
- LlmCycle 简化合并前全量测试为 177 通过,合并后尚未跑通过过全量测试
|
||||||
|
|
||||||
|
v0.1 发布的阻塞项不是"功能不足",而是"已有功能不能被用户快速看见和使用"。本计划聚焦于:
|
||||||
|
|
||||||
|
1. **扫清技术债** — composer.rs 迁移、锁修复、clippy 清零
|
||||||
|
2. **提供可离线运行的示例** — 让用户 5 分钟内上手
|
||||||
|
3. **补充面向用户的文档** — README、MockProvider、错误消息友好化
|
||||||
|
4. **完成发布前准备** — CI 验证、Roadmap 同步、版本标记
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 总体时间线
|
||||||
|
|
||||||
|
预计 8-10 个工作日(2 周),分为两条并行线:
|
||||||
|
|
||||||
|
```
|
||||||
|
Week 1 ────┬── 测试修复 + 技术债扫清
|
||||||
|
│ ├ T0 测试编译回归修复(~0.5d)← ⚠️ 任何改动前的前置步骤
|
||||||
|
│ ├ T1.1 composer.rs IR 迁移(~1d)
|
||||||
|
│ ├ T1.2 knowledge.rs 锁修复(~0.5d)
|
||||||
|
│ ├ T1.3 旧类型废弃标记(~0.5d)
|
||||||
|
│ └ T1.4 clippy 清零(~0.5d)
|
||||||
|
│
|
||||||
|
├── 示例 + 开发者体验
|
||||||
|
│ ├ T2.0 MockProvider 公开化(~0.5d)
|
||||||
|
│ ├ T2.1 4个🥇示例(~2-3d,可并行)
|
||||||
|
│ └ T3.1 README 初稿(~1d,并行)
|
||||||
|
│
|
||||||
|
Week 2 ────┬── 示例继续
|
||||||
|
│ ├ T2.2 3个🥈示例(~2d)
|
||||||
|
│ └ T3.2 错误消息 review(~0.5d)
|
||||||
|
│
|
||||||
|
└── 发布准备
|
||||||
|
├ T3.3 README 定稿(~1d)
|
||||||
|
├ T4.1 CI 示例验证(~0.5d)
|
||||||
|
├ T4.2 Roadmap 更新(~0.5d)
|
||||||
|
├ T4.3 CHANGELOG 初始化(~0.5d)
|
||||||
|
└ T4.4 v0.1 tag(~0.5d)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实施步骤
|
||||||
|
|
||||||
|
### Phase A — 测试修复 + 技术债扫清(Week 1 前半)
|
||||||
|
|
||||||
|
> **重要**:Task A0 是后续所有任务的前提——不修复测试编译,所有 Task 的验收条件(`cargo test` 全通过)都不可执行。
|
||||||
|
|
||||||
|
#### Task A0:修复测试编译回归(~0.5d)
|
||||||
|
|
||||||
|
**问题**:Provider IR 重构后,`ContentBlock`/`ProviderCapabilities`/`ProviderFeatures` 被移到新位置,但 2 个测试模块的导入未同步,导致 `cargo test` 编译失败。
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- `src/agent/session.rs` — 测试模块缺少 `ContentBlock` 导入
|
||||||
|
- `src/llm/cycle.rs` — 测试模块缺少 `ProviderCapabilities`/`ProviderFeatures` 导入
|
||||||
|
|
||||||
|
**修复方案**:在相应测试模块内补全 `use` 语句(每处加一行即可)。
|
||||||
|
|
||||||
|
**额外收益**:修复后 `convert.rs` 中 2 处 `irrefutable if let` 警告也一并修正(改为 `let`),减少 clippy 基数。
|
||||||
|
|
||||||
|
**前置依赖**:无
|
||||||
|
|
||||||
|
**验收条件**:
|
||||||
|
- `cargo test` 全量编译通过
|
||||||
|
- `cargo test` 全量运行通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task A1:`composer.rs` IR 迁移(~1d)
|
||||||
|
|
||||||
|
**前置依赖**:Task A0(否则无法通过 `cargo test` 验证)
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- `src/prompt/composer.rs` — 主要改动
|
||||||
|
- `src/prompt/mod.rs` — 类型重导出
|
||||||
|
|
||||||
|
**改动范围**:约 60 处 `OpenaiChatMessage` → `Message`,`ContentField`/`OpenaiContentPart` → `ContentBlock`
|
||||||
|
|
||||||
|
**具体清单**:
|
||||||
|
1. `PromptComposer` 内部 `messages` 字段类型 `Vec<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 A4:clippy 警告清零(~0.5d)
|
||||||
|
|
||||||
|
当前剩余 8 个:
|
||||||
|
|
||||||
|
| # | 警告类型 | 文件 | 修复方式 |
|
||||||
|
|---|---------|------|---------|
|
||||||
|
| 1 | irrefutable `if let` | — | 改为直接 `let` |
|
||||||
|
| 2 | `with_plan_step_index` 未用 | `src/llm/hooks.rs` | 加 `#[allow(dead_code)]` |
|
||||||
|
| 3-5 | 字段未读(api_key, stop_sequence, next_block_index) | `anthropic.rs`, `response_v2.rs` | 加 `#[allow(dead_code)]` |
|
||||||
|
| 6 | 手动前缀剥离 | — | 用 `strip_prefix()` |
|
||||||
|
| 7 | MutexGuard 跨 await | knowledge.rs | Task A2 修复后自动消失 |
|
||||||
|
| 8 | `if` 相同分支 | retriever.rs | 合并条件 |
|
||||||
|
|
||||||
|
**验收条件**:`cargo clippy --lib -p agcore` 0 警告
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase B — 示例实现(Week 1 后半 ~ Week 2 前半)
|
||||||
|
|
||||||
|
#### Task B0:MockProvider 公开化(~0.5d)
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- `src/llm/mock.rs` — 新建,公开 `MockProvider` struct
|
||||||
|
- `src/llm/mod.rs` — 加 `pub mod mock;`
|
||||||
|
- `src/agent/session.rs` — 测试中 `MockProvider` 改为引用 `crate::llm::mock::MockProvider`
|
||||||
|
|
||||||
|
**MockProvider 接口**:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct MockProvider { .. }
|
||||||
|
impl MockProvider {
|
||||||
|
pub fn new(responses: Vec<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 B0(MockProvider 需支持 `chat_stream`) |
|
||||||
|
|
||||||
|
**验收条件**:额外 3 个示例均可 `cargo run` 成功退出
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase C — 开发者体验 + 文档(Week 2 后半,与 Phase B 后段并行)
|
||||||
|
|
||||||
|
#### Task C1:README 完整版(~1.5d)
|
||||||
|
|
||||||
|
**涉及文件**:`README.md`
|
||||||
|
|
||||||
|
**内容结构**:
|
||||||
|
|
||||||
|
```
|
||||||
|
# AG Core
|
||||||
|
|
||||||
|
## 这是什么? ← 一句话定位
|
||||||
|
## 快速上手 ← cargo add + MockProvider 示例
|
||||||
|
## 核心模块 ← 7 个模块一句话说明
|
||||||
|
## 架构关系图 ← ASCII 或 Mermaid
|
||||||
|
## 模块依赖关系 ← 依赖关系图 + 说明
|
||||||
|
## 环境变量 ← 当前支持的环境变量表
|
||||||
|
## 参考项目 ← OpenClaw / Hermes / OpenHuman / OpenHarness
|
||||||
|
## 许可证 ← MIT / Apache-2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**要求**:快速上手代码段在提交前必须真实编译通过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task C2:错误消息友好化 review(~0.5d)
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- `src/agent/error.rs` — AgentError 消息
|
||||||
|
- `src/llm/error.rs` — LlmError 消息
|
||||||
|
- `src/tools/error.rs` — ToolError 消息
|
||||||
|
- `src/memory/error.rs` — MemoryError 消息
|
||||||
|
- `src/prompt/error.rs` — PromptError 消息
|
||||||
|
|
||||||
|
**检查标准**:
|
||||||
|
- 用户能理解"哪里错了"
|
||||||
|
- 有可操作的建议("请检查 API key"、"请配置环境变量 LLM_API_KEY")
|
||||||
|
- 无英文残留的工程师视角消息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase D — 发布准备(Week 2 末)
|
||||||
|
|
||||||
|
#### Task D1:CI 示例验证集成(~0.5d)
|
||||||
|
|
||||||
|
- 确认 `cargo build` 无新增警告
|
||||||
|
- 确认 `cargo test` 全部通过(退出码 0)
|
||||||
|
- 确认 `cargo test --examples` 全部通过
|
||||||
|
- 确认 `cargo clippy --lib -p agcore` 0 警告
|
||||||
|
|
||||||
|
#### Task D2:Roadmap 更新(~0.5d)
|
||||||
|
|
||||||
|
更新 `docs/roadmap.md`:
|
||||||
|
- Phase 2 状态更新为 ✅ 全部交付物已完成(Provider IR 重构 + LlmCycle 简化)
|
||||||
|
- 测试计数更新为 D1 确认的最终实际数值
|
||||||
|
- 移除 clippy 警告计数
|
||||||
|
- 添加 v0.1 发布里程碑
|
||||||
|
|
||||||
|
#### Task D3:依赖裁剪(~0.5d,非阻塞,可跳过)
|
||||||
|
|
||||||
|
`tokio = { features = ["full"] }` → 按需 feature(`rt`、`macros`、`sync`、`time`、`net`)
|
||||||
|
|
||||||
|
**理由**:减少编译时间 + 减少安全面。时间不够可延后到 v0.2。
|
||||||
|
|
||||||
|
#### Task D4:CHANGELOG 初始化(~0.5d)
|
||||||
|
|
||||||
|
**涉及文件**:`CHANGELOG.md`
|
||||||
|
|
||||||
|
**内容要求**:
|
||||||
|
- 版本号:`v0.1.0`
|
||||||
|
- 发布日期:标记当天
|
||||||
|
- 变更摘要:Phase 0-4c(LLM 调用周期、提示词工程、工具系统、记忆系统、Agent 运行时、任务执行、会话级记忆)+ Provider IR 重构(统一类型系统 + Anthropic/DeepSeek/Qwen 适配)+ LlmCycle 简化
|
||||||
|
- 格式参考 [Keep a Changelog](https://keepachangelog.com/) 规范
|
||||||
|
|
||||||
|
**验收条件**:
|
||||||
|
- `CHANGELOG.md` 文件存在,内容与当前版本一致
|
||||||
|
- 文件随 tag commit 一起提交
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task D5:版本标记(~0.5d)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag v0.1.0 && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 并行机会
|
||||||
|
|
||||||
|
| 并行组 | 包含任务 | 说明 |
|
||||||
|
|--------|---------|------|
|
||||||
|
| 组 0 | A0 | 单独执行,后续所有 Task 的前提 |
|
||||||
|
| 组 1 | A1 + A2 + A3 | A0 完成后可并行修 |
|
||||||
|
| 组 2 | B1a + B1b + B1d | 三个示例无前置依赖(B1a 需等 B0,B1c 需等 A1) |
|
||||||
|
| 组 3 | C1 + C2 + D2 | README / 错误消息 / Roadmap 更新,纯文档工作 |
|
||||||
|
| 组 4 | D1 + D3 | CI 验证 + 依赖裁剪,可并行 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险与应对
|
||||||
|
|
||||||
|
| 风险 | 影响 | 概率 | 应对 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| A0 修复后仍有未发现的测试回归 | 🔴 后续 Task 验收不可信 | 低 | `cargo test` 全量通过后才启动 A1 |
|
||||||
|
| composer.rs 迁移发现未预见的 API 依赖 | 🔴 A1 延期 | 低 | 保持每次 commit 可编译;分 2 次提交(先私有不影响构建,再改返回类型) |
|
||||||
|
| MockProvider stream 实现比预期复杂 | 🟡 B2c 延期 | 中 | 先简化实现(只支持基本 text delta),复杂 case 留给 v0.2 |
|
||||||
|
| 示例与库 API 不同步 | 🟡 持续风险 | 中 | 所有示例纳入 `cargo test --examples` 作为 CI gate |
|
||||||
|
| clippy `#[allow(dead_code)]` 积累过多 | 🟢 低 | 高 | v0.2 发布前专门 review 一次允许列表 |
|
||||||
|
| tokio feature 裁剪导致编译失败 | 🟡 D3 延期 | 低 | 裁剪前确认现有 feature 覆盖所有 `tokio::` 调用点 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
1. **代码质量**:`cargo build` 0 错误,`cargo clippy --lib -p agcore` 0 警告
|
||||||
|
2. **测试覆盖**:`cargo test` 全量编译通过且全部运行通过,`cargo test --examples` 全通过
|
||||||
|
3. **示例可用**:4 个🥇 + 3 个🥈共 7 个示例均可离线 `cargo run` 成功退出
|
||||||
|
4. **文档完整**:README 包含快速上手 + 架构概览,错误消息全部友好化
|
||||||
|
5. **版本标记**:`git tag v0.1.0`
|
||||||
|
6. **Roadmap 同步**:`docs/roadmap.md` 测试计数和状态与代码一致
|
||||||
+26
-2
@@ -1,13 +1,13 @@
|
|||||||
# AG Core Roadmap
|
# AG Core Roadmap
|
||||||
|
|
||||||
> 定稿日期:2026-05-11
|
> 定稿日期:2026-05-11
|
||||||
> 最后更新:2026-06-11(Phase 4c 编码实施完成)
|
> 最后更新:2026-07-04(v0.1 发布完成)
|
||||||
|
|
||||||
## 愿景
|
## 愿景
|
||||||
|
|
||||||
AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。
|
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 4a Core Glue — 全部交付物已完成
|
||||||
- ✅ Phase 4b Task Execution — 全部交付物已完成
|
- ✅ Phase 4b Task Execution — 全部交付物已完成
|
||||||
- ✅ Phase 4c Session Memory — 全部交付物已完成
|
- ✅ 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
|
||||||
|
|||||||
@@ -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 完成");
|
||||||
|
}
|
||||||
@@ -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 完成");
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
//! custom_tool —— 自定义工具注册、单次 / 并行调用、权限检查。
|
||||||
|
//!
|
||||||
|
//! 演示:
|
||||||
|
//! 1. 实现 `BaseTool` trait(WeatherTool + DeleteFileTool)
|
||||||
|
//! 2. 注册到 `ToolRegistry`
|
||||||
|
//! 3. 单次 `invoke`(含 tool_call_id 关联)
|
||||||
|
//! 4. 并行 `invoke_all`
|
||||||
|
//! 5. 调用未注册工具 → `ToolError::NotFound`
|
||||||
|
//! 6. `PermissionChecker` 黑名单阻断 `DeleteFileTool`
|
||||||
|
//!
|
||||||
|
//! 运行:`cargo run --example custom_tool`
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use agcore::tools::{
|
||||||
|
BaseTool, Permission, PermissionChecker, PermissionConfig, ToolContext, ToolError, ToolRef,
|
||||||
|
ToolRegistry,
|
||||||
|
};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
/// 天气查询工具 —— 模拟根据城市返回天气数据。
|
||||||
|
struct WeatherTool;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BaseTool for WeatherTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"get_weather"
|
||||||
|
}
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"查询指定城市的天气"
|
||||||
|
}
|
||||||
|
fn parameters(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"city": { "type": "string", "description": "城市名" }
|
||||||
|
},
|
||||||
|
"required": ["city"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn required_permissions(&self) -> Vec<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. 单次 invoke:tool_call_id 用于回传时与原始调用关联
|
||||||
|
println!("\n=== 单次 invoke ===");
|
||||||
|
let result = registry
|
||||||
|
.invoke("call_1", "get_weather", json!({"city": "北京"}))
|
||||||
|
.await
|
||||||
|
.expect("invoke 失败");
|
||||||
|
println!("tool_call_id: {}", result.tool_call_id);
|
||||||
|
println!("tool_name: {}", result.tool_name);
|
||||||
|
println!("output: {}", result.output.unwrap());
|
||||||
|
|
||||||
|
// 3. 并行 invoke_all:三个并行天气查询
|
||||||
|
println!("\n=== 并行 invoke_all(30s 超时)===");
|
||||||
|
let calls = vec![
|
||||||
|
("c1".into(), "get_weather".into(), json!({"city": "北京"})),
|
||||||
|
("c2".into(), "get_weather".into(), json!({"city": "上海"})),
|
||||||
|
("c3".into(), "get_weather".into(), json!({"city": "广州"})),
|
||||||
|
];
|
||||||
|
let results = registry.invoke_all(calls, 30).await;
|
||||||
|
assert_eq!(results.len(), 3);
|
||||||
|
for r in &results {
|
||||||
|
let output = r.output.as_ref().unwrap();
|
||||||
|
println!("[{}] {}", r.tool_call_id, output);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 调用未注册工具
|
||||||
|
println!("\n=== 未注册工具 ===");
|
||||||
|
let err = registry
|
||||||
|
.invoke("c_x", "nope_tool", json!({}))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
println!("错误: {err}");
|
||||||
|
|
||||||
|
// 5. 权限检查:默认 PermissionConfig 黑名单含 Delete
|
||||||
|
println!("\n=== 权限检查(默认 PermissionConfig,denied = [Delete, Shell])===");
|
||||||
|
let mut registry_with_checker = ToolRegistry::new().with_permission_checker(PermissionChecker::new(
|
||||||
|
PermissionConfig::default(),
|
||||||
|
));
|
||||||
|
registry_with_checker
|
||||||
|
.register(Arc::new(WeatherTool) as ToolRef)
|
||||||
|
.unwrap();
|
||||||
|
registry_with_checker
|
||||||
|
.register(Arc::new(DeleteFileTool) as ToolRef)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// get_weather 声明 Network → 在 allowed 列表 → 通过
|
||||||
|
let r = registry_with_checker
|
||||||
|
.invoke("c1", "get_weather", json!({"city": "北京"}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
println!(
|
||||||
|
"get_weather 权限检查: {}",
|
||||||
|
if r.output.is_ok() { "通过 ✓" } else { "阻断 ✗" }
|
||||||
|
);
|
||||||
|
|
||||||
|
// delete_file 声明 Delete → 在 denied 列表 → 阻断
|
||||||
|
let err = registry_with_checker
|
||||||
|
.invoke("c2", "delete_file", json!({"path": "/tmp/x"}))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
println!("delete_file 权限检查: 阻断 ✗ ({err})");
|
||||||
|
|
||||||
|
println!("\n✓ custom_tool 完成");
|
||||||
|
}
|
||||||
@@ -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 完成");
|
||||||
|
}
|
||||||
@@ -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 完成");
|
||||||
|
}
|
||||||
@@ -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 完成");
|
||||||
|
}
|
||||||
@@ -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 完成");
|
||||||
|
}
|
||||||
@@ -7,12 +7,14 @@
|
|||||||
//! - **不绑定业务循环**:`submit_turn` 在 `AgentSession` 上,不在 trait 上
|
//! - **不绑定业务循环**:`submit_turn` 在 `AgentSession` 上,不在 trait 上
|
||||||
|
|
||||||
use crate::agent::runtime::RuntimeBundle;
|
use crate::agent::runtime::RuntimeBundle;
|
||||||
|
#[allow(deprecated)]
|
||||||
use crate::llm::types::ToolDefinition;
|
use crate::llm::types::ToolDefinition;
|
||||||
|
|
||||||
/// Agent 角色抽象。
|
/// Agent 角色抽象。
|
||||||
///
|
///
|
||||||
/// 实现此 trait 即可接入 Agent Runtime。典型实现是 struct 持有静态配置(name、system prompt 模板),
|
/// 实现此 trait 即可接入 Agent Runtime。典型实现是 struct 持有静态配置(name、system prompt 模板),
|
||||||
/// 也可以是基于配置动态生成的轻量实现。
|
/// 也可以是基于配置动态生成的轻量实现。
|
||||||
|
#[allow(deprecated)]
|
||||||
pub trait Agent: Send + Sync {
|
pub trait Agent: Send + Sync {
|
||||||
/// 角色名(用于日志、调试、UI 展示)。
|
/// 角色名(用于日志、调试、UI 展示)。
|
||||||
fn name(&self) -> &str;
|
fn name(&self) -> &str;
|
||||||
|
|||||||
@@ -89,17 +89,18 @@ impl AgentBuilder {
|
|||||||
/// 构造 `RuntimeBundle`,校验必填字段。
|
/// 构造 `RuntimeBundle`,校验必填字段。
|
||||||
///
|
///
|
||||||
/// **错误**:`provider` / `tool_registry` / `hook_executor` 任一缺失则返回
|
/// **错误**:`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> {
|
pub fn build(self) -> Result<RuntimeBundle, AgentError> {
|
||||||
let provider = self
|
let provider = self
|
||||||
.provider
|
.provider
|
||||||
.ok_or_else(|| AgentError::Config("missing provider".into()))?;
|
.ok_or_else(|| AgentError::Config("缺少 LLM provider,请先调用 .provider(...)".into()))?;
|
||||||
let tool_registry = self
|
let tool_registry = self
|
||||||
.tool_registry
|
.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
|
let hook_executor = self
|
||||||
.hook_executor
|
.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();
|
let config = self.config.unwrap_or_default();
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -129,7 +129,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn config_not_recoverable() {
|
fn config_not_recoverable() {
|
||||||
assert!(!AgentError::Config("missing provider".into()).is_recoverable());
|
assert!(!AgentError::Config("缺少 provider".into()).is_recoverable());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+4
-46
@@ -15,8 +15,7 @@ use crate::agent::runtime::RuntimeBundle;
|
|||||||
use crate::agent::session_memory::SessionMemory;
|
use crate::agent::session_memory::SessionMemory;
|
||||||
use crate::llm::cycle::{CostTracker, CycleConfig, LlmCycle};
|
use crate::llm::cycle::{CostTracker, CycleConfig, LlmCycle};
|
||||||
use crate::llm::hooks::{HookContext, HookEvent};
|
use crate::llm::hooks::{HookContext, HookEvent};
|
||||||
use crate::llm::types::message::{ContentBlock, Message};
|
use crate::llm::types::message::Message;
|
||||||
use crate::llm::types::openai_message::OpenaiChatMessage;
|
|
||||||
use crate::llm::types::response_v2::MessageResponse;
|
use crate::llm::types::response_v2::MessageResponse;
|
||||||
use crate::memory::store::InMemoryStore;
|
use crate::memory::store::InMemoryStore;
|
||||||
|
|
||||||
@@ -176,15 +175,12 @@ impl AgentSession {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::agent::builder::AgentBuilder;
|
use crate::agent::builder::AgentBuilder;
|
||||||
use crate::llm::error::LlmError;
|
|
||||||
use crate::llm::hooks::{Hook, HookContext, HookExecutor, HookResult};
|
use crate::llm::hooks::{Hook, HookContext, HookExecutor, HookResult};
|
||||||
use crate::llm::provider::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
use crate::llm::mock::MockProvider;
|
||||||
use crate::llm::types::request_v2::MessageRequest;
|
use crate::llm::types::message::ContentBlock;
|
||||||
use crate::llm::types::response_v2::{MessageResponse, StopReason, StreamEvent};
|
use crate::llm::types::response_v2::{MessageResponse, StopReason};
|
||||||
use crate::tools::ToolRegistry;
|
use crate::tools::ToolRegistry;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures_core::Stream;
|
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
||||||
/// 计数 hook —— 每被调用一次 +1。
|
/// 计数 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 {
|
struct StubAgent {
|
||||||
name: String,
|
name: String,
|
||||||
prompt: Option<String>,
|
prompt: Option<String>,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
//! - 重试由上层新建 `Plan` 实现,`TaskAgent` 不做自动重试
|
//! - 重试由上层新建 `Plan` 实现,`TaskAgent` 不做自动重试
|
||||||
|
|
||||||
use crate::agent::error::AgentError;
|
use crate::agent::error::AgentError;
|
||||||
|
#[allow(deprecated)]
|
||||||
use crate::llm::types::ChatResponse;
|
use crate::llm::types::ChatResponse;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -55,6 +56,7 @@ impl Step {
|
|||||||
/// 均未派生 `Clone`(保留原始错误信息,传递所有权而非克隆)。如需复制 `Plan`,
|
/// 均未派生 `Clone`(保留原始错误信息,传递所有权而非克隆)。如需复制 `Plan`,
|
||||||
/// 只能 clone 处于 `Pending` / `Running` / `Completed` / `Skipped` 状态的步骤。
|
/// 只能 clone 处于 `Pending` / `Running` / `Completed` / `Skipped` 状态的步骤。
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
#[allow(deprecated)]
|
||||||
pub enum StepStatus {
|
pub enum StepStatus {
|
||||||
/// 初始状态 —— 等待执行。
|
/// 初始状态 —— 等待执行。
|
||||||
Pending,
|
Pending,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub mod convert;
|
|||||||
pub mod cycle;
|
pub mod cycle;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod hooks;
|
pub mod hooks;
|
||||||
|
pub mod mock;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
pub mod stream;
|
pub mod stream;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|||||||
+15
-21
@@ -40,15 +40,14 @@ pub fn from_openai(msg: &OpenaiChatMessage) -> Message {
|
|||||||
let mut blocks = content_to_blocks(content);
|
let mut blocks = content_to_blocks(content);
|
||||||
if let Some(calls) = tool_calls {
|
if let Some(calls) = tool_calls {
|
||||||
for call in calls {
|
for call in calls {
|
||||||
if let OpenaiToolCall::Function { id, function } = call {
|
let OpenaiToolCall::Function { id, function } = call;
|
||||||
let input: Value =
|
let input: Value =
|
||||||
serde_json::from_str(&function.arguments).unwrap_or(Value::Null);
|
serde_json::from_str(&function.arguments).unwrap_or(Value::Null);
|
||||||
blocks.push(ContentBlock::ToolUse {
|
blocks.push(ContentBlock::ToolUse {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
name: function.name.clone(),
|
name: function.name.clone(),
|
||||||
input,
|
input,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::Assistant { content: blocks }
|
Message::Assistant { content: blocks }
|
||||||
@@ -178,8 +177,8 @@ pub fn content_to_blocks(field: &ContentField) -> Vec<ContentBlock> {
|
|||||||
// ponytail: 简化处理 —— URL 直接通过,data URI 拆出
|
// ponytail: 简化处理 —— URL 直接通过,data URI 拆出
|
||||||
// data:<mime>;base64,<b64> → ImageSource { data: b64, mime, is_url: false }。
|
// data:<mime>;base64,<b64> → ImageSource { data: b64, mime, is_url: false }。
|
||||||
let url = &image_url.url;
|
let url = &image_url.url;
|
||||||
if let Some(rest) = url.strip_prefix("data:") {
|
if let Some(rest) = url.strip_prefix("data:")
|
||||||
if let Some((mime, b64)) = rest.split_once(";base64,") {
|
&& let Some((mime, b64)) = rest.split_once(";base64,") {
|
||||||
return Some(ContentBlock::Image {
|
return Some(ContentBlock::Image {
|
||||||
source: crate::llm::types::message::ImageSource {
|
source: crate::llm::types::message::ImageSource {
|
||||||
data: b64.to_string(),
|
data: b64.to_string(),
|
||||||
@@ -188,7 +187,6 @@ pub fn content_to_blocks(field: &ContentField) -> Vec<ContentBlock> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Some(ContentBlock::Image {
|
Some(ContentBlock::Image {
|
||||||
source: crate::llm::types::message::ImageSource {
|
source: crate::llm::types::message::ImageSource {
|
||||||
data: url.clone(),
|
data: url.clone(),
|
||||||
@@ -255,8 +253,7 @@ pub fn blocks_to_content(blocks: &[ContentBlock]) -> ContentField {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::llm::types::message::ImageSource;
|
use crate::llm::types::shared::ImageDetail;
|
||||||
use crate::llm::types::shared::{AudioFormat, ImageDetail};
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -365,13 +362,10 @@ mod tests {
|
|||||||
assert!(matches!(content, ContentField::String(_)));
|
assert!(matches!(content, ContentField::String(_)));
|
||||||
let calls = tool_calls.expect("tool_calls");
|
let calls = tool_calls.expect("tool_calls");
|
||||||
assert_eq!(calls.len(), 1);
|
assert_eq!(calls.len(), 1);
|
||||||
if let OpenaiToolCall::Function { id, function } = &calls[0] {
|
let OpenaiToolCall::Function { id, function } = &calls[0];
|
||||||
assert_eq!(id, "call_1");
|
assert_eq!(id, "call_1");
|
||||||
assert_eq!(function.name, "search");
|
assert_eq!(function.name, "search");
|
||||||
assert!(function.arguments.contains("rust"));
|
assert!(function.arguments.contains("rust"));
|
||||||
} else {
|
|
||||||
panic!("expected Function variant");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => panic!("expected Assistant"),
|
_ => panic!("expected Assistant"),
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-8
@@ -16,11 +16,12 @@ use crate::llm::compact::{should_compact, microcompact, CompactConfig, CompactSt
|
|||||||
use crate::llm::cycle::retry::should_retry;
|
use crate::llm::cycle::retry::should_retry;
|
||||||
use crate::llm::error::LlmError;
|
use crate::llm::error::LlmError;
|
||||||
use crate::llm::hooks::{HookContext, HookExecutor};
|
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::stream::StreamEvent;
|
||||||
use crate::llm::types::message::{ContentBlock, Message};
|
use crate::llm::types::message::{ContentBlock, Message};
|
||||||
use crate::llm::types::request_v2::MessageRequest;
|
use crate::llm::types::request_v2::MessageRequest;
|
||||||
use crate::llm::types::response_v2::{MessageResponse, StopReason};
|
use crate::llm::types::response_v2::{MessageResponse, StopReason};
|
||||||
|
#[allow(deprecated)]
|
||||||
use crate::llm::types::{ToolChoice, ToolDefinition};
|
use crate::llm::types::{ToolChoice, ToolDefinition};
|
||||||
|
|
||||||
/// LLM 调用周期配置。
|
/// LLM 调用周期配置。
|
||||||
@@ -79,6 +80,7 @@ pub struct LlmCycle {
|
|||||||
compact_state: CompactState,
|
compact_state: CompactState,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(deprecated)]
|
||||||
impl LlmCycle {
|
impl LlmCycle {
|
||||||
/// 创建一个新的 LlmCycle(持有 `Box<dyn LlmProvider>` 的独占所有权)。
|
/// 创建一个新的 LlmCycle(持有 `Box<dyn LlmProvider>` 的独占所有权)。
|
||||||
///
|
///
|
||||||
@@ -175,13 +177,10 @@ impl LlmCycle {
|
|||||||
messages: Vec<Message>,
|
messages: Vec<Message>,
|
||||||
tools: Vec<ToolDefinition>,
|
tools: Vec<ToolDefinition>,
|
||||||
) -> Result<MessageResponse, LlmError> {
|
) -> Result<MessageResponse, LlmError> {
|
||||||
let ir_messages: Vec<Message> = messages;
|
|
||||||
let ir_tools: Vec<ToolDefinition> = tools.clone();
|
|
||||||
|
|
||||||
let request = MessageRequest {
|
let request = MessageRequest {
|
||||||
model: self.config.model.clone(),
|
model: self.config.model.clone(),
|
||||||
messages: ir_messages,
|
messages,
|
||||||
tools: ir_tools,
|
tools,
|
||||||
tool_choice: ToolChoice::Auto,
|
tool_choice: ToolChoice::Auto,
|
||||||
max_tokens: self.config.max_tokens,
|
max_tokens: self.config.max_tokens,
|
||||||
temperature: self.config.temperature,
|
temperature: self.config.temperature,
|
||||||
@@ -672,6 +671,7 @@ fn truncate_tool_result(s: &str, max_bytes: usize) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::llm::provider::{ProviderCapabilities, ProviderFeatures};
|
||||||
use crate::tools::{BaseTool, ToolRegistry};
|
use crate::tools::{BaseTool, ToolRegistry};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures_core::Stream;
|
use futures_core::Stream;
|
||||||
@@ -858,8 +858,10 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_submit_with_tools_max_turns_exceeded() {
|
async fn test_submit_with_tools_max_turns_exceeded() {
|
||||||
let mut config = CycleConfig::default();
|
let config = CycleConfig {
|
||||||
config.max_tool_turns = Some(2);
|
max_tool_turns: Some(2),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
let responses = vec![
|
let responses = vec![
|
||||||
assistant_tool_call_response(vec![("c1", "add", r#"{"a":1,"b":1}"#)]),
|
assistant_tool_call_response(vec![("c1", "add", r#"{"a":1,"b":1}"#)]),
|
||||||
assistant_tool_call_response(vec![("c2", "add", r#"{"a":1,"b":1}"#)]),
|
assistant_tool_call_response(vec![("c2", "add", r#"{"a":1,"b":1}"#)]),
|
||||||
|
|||||||
+15
-13
@@ -5,33 +5,35 @@ use std::time::Duration;
|
|||||||
/// 错误按可重试性分为两类:
|
/// 错误按可重试性分为两类:
|
||||||
/// - **可重试**:`RateLimit`、`Timeout`、状态码 >= 500
|
/// - **可重试**:`RateLimit`、`Timeout`、状态码 >= 500
|
||||||
/// - **不可重试**:`Authentication`、`ContextLength`、状态码 4xx(除 429)
|
/// - **不可重试**:`Authentication`、`ContextLength`、状态码 4xx(除 429)
|
||||||
|
///
|
||||||
|
/// 错误消息面向最终用户(中文),并尽量附带可操作的修复建议(如检查 API key、减少上下文)。
|
||||||
#[derive(thiserror::Error, Debug)]
|
#[derive(thiserror::Error, Debug)]
|
||||||
pub enum LlmError {
|
pub enum LlmError {
|
||||||
/// API 认证失败(如 API key 无效)。
|
/// API 认证失败(API key 无效、过期或权限不足)。
|
||||||
#[error("认证失败: {0}")]
|
#[error("LLM 认证失败: {0}。请检查环境变量中的 API key(如 OPENAI_API_KEY / ANTHROPIC_API_KEY)是否正确")]
|
||||||
Authentication(String),
|
Authentication(String),
|
||||||
|
|
||||||
/// 请求被限流,可选地附带重试等待时间。
|
/// 请求被限流,可选地附带重试等待时间。可重试。
|
||||||
#[error("限流(retry_after={retry_after:?})")]
|
#[error("LLM 限流(服务方),建议等待 {retry_after:?} 后重试")]
|
||||||
RateLimit { retry_after: Option<Duration> },
|
RateLimit { retry_after: Option<Duration> },
|
||||||
|
|
||||||
/// HTTP 请求失败,包含状态码和响应体。
|
/// HTTP 请求失败(网络错误或非 2xx 状态码),包含状态码与响应体。
|
||||||
#[error("请求失败(status={status}): {body}")]
|
#[error("LLM 请求失败(HTTP {status}): {body}。请检查 Provider 端点地址(base_url)和网络连通性")]
|
||||||
Request { status: u16, body: String },
|
Request { status: u16, body: String },
|
||||||
|
|
||||||
/// 请求超时。
|
/// 请求超时。可重试。
|
||||||
#[error("请求超时(duration={duration:?})")]
|
#[error("LLM 请求超时({duration:?})。请检查网络连接,或调大 LlmCycle 超时配置")]
|
||||||
Timeout { duration: Duration },
|
Timeout { duration: Duration },
|
||||||
|
|
||||||
/// 流式响应处理错误(预留)。
|
/// 流式响应处理错误(SSE 解析失败、流中断等)。可重试。
|
||||||
#[error("流式响应错误: {0}")]
|
#[error("LLM 流式响应错误: {0}。可重试或改用非流式接口")]
|
||||||
Stream(String),
|
Stream(String),
|
||||||
|
|
||||||
/// 上下文长度超限。
|
/// 上下文长度超出模型窗口限制。
|
||||||
#[error("上下文超限(actual={actual}, limit={limit})")]
|
#[error("LLM 上下文超限:当前 {actual} tokens > 模型上限 {limit} tokens。请减少消息历史、缩短 prompt,或启用 auto-compaction(llm::compact)")]
|
||||||
ContextLength { actual: u32, limit: u32 },
|
ContextLength { actual: u32, limit: u32 },
|
||||||
|
|
||||||
/// 其他未分类的 LLM 调用失败。
|
/// 其他未分类的 LLM 调用失败。
|
||||||
#[error("LLM 调用失败: {0}")]
|
#[error("LLM 调用失败: {0}")]
|
||||||
Other(String),
|
Other(String),
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,7 @@ impl<'a> HookContext<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 设置 plan step 序号(仅 OnPlanStepComplete 使用,Phase 4b 新增)。
|
/// 设置 plan step 序号(仅 OnPlanStepComplete 使用,Phase 4b 新增)。
|
||||||
|
#[allow(dead_code)]
|
||||||
pub(crate) fn with_plan_step_index(mut self, plan_step_index: usize) -> Self {
|
pub(crate) fn with_plan_step_index(mut self, plan_step_index: usize) -> Self {
|
||||||
self.plan_step_index = Some(plan_step_index);
|
self.plan_step_index = Some(plan_step_index);
|
||||||
self
|
self
|
||||||
|
|||||||
+305
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ use tracing::{debug, error, info, warn};
|
|||||||
use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
||||||
use crate::llm::error::LlmError;
|
use crate::llm::error::LlmError;
|
||||||
use crate::llm::types::message::{ContentBlock, ContentBlockType, Message};
|
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::{
|
use crate::llm::types::response_v2::{
|
||||||
MessageResponse, PartialMessageResponse, PartialUsage, StopReason, StreamEvent,
|
MessageResponse, PartialMessageResponse, PartialUsage, StopReason, StreamEvent,
|
||||||
};
|
};
|
||||||
@@ -36,6 +36,7 @@ const DEFAULT_MAX_TOKENS: u32 = 4096;
|
|||||||
pub struct AnthropicProvider {
|
pub struct AnthropicProvider {
|
||||||
http_client: Client,
|
http_client: Client,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
|
#[allow(dead_code)]
|
||||||
api_key: String,
|
api_key: String,
|
||||||
model: String,
|
model: String,
|
||||||
}
|
}
|
||||||
@@ -193,7 +194,7 @@ impl AnthropicProvider {
|
|||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Self::map_reqwest_error(e))?;
|
.map_err(Self::map_reqwest_error)?;
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
@@ -229,7 +230,7 @@ impl AnthropicProvider {
|
|||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Self::map_reqwest_error(e))?;
|
.map_err(Self::map_reqwest_error)?;
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
@@ -586,6 +587,7 @@ enum AnthropicDelta {
|
|||||||
struct AnthropicMessageDeltaInner {
|
struct AnthropicMessageDeltaInner {
|
||||||
stop_reason: Option<String>,
|
stop_reason: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[allow(dead_code)]
|
||||||
stop_sequence: Option<String>,
|
stop_sequence: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,6 +601,7 @@ pub struct AnthropicSseStream {
|
|||||||
chunks: Pin<Box<dyn Stream<Item = Result<Bytes, LlmError>> + Send>>,
|
chunks: Pin<Box<dyn Stream<Item = Result<Bytes, LlmError>> + Send>>,
|
||||||
buffer: String,
|
buffer: String,
|
||||||
partial: PartialMessageResponse,
|
partial: PartialMessageResponse,
|
||||||
|
#[allow(dead_code)]
|
||||||
next_block_index: u32,
|
next_block_index: u32,
|
||||||
saw_terminal: bool,
|
saw_terminal: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use serde_json::Value;
|
|||||||
use tracing::{debug, error, info};
|
use tracing::{debug, error, info};
|
||||||
|
|
||||||
use super::{LlmProvider, ProviderCapabilities, ProviderFeatures};
|
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::error::LlmError;
|
||||||
use crate::llm::types::message::{ContentBlock, ContentBlockType, Message};
|
use crate::llm::types::message::{ContentBlock, ContentBlockType, Message};
|
||||||
use crate::llm::types::openai_message::{ContentField, OpenaiChatMessage};
|
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: ") {
|
let data = if let Some(p) = trimmed.strip_prefix("data: ") {
|
||||||
p
|
p
|
||||||
} else if trimmed.starts_with("data:") {
|
} else if let Some(p) = trimmed.strip_prefix("data:") {
|
||||||
&trimmed[5..]
|
p
|
||||||
} else {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ use crate::llm::types::old_stream::LegacyStreamEvent;
|
|||||||
use crate::llm::types::response_v2::MessageResponse;
|
use crate::llm::types::response_v2::MessageResponse;
|
||||||
use crate::llm::types::response_v2::StopReason;
|
use crate::llm::types::response_v2::StopReason;
|
||||||
use crate::llm::types::usage::Usage;
|
use crate::llm::types::usage::Usage;
|
||||||
use crate::llm::types::{FinishReason, OpenaiChatChunk, OpenaiToolCall};
|
use crate::llm::types::{OpenaiChatChunk, OpenaiToolCall};
|
||||||
|
|
||||||
// 唯一的对外 `StreamEvent` 定义(高精度 IR 事件,来自 `response_v2`)。
|
// 唯一的对外 `StreamEvent` 定义(高精度 IR 事件,来自 `response_v2`)。
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ pub use usage::{CompletionTokensDetails, CostTracker, PromptTokensDetails, Usage
|
|||||||
// Phase 1 起移除 `ChatRequest` 别名 —— 新代码统一使用 `MessageRequest`(v2 IR)。
|
// Phase 1 起移除 `ChatRequest` 别名 —— 新代码统一使用 `MessageRequest`(v2 IR)。
|
||||||
// `ChatResponse` 结构体仍存在,作为 OpenAI `chat_inner()` 内部 wire-format 转换目标。
|
// `ChatResponse` 结构体仍存在,作为 OpenAI `chat_inner()` 内部 wire-format 转换目标。
|
||||||
/// 旧 wire-format 响应结构(保留用于 OpenAI 内部转换层)。
|
/// 旧 wire-format 响应结构(保留用于 OpenAI 内部转换层)。
|
||||||
|
#[deprecated(since = "0.1.0", note = "请改用 MessageResponse")]
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ChatResponse {
|
pub struct ChatResponse {
|
||||||
pub message: OpenaiChatMessage,
|
pub message: OpenaiChatMessage,
|
||||||
@@ -47,6 +48,7 @@ pub struct ChatResponse {
|
|||||||
pub stop_reason: Option<FinishReason>,
|
pub stop_reason: Option<FinishReason>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(deprecated)]
|
||||||
impl From<OpenaiChatResponse> for ChatResponse {
|
impl From<OpenaiChatResponse> for ChatResponse {
|
||||||
fn from(response: OpenaiChatResponse) -> Self {
|
fn from(response: OpenaiChatResponse) -> Self {
|
||||||
let message = response
|
let message = response
|
||||||
@@ -63,6 +65,7 @@ impl From<OpenaiChatResponse> for ChatResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(deprecated)]
|
||||||
impl From<ChatResponse> for OpenaiChatChunk {
|
impl From<ChatResponse> for OpenaiChatChunk {
|
||||||
fn from(response: ChatResponse) -> Self {
|
fn from(response: ChatResponse) -> Self {
|
||||||
let delta = Delta::from(response.message.clone());
|
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;
|
pub type ToolDefinition = OpenaiToolDefinition;
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ pub struct StreamOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
#[derive(Default)]
|
||||||
pub enum ToolChoice {
|
pub enum ToolChoice {
|
||||||
|
#[default]
|
||||||
None,
|
None,
|
||||||
Auto,
|
Auto,
|
||||||
Required,
|
Required,
|
||||||
@@ -20,12 +22,6 @@ pub enum ToolChoice {
|
|||||||
AllowedTools { tool_names: Vec<String> },
|
AllowedTools { tool_names: Vec<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ToolChoice {
|
|
||||||
fn default() -> Self {
|
|
||||||
// Default 行为由调用方显式选择;此处选择不暴露任何工具 —— 不暴露比错暴露安全。
|
|
||||||
ToolChoice::None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Serialize for ToolChoice {
|
impl Serialize for ToolChoice {
|
||||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
|||||||
@@ -174,8 +174,8 @@ impl ConversationMemory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref compact_config) = self.config.compact_config {
|
if let Some(ref compact_config) = self.config.compact_config
|
||||||
if should_compact(&self.messages, compact_config, &self.compact_state) {
|
&& should_compact(&self.messages, compact_config, &self.compact_state) {
|
||||||
let keep_recent = compact_config.keep_recent;
|
let keep_recent = compact_config.keep_recent;
|
||||||
let freed = microcompact(&mut self.messages, keep_recent);
|
let freed = microcompact(&mut self.messages, keep_recent);
|
||||||
if freed > 0 {
|
if freed > 0 {
|
||||||
@@ -184,7 +184,6 @@ impl ConversationMemory {
|
|||||||
let _ = self.compact_state.record_failure();
|
let _ = self.compact_state.record_failure();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +236,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let mut conv = ConversationMemory::new(store, "s1", config);
|
let mut conv = ConversationMemory::new(store, "s1", config);
|
||||||
for i in 0..5 {
|
for i in 0..5 {
|
||||||
conv.add_message(Message::user_text(&format!("msg-{i}")))
|
conv.add_message(Message::user_text(format!("msg-{i}")))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
@@ -254,7 +253,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let mut conv = ConversationMemory::new(store, "s1", config);
|
let mut conv = ConversationMemory::new(store, "s1", config);
|
||||||
for i in 0..5 {
|
for i in 0..5 {
|
||||||
conv.add_message(Message::user_text(&format!("msg-{i}")))
|
conv.add_message(Message::user_text(format!("msg-{i}")))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-6
@@ -3,21 +3,28 @@
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// 记忆系统错误枚举。
|
/// 记忆系统错误枚举。
|
||||||
|
///
|
||||||
|
/// 错误消息面向最终用户(中文),并尽量附带可操作的修复建议(如检查环境变量、重试)。
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum MemoryError {
|
pub enum MemoryError {
|
||||||
#[error("Item not found: {0}")]
|
/// 按 ID 未找到指定记忆条目。可重试——通常是 namespace 拼写错误或条目已被淘汰。
|
||||||
|
#[error("未找到记忆条目 '{0}',请检查 ID 或 namespace 是否正确")]
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
|
|
||||||
#[error("Storage error: {0}")]
|
/// 底层存储失败(磁盘满、连接断开等)。一般不可恢复,建议上层记录并告警。
|
||||||
|
#[error("存储失败: {0},请检查后端存储是否可用(磁盘 / 网络 / 权限)")]
|
||||||
Storage(String),
|
Storage(String),
|
||||||
|
|
||||||
#[error("Serialization error: {0}")]
|
/// 序列化 / 反序列化失败(数据结构变更、JSON 字段缺失等)。一般不可恢复。
|
||||||
|
#[error("序列化失败: {0},请检查数据结构兼容性或升级记忆格式")]
|
||||||
Serialization(String),
|
Serialization(String),
|
||||||
|
|
||||||
#[error("Invalid input: {0}")]
|
/// 调用方传入的参数不合法(空 ID、负数容量等)。可重试——修正参数即可。
|
||||||
|
#[error("参数无效: {0}")]
|
||||||
InvalidInput(String),
|
InvalidInput(String),
|
||||||
|
|
||||||
#[error("Retrieval error: {0}")]
|
/// 检索过程出错(索引重建失败、评分异常等)。可重试。
|
||||||
|
#[error("检索失败: {0},可重试或调整 query")]
|
||||||
RetrievalError(String),
|
RetrievalError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,4 +33,4 @@ impl MemoryError {
|
|||||||
pub fn is_recoverable(&self) -> bool {
|
pub fn is_recoverable(&self) -> bool {
|
||||||
matches!(self, Self::NotFound(_) | Self::RetrievalError(_))
|
matches!(self, Self::NotFound(_) | Self::RetrievalError(_))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+28
-21
@@ -19,7 +19,7 @@ pub const KNOWLEDGE_PREFIX: &str = "knowledge_";
|
|||||||
/// 同时维护一个 `Vec<PageIndexEntry>` 索引以加速列表遍历。
|
/// 同时维护一个 `Vec<PageIndexEntry>` 索引以加速列表遍历。
|
||||||
pub struct KnowledgeStore {
|
pub struct KnowledgeStore {
|
||||||
store: Arc<dyn MemoryStore>,
|
store: Arc<dyn MemoryStore>,
|
||||||
index: std::sync::Mutex<Vec<PageIndexEntry>>,
|
index: tokio::sync::Mutex<Vec<PageIndexEntry>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KnowledgeStore {
|
impl KnowledgeStore {
|
||||||
@@ -27,7 +27,7 @@ impl KnowledgeStore {
|
|||||||
pub fn new(store: Arc<dyn MemoryStore>) -> Self {
|
pub fn new(store: Arc<dyn MemoryStore>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
store,
|
store,
|
||||||
index: std::sync::Mutex::new(Vec::new()),
|
index: tokio::sync::Mutex::new(Vec::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ impl KnowledgeStore {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
let mut index = self.index.lock().unwrap();
|
let mut index = self.index.lock().await;
|
||||||
index.clear();
|
index.clear();
|
||||||
for item in items {
|
for item in items {
|
||||||
let page: KnowledgePage = serde_json::from_str(&item.content)
|
let page: KnowledgePage = serde_json::from_str(&item.content)
|
||||||
@@ -66,7 +66,7 @@ impl KnowledgeStore {
|
|||||||
created_at: now,
|
created_at: now,
|
||||||
};
|
};
|
||||||
self.store.save(item).await?;
|
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) {
|
if let Some(existing) = index.iter_mut().find(|e| e.id == page.id) {
|
||||||
*existing = PageIndexEntry::from(&page);
|
*existing = PageIndexEntry::from(&page);
|
||||||
@@ -106,7 +106,7 @@ impl KnowledgeStore {
|
|||||||
pub async fn delete_page(&self, id: &str) -> Result<(), MemoryError> {
|
pub async fn delete_page(&self, id: &str) -> Result<(), MemoryError> {
|
||||||
let full_id = format!("{KNOWLEDGE_PREFIX}{id}");
|
let full_id = format!("{KNOWLEDGE_PREFIX}{id}");
|
||||||
self.store.delete(&full_id).await?;
|
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);
|
index.retain(|e| e.id != id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -120,24 +120,31 @@ impl KnowledgeStore {
|
|||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let needle = query.to_lowercase();
|
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 mut results = Vec::new();
|
||||||
let index = self.index.lock().unwrap();
|
for id in &ids {
|
||||||
for entry in index.iter() {
|
if let Some(page) = self.get_page(id).await? {
|
||||||
if entry.title.to_lowercase().contains(&needle)
|
results.push(page);
|
||||||
|| 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取内容目录(所有页面的轻量级索引条目)。
|
/// 获取内容目录(所有页面的轻量级索引条目)。
|
||||||
pub fn get_index(&self) -> Vec<PageIndexEntry> {
|
pub async fn get_index(&self) -> Vec<PageIndexEntry> {
|
||||||
self.index.lock().unwrap().clone()
|
self.index.lock().await.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +231,7 @@ mod tests {
|
|||||||
let ks = KnowledgeStore::new(store);
|
let ks = KnowledgeStore::new(store);
|
||||||
ks.add_page(make_page("p1", "A", &[])).await.unwrap();
|
ks.add_page(make_page("p1", "A", &[])).await.unwrap();
|
||||||
ks.add_page(make_page("p2", "B", &[])).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);
|
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("p1", "A", &[])).await.unwrap();
|
||||||
ks.add_page(make_page("p2", "B", &[])).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 漂移:清空后重建
|
// 模拟 index 漂移:清空后重建
|
||||||
ks.index.lock().unwrap().clear();
|
ks.index.lock().await.clear();
|
||||||
assert_eq!(ks.get_index().len(), 0);
|
assert_eq!(ks.get_index().await.len(), 0);
|
||||||
|
|
||||||
ks.rebuild_index().await.unwrap();
|
ks.rebuild_index().await.unwrap();
|
||||||
assert_eq!(ks.get_index().len(), 2);
|
assert_eq!(ks.get_index().await.len(), 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,9 +113,7 @@ fn extract_keywords(query: &str, stop_words: &HashSet<String>) -> Vec<String> {
|
|||||||
.split(|c: char| !c.is_alphanumeric())
|
.split(|c: char| !c.is_alphanumeric())
|
||||||
.filter_map(|s| {
|
.filter_map(|s| {
|
||||||
let lower = s.to_lowercase();
|
let lower = s.to_lowercase();
|
||||||
if lower.is_empty() || lower.chars().count() < 2 {
|
if lower.is_empty() || lower.chars().count() < 2 || stop_words.contains(&lower) {
|
||||||
None
|
|
||||||
} else if stop_words.contains(&lower) {
|
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(lower)
|
Some(lower)
|
||||||
|
|||||||
+1
-1
@@ -4,4 +4,4 @@ pub mod composer;
|
|||||||
|
|
||||||
pub use error::PromptError;
|
pub use error::PromptError;
|
||||||
pub use template::{PromptTemplate, PromptTemplateRegistry, TemplateContext, TemplateValue};
|
pub use template::{PromptTemplate, PromptTemplateRegistry, TemplateContext, TemplateValue};
|
||||||
pub use composer::PromptComposer;
|
pub use composer::{validate_messages, PromptComposer};
|
||||||
|
|||||||
+63
-128
@@ -1,12 +1,11 @@
|
|||||||
use crate::llm::types::openai_message::{ContentField, OpenaiChatMessage, OpenaiContentPart};
|
use crate::llm::types::message::{ContentBlock, Message};
|
||||||
use crate::llm::types::request::OpenaiChatRequest;
|
|
||||||
use crate::prompt::error::PromptError;
|
use crate::prompt::error::PromptError;
|
||||||
use crate::prompt::template::{PromptTemplate, TemplateContext};
|
use crate::prompt::template::{PromptTemplate, TemplateContext};
|
||||||
|
|
||||||
/// 提示词组合器——构建多角色消息序列。
|
/// 提示词组合器——构建多角色消息序列。
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct PromptComposer {
|
pub struct PromptComposer {
|
||||||
messages: Vec<OpenaiChatMessage>,
|
messages: Vec<Message>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PromptComposer {
|
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 }
|
Self { messages }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,34 +23,32 @@ impl PromptComposer {
|
|||||||
|
|
||||||
/// 添加一条纯文本 system 消息。
|
/// 添加一条纯文本 system 消息。
|
||||||
pub fn system(mut self, text: impl Into<String>) -> Self {
|
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
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加一条纯文本 user 消息。
|
/// 添加一条纯文本 user 消息。
|
||||||
pub fn user(mut self, text: impl Into<String>) -> Self {
|
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
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加一条纯文本 assistant 消息。
|
/// 添加一条纯文本 assistant 消息。
|
||||||
pub fn assistant(mut self, text: impl Into<String>) -> Self {
|
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
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加一条纯文本 developer 消息(o1 系列模型使用)。
|
/// 添加一条纯文本 developer 消息(o1 系列模型使用)。
|
||||||
|
/// IR 层统一映射为 `Message::System`,由 Provider 在发送时按目标模型决定 `role`。
|
||||||
pub fn developer(mut self, text: impl Into<String>) -> Self {
|
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
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加一条 Tool 消息(工具执行结果回传)。
|
/// 添加一条 Tool 消息(工具执行结果回传)。
|
||||||
pub fn tool(mut self, tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
|
pub fn tool(mut self, tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
|
||||||
self.push_message(OpenaiChatMessage::tool_result(
|
self.push_message(Message::tool_result(tool_call_id.into(), content.into(), false));
|
||||||
tool_call_id.into(),
|
|
||||||
content.into(),
|
|
||||||
));
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +61,7 @@ impl PromptComposer {
|
|||||||
ctx: &TemplateContext,
|
ctx: &TemplateContext,
|
||||||
) -> Result<Self, PromptError> {
|
) -> Result<Self, PromptError> {
|
||||||
let text = template.render(ctx)?;
|
let text = template.render(ctx)?;
|
||||||
self.push_message(OpenaiChatMessage::user_text(text));
|
self.push_message(Message::user_text(text));
|
||||||
Ok(self)
|
Ok(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +72,7 @@ impl PromptComposer {
|
|||||||
ctx: &TemplateContext,
|
ctx: &TemplateContext,
|
||||||
) -> Result<Self, PromptError> {
|
) -> Result<Self, PromptError> {
|
||||||
let text = template.render(ctx)?;
|
let text = template.render(ctx)?;
|
||||||
self.push_message(OpenaiChatMessage::system_text(text));
|
self.push_message(Message::system(text));
|
||||||
Ok(self)
|
Ok(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +83,7 @@ impl PromptComposer {
|
|||||||
ctx: &TemplateContext,
|
ctx: &TemplateContext,
|
||||||
) -> Result<Self, PromptError> {
|
) -> Result<Self, PromptError> {
|
||||||
let text = template.render(ctx)?;
|
let text = template.render(ctx)?;
|
||||||
self.push_message(OpenaiChatMessage::assistant_text(text));
|
self.push_message(Message::assistant(text));
|
||||||
Ok(self)
|
Ok(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,150 +94,98 @@ impl PromptComposer {
|
|||||||
ctx: &TemplateContext,
|
ctx: &TemplateContext,
|
||||||
) -> Result<Self, PromptError> {
|
) -> Result<Self, PromptError> {
|
||||||
let text = template.render(ctx)?;
|
let text = template.render(ctx)?;
|
||||||
self.push_message(OpenaiChatMessage::developer_text(text));
|
self.push_message(Message::system(text));
|
||||||
Ok(self)
|
Ok(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 多模态 ContentPart =====
|
// ===== 多模态 ContentBlock =====
|
||||||
|
|
||||||
/// 添加一条含指定 ContentPart 的 system 消息。
|
/// 添加一条含指定 ContentBlock 的 system 消息。
|
||||||
pub fn system_content(mut self, part: OpenaiContentPart) -> Self {
|
pub fn system_content(mut self, block: ContentBlock) -> Self {
|
||||||
self.push_message(OpenaiChatMessage::System {
|
self.push_message(Message::System {
|
||||||
content: ContentField::Array(vec![part]),
|
content: vec![block],
|
||||||
name: None,
|
|
||||||
});
|
});
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加一条含指定 ContentPart 的 user 消息。
|
/// 添加一条含指定 ContentBlock 的 user 消息。
|
||||||
pub fn user_content(mut self, part: OpenaiContentPart) -> Self {
|
pub fn user_content(mut self, block: ContentBlock) -> Self {
|
||||||
self.push_message(OpenaiChatMessage::User {
|
self.push_message(Message::User {
|
||||||
content: ContentField::Array(vec![part]),
|
content: vec![block],
|
||||||
name: None,
|
|
||||||
});
|
});
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加一条含指定 ContentPart 的 assistant 消息。
|
/// 添加一条含指定 ContentBlock 的 assistant 消息。
|
||||||
pub fn assistant_content(mut self, part: OpenaiContentPart) -> Self {
|
pub fn assistant_content(mut self, block: ContentBlock) -> Self {
|
||||||
self.push_message(OpenaiChatMessage::Assistant {
|
self.push_message(Message::Assistant {
|
||||||
content: ContentField::Array(vec![part]),
|
content: vec![block],
|
||||||
refusal: None,
|
|
||||||
name: None,
|
|
||||||
tool_calls: None,
|
|
||||||
});
|
});
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加一条含指定 ContentPart 的 developer 消息。
|
/// 添加一条含指定 ContentBlock 的 developer 消息。
|
||||||
pub fn developer_content(mut self, part: OpenaiContentPart) -> Self {
|
pub fn developer_content(mut self, block: ContentBlock) -> Self {
|
||||||
self.push_message(OpenaiChatMessage::Developer {
|
self.push_message(Message::System {
|
||||||
content: ContentField::Array(vec![part]),
|
content: vec![block],
|
||||||
name: None,
|
|
||||||
});
|
});
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加一条含指定 ContentPart 的 Tool 消息。
|
/// 添加一条含指定 ContentBlock 的 Tool 消息。
|
||||||
pub fn tool_content(
|
pub fn tool_content(
|
||||||
mut self,
|
mut self,
|
||||||
tool_call_id: impl Into<String>,
|
tool_call_id: impl Into<String>,
|
||||||
part: OpenaiContentPart,
|
block: ContentBlock,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
self.push_message(OpenaiChatMessage::Tool {
|
self.push_message(Message::ToolResult {
|
||||||
content: ContentField::Array(vec![part]),
|
|
||||||
tool_call_id: tool_call_id.into(),
|
tool_call_id: tool_call_id.into(),
|
||||||
|
content: vec![block],
|
||||||
|
is_error: false,
|
||||||
});
|
});
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 批量添加 ContentPart 作为 user 消息。
|
/// 批量添加 ContentBlock 作为 user 消息。
|
||||||
pub fn user_contents(mut self, parts: Vec<OpenaiContentPart>) -> Self {
|
pub fn user_contents(mut self, blocks: Vec<ContentBlock>) -> Self {
|
||||||
self.push_message(OpenaiChatMessage::User {
|
self.push_message(Message::User { content: blocks });
|
||||||
content: ContentField::Array(parts),
|
|
||||||
name: None,
|
|
||||||
});
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 批量添加 ContentPart 作为 system 消息。
|
/// 批量添加 ContentBlock 作为 system 消息。
|
||||||
pub fn system_contents(mut self, parts: Vec<OpenaiContentPart>) -> Self {
|
pub fn system_contents(mut self, blocks: Vec<ContentBlock>) -> Self {
|
||||||
self.push_message(OpenaiChatMessage::System {
|
self.push_message(Message::System { content: blocks });
|
||||||
content: ContentField::Array(parts),
|
|
||||||
name: None,
|
|
||||||
});
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 批量添加 ContentPart 作为 assistant 消息。
|
/// 批量添加 ContentBlock 作为 assistant 消息。
|
||||||
pub fn assistant_contents(mut self, parts: Vec<OpenaiContentPart>) -> Self {
|
pub fn assistant_contents(mut self, blocks: Vec<ContentBlock>) -> Self {
|
||||||
self.push_message(OpenaiChatMessage::Assistant {
|
self.push_message(Message::Assistant { content: blocks });
|
||||||
content: ContentField::Array(parts),
|
|
||||||
refusal: None,
|
|
||||||
name: None,
|
|
||||||
tool_calls: None,
|
|
||||||
});
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 批量添加 ContentPart 作为 developer 消息。
|
/// 批量添加 ContentBlock 作为 developer 消息。
|
||||||
pub fn developer_contents(mut self, parts: Vec<OpenaiContentPart>) -> Self {
|
pub fn developer_contents(mut self, blocks: Vec<ContentBlock>) -> Self {
|
||||||
self.push_message(OpenaiChatMessage::Developer {
|
self.push_message(Message::System { content: blocks });
|
||||||
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);
|
|
||||||
}
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 构建 =====
|
// ===== 构建 =====
|
||||||
|
|
||||||
/// 构建最终的消息列表。
|
/// 构建最终的消息列表。
|
||||||
pub fn build(self) -> Vec<OpenaiChatMessage> {
|
pub fn build(self) -> Vec<Message> {
|
||||||
self.messages
|
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);
|
self.messages.push(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_message_name(msg: &mut OpenaiChatMessage, name: String) {
|
/// 验证消息序列是否符合 LLM API 要求(Tool 消息必须紧跟含 tool_calls 的 Assistant)。
|
||||||
match msg {
|
pub fn validate_messages(messages: &[Message]) -> Result<(), PromptError> {
|
||||||
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> {
|
|
||||||
if messages.is_empty() {
|
if messages.is_empty() {
|
||||||
return Err(PromptError::InvalidSequence(
|
return Err(PromptError::InvalidSequence(
|
||||||
"消息列表不能为空".to_string(),
|
"消息列表不能为空".to_string(),
|
||||||
@@ -251,10 +196,7 @@ pub fn validate_messages(messages: &[OpenaiChatMessage]) -> Result<(), PromptErr
|
|||||||
|
|
||||||
for (i, msg) in messages.iter().enumerate() {
|
for (i, msg) in messages.iter().enumerate() {
|
||||||
match msg {
|
match msg {
|
||||||
OpenaiChatMessage::Tool {
|
Message::ToolResult { tool_call_id, .. } => {
|
||||||
tool_call_id,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
if last_tool_call_ids.is_empty() {
|
if last_tool_call_ids.is_empty() {
|
||||||
return Err(PromptError::InvalidSequence(format!(
|
return Err(PromptError::InvalidSequence(format!(
|
||||||
"消息[{i}] Tool 消息前必须有 Assistant 消息且含 tool_calls"
|
"消息[{i}] Tool 消息前必须有 Assistant 消息且含 tool_calls"
|
||||||
@@ -267,21 +209,14 @@ pub fn validate_messages(messages: &[OpenaiChatMessage]) -> Result<(), PromptErr
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OpenaiChatMessage::Assistant {
|
Message::Assistant { content } => {
|
||||||
tool_calls: Some(calls),
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
last_tool_call_ids.clear();
|
last_tool_call_ids.clear();
|
||||||
for call in calls {
|
for block in content {
|
||||||
let crate::llm::types::OpenaiToolCall::Function { id, .. } = call;
|
if let ContentBlock::ToolUse { id, .. } = block {
|
||||||
last_tool_call_ids.push(id.clone());
|
last_tool_call_ids.push(id.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OpenaiChatMessage::Assistant {
|
|
||||||
tool_calls: None, ..
|
|
||||||
} => {
|
|
||||||
last_tool_call_ids.clear();
|
|
||||||
}
|
|
||||||
_ => {
|
_ => {
|
||||||
last_tool_call_ids.clear();
|
last_tool_call_ids.clear();
|
||||||
}
|
}
|
||||||
@@ -318,18 +253,18 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(msgs.len(), 4);
|
assert_eq!(msgs.len(), 4);
|
||||||
match &msgs[3] {
|
match &msgs[3] {
|
||||||
OpenaiChatMessage::Tool {
|
Message::ToolResult {
|
||||||
tool_call_id,
|
tool_call_id,
|
||||||
content,
|
content,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(tool_call_id, "call_123");
|
assert_eq!(tool_call_id, "call_123");
|
||||||
match content {
|
match &content[0] {
|
||||||
ContentField::String(s) => assert_eq!(s, "Sunny, 25°C"),
|
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]
|
#[test]
|
||||||
fn test_validate_messages_empty() {
|
fn test_validate_messages_empty() {
|
||||||
let msgs: Vec<OpenaiChatMessage> = vec![];
|
let msgs: Vec<Message> = vec![];
|
||||||
assert!(validate_messages(&msgs).is_err());
|
assert!(validate_messages(&msgs).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -2,27 +2,27 @@ use thiserror::Error;
|
|||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum PromptError {
|
pub enum PromptError {
|
||||||
#[error("模板解析错误: {0}")]
|
#[error("模板解析错误: {0}。请检查模板语法({{var}} / {{#if}} / {{#each}})")]
|
||||||
Parse(String),
|
Parse(String),
|
||||||
|
|
||||||
#[error("渲染错误: 变量 '{0}' 未找到")]
|
#[error("渲染错误: 变量 '{0}' 未找到。请在 TemplateContext 中插入该变量")]
|
||||||
VariableNotFound(String),
|
VariableNotFound(String),
|
||||||
|
|
||||||
#[error("渲染错误: 引用的子模板 '{0}' 未注册")]
|
#[error("渲染错误: 引用的子模板 '{0}' 未注册。请先用 PromptTemplateRegistry::register 注册该子模板")]
|
||||||
PartialNotFound(String),
|
PartialNotFound(String),
|
||||||
|
|
||||||
#[error("渲染错误: '{0}' 不是数组,无法遍历")]
|
#[error("渲染错误: '{0}' 不是数组,无法遍历。请确认传入的是数组或先判空")]
|
||||||
NotAnArray(String),
|
NotAnArray(String),
|
||||||
|
|
||||||
#[error("渲染递归超过最大深度限制 ({0})")]
|
#[error("渲染递归超过最大深度限制 ({0})。请检查是否有循环 include,或调大 MAX_DEPTH")]
|
||||||
MaxDepthReached(u8),
|
MaxDepthReached(u8),
|
||||||
|
|
||||||
#[error("渲染错误: {0}")]
|
#[error("渲染错误: {0}")]
|
||||||
Render(String),
|
Render(String),
|
||||||
|
|
||||||
#[error("消息序列校验失败: {0}")]
|
#[error("消息序列校验失败: {0}。请检查消息角色顺序(例如 tool 必须在 assistant tool_call 之后)")]
|
||||||
InvalidSequence(String),
|
InvalidSequence(String),
|
||||||
|
|
||||||
#[error("文件读取错误: {0}")]
|
#[error("文件读取错误: {0}。请检查模板文件路径与权限")]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-13
@@ -5,44 +5,44 @@ use std::sync::Arc;
|
|||||||
/// 工具调用过程中可能发生的所有错误。
|
/// 工具调用过程中可能发生的所有错误。
|
||||||
#[derive(thiserror::Error, Debug, Clone)]
|
#[derive(thiserror::Error, Debug, Clone)]
|
||||||
pub enum ToolError {
|
pub enum ToolError {
|
||||||
/// 工具未注册。
|
/// 工具未注册。不可恢复——需调用方先 `registry.register(...)`。
|
||||||
#[error("工具 '{0}' 未注册")]
|
#[error("工具 '{0}' 未注册。请先用 ToolRegistry::register(...) 注册该工具,或检查 LLM 输出的工具名拼写")]
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
|
|
||||||
/// 工具执行失败(可恢复——文本回传 LLM)。
|
/// 工具执行失败(可恢复——文本回传 LLM 由其决定重试或放弃)。
|
||||||
#[error("工具 '{0}' 执行失败: {1}")]
|
#[error("工具 '{0}' 执行失败: {1}。可让 LLM 调整参数后重试,或切换到备选工具")]
|
||||||
ExecutionFailed(String, String),
|
ExecutionFailed(String, String),
|
||||||
|
|
||||||
/// 工具参数无效(可恢复——文本回传 LLM)。
|
/// 工具参数无效(可恢复——文本回传 LLM)。
|
||||||
#[error("工具 '{0}' 参数无效: {1}")]
|
#[error("工具 '{0}' 参数无效: {1}。请检查 LLM 输出的参数是否符合 BaseTool::parameters() 声明的 JSON Schema")]
|
||||||
InvalidArguments(String, String),
|
InvalidArguments(String, String),
|
||||||
|
|
||||||
/// 权限被拒绝(不可恢复——终止循环)。
|
/// 权限被拒绝(不可恢复——终止循环)。
|
||||||
#[error("权限被拒绝: 工具 '{0}' 需要 {1} 权限")]
|
#[error("权限被拒绝: 工具 '{0}' 需要 {1} 权限。请在 PermissionConfig 中显式允许,或人工确认后绕过")]
|
||||||
PermissionDenied(String, String),
|
PermissionDenied(String, String),
|
||||||
|
|
||||||
/// MCP 协议错误(不可恢复)。
|
/// MCP 协议错误(不可恢复)。
|
||||||
#[error("MCP 协议错误: {0}")]
|
#[error("MCP 协议错误: {0}。请检查 MCP server 版本与本库兼容,或查看 server 日志")]
|
||||||
McpError(String),
|
McpError(String),
|
||||||
|
|
||||||
/// MCP 未初始化(不可恢复)。
|
/// MCP 未初始化(不可恢复)。
|
||||||
#[error("MCP 未初始化: {0}")]
|
#[error("MCP 未初始化: {0}。请先调用 McpClient::initialize() 完成握手")]
|
||||||
McpNotInitialized(String),
|
McpNotInitialized(String),
|
||||||
|
|
||||||
/// MCP 超时(不可恢复)。
|
/// MCP 超时(不可恢复)。
|
||||||
#[error("MCP 超时: {0}")]
|
#[error("MCP 超时: {0}。请检查 MCP server 是否存活,或调大超时配置")]
|
||||||
McpTimeout(String),
|
McpTimeout(String),
|
||||||
|
|
||||||
/// IO 错误(不可恢复)。
|
/// IO 错误(不可恢复)。
|
||||||
#[error("IO 错误: {0}")]
|
#[error("IO 错误: {0}。请检查文件路径、权限或磁盘空间")]
|
||||||
Io(Arc<std::io::Error>),
|
Io(Arc<std::io::Error>),
|
||||||
|
|
||||||
/// 取消。
|
/// 工具执行被取消。
|
||||||
#[error("工具执行已取消: {0}")]
|
#[error("工具执行已取消: {0}")]
|
||||||
Cancelled(String),
|
Cancelled(String),
|
||||||
|
|
||||||
/// 其他未分类错误。
|
/// 其他未分类错误。
|
||||||
#[error("其他错误: {0}")]
|
#[error("工具调用错误: {0}")]
|
||||||
Other(String),
|
Other(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_io_is_not_recoverable() {
|
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);
|
let err = ToolError::from(io_err);
|
||||||
assert!(!err.is_recoverable());
|
assert!(!err.is_recoverable());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|||||||
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
|
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
|
||||||
use tokio::sync::{oneshot, Mutex};
|
use tokio::sync::{oneshot, Mutex};
|
||||||
|
|
||||||
|
#[allow(deprecated)]
|
||||||
use crate::llm::types::ToolDefinition;
|
use crate::llm::types::ToolDefinition;
|
||||||
use crate::tools::base::{BaseTool, ToolContext, ToolRef};
|
use crate::tools::base::{BaseTool, ToolContext, ToolRef};
|
||||||
use crate::tools::error::ToolError;
|
use crate::tools::error::ToolError;
|
||||||
@@ -135,6 +136,7 @@ impl std::fmt::Debug for McpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(deprecated)]
|
||||||
impl McpClient {
|
impl McpClient {
|
||||||
/// 创建一个 MCP 客户端。
|
/// 创建一个 MCP 客户端。
|
||||||
pub fn new(server_name: impl Into<String>, transport: McpTransport) -> Self {
|
pub fn new(server_name: impl Into<String>, transport: McpTransport) -> Self {
|
||||||
@@ -540,6 +542,7 @@ enum McpClientHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
#[allow(deprecated)]
|
||||||
impl BaseTool for McpToolAdapter {
|
impl BaseTool for McpToolAdapter {
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
&self.name
|
&self.name
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use std::time::Duration;
|
|||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[allow(deprecated)]
|
||||||
use crate::llm::types::ToolDefinition;
|
use crate::llm::types::ToolDefinition;
|
||||||
use crate::tools::base::{ToolContext, ToolRef};
|
use crate::tools::base::{ToolContext, ToolRef};
|
||||||
use crate::tools::error::ToolError;
|
use crate::tools::error::ToolError;
|
||||||
@@ -70,6 +71,7 @@ impl std::fmt::Debug for ToolRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(deprecated)]
|
||||||
impl ToolRegistry {
|
impl ToolRegistry {
|
||||||
/// 创建一个新的工具注册表。
|
/// 创建一个新的工具注册表。
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
|
|||||||
Reference in New Issue
Block a user